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:
commit
579528edf7
197 files changed
+25022
No files matched your search
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SearchableSelect } from "@/components/searchable-select";
|
||||
import { AddressAutocomplete } from "@/components/address-autocomplete";
|
||||
|
||||
interface Draft {
|
||||
id: string;
|
||||
fromEmail: string;
|
||||
fromName: string | null;
|
||||
subject: string;
|
||||
rawBody: string;
|
||||
extractedCargoDescription: string | null;
|
||||
extractedLengthMm: number | null;
|
||||
extractedWidthMm: number | null;
|
||||
extractedHeightMm: number | null;
|
||||
extractedWeightKg: number | null;
|
||||
extractedRouteFrom: string | null;
|
||||
extractedRouteTo: string | null;
|
||||
extractedDeadline: string | null;
|
||||
confidence: string;
|
||||
}
|
||||
|
||||
const CONFIDENCE_LABEL: Record<string, string> = { high: "Высокая", medium: "Средняя", low: "Низкая" };
|
||||
|
||||
export function DraftReviewCard({
|
||||
draft,
|
||||
customers,
|
||||
managers,
|
||||
currentUserId,
|
||||
}: {
|
||||
draft: Draft;
|
||||
customers: { id: string; name: string }[];
|
||||
managers: { id: string; name: string }[];
|
||||
currentUserId: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [customerId, setCustomerId] = useState(customers[0]?.id ?? "");
|
||||
const [managerId, setManagerId] = useState(currentUserId);
|
||||
const [cargoDescription, setCargoDescription] = useState(draft.extractedCargoDescription ?? "");
|
||||
const [routeFrom, setRouteFrom] = useState(draft.extractedRouteFrom ?? "");
|
||||
const [routeTo, setRouteTo] = useState(draft.extractedRouteTo ?? "");
|
||||
const [customsPlace, setCustomsPlace] = useState("");
|
||||
const [deadline, setDeadline] = useState(draft.extractedDeadline ?? "");
|
||||
const [loading, setLoading] = useState<"accept" | "reject" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function accept() {
|
||||
setLoading("accept");
|
||||
setError(null);
|
||||
const res = await fetch(`/api/email-drafts/${draft.id}/accept`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
customerId,
|
||||
managerId,
|
||||
cargoDescription,
|
||||
routeFrom,
|
||||
routeTo,
|
||||
customsPlace: customsPlace || undefined,
|
||||
deadline: deadline || undefined,
|
||||
lengthMm: draft.extractedLengthMm ?? undefined,
|
||||
widthMm: draft.extractedWidthMm ?? undefined,
|
||||
heightMm: draft.extractedHeightMm ?? undefined,
|
||||
weightKg: draft.extractedWeightKg ?? undefined,
|
||||
}),
|
||||
});
|
||||
setLoading(null);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setError(data?.error ?? "Не удалось создать запрос");
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function reject() {
|
||||
setLoading("reject");
|
||||
await fetch(`/api/email-drafts/${draft.id}/reject`, { method: "POST" });
|
||||
setLoading(null);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
if (customers.length === 0 || managers.length === 0) {
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<p className="text-sm text-text-faint">
|
||||
Нужны хотя бы один клиент и один менеджер в справочниках, чтобы подтвердить черновик.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{draft.subject}</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{draft.fromName} <{draft.fromEmail}>
|
||||
</p>
|
||||
</div>
|
||||
<span className="badge badge-new">Уверенность: {CONFIDENCE_LABEL[draft.confidence]}</span>
|
||||
</div>
|
||||
|
||||
<details className="mb-3 text-xs text-text-faint">
|
||||
<summary className="cursor-pointer">Исходное письмо</summary>
|
||||
<p className="mt-1 whitespace-pre-wrap">{draft.rawBody}</p>
|
||||
</details>
|
||||
|
||||
<div className="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Клиент</span>
|
||||
<SearchableSelect
|
||||
value={customerId}
|
||||
onChange={setCustomerId}
|
||||
options={customers.map((c) => ({ id: c.id, label: c.name }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Менеджер</span>
|
||||
<select
|
||||
value={managerId}
|
||||
onChange={(e) => setManagerId(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||
>
|
||||
{managers.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm sm:col-span-2">
|
||||
<span className="mb-1 block font-medium text-text-muted">Груз</span>
|
||||
<input
|
||||
value={cargoDescription}
|
||||
onChange={(e) => setCargoDescription(e.target.value)}
|
||||
className="w-full 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>
|
||||
<AddressAutocomplete value={routeFrom} onChange={setRouteFrom} />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="mb-1 block font-medium text-text-muted">Место выгрузки</span>
|
||||
<AddressAutocomplete value={routeTo} onChange={setRouteTo} />
|
||||
</label>
|
||||
<label className="text-sm sm:col-span-2">
|
||||
<span className="mb-1 block font-medium text-text-muted">Место таможенного оформления</span>
|
||||
<AddressAutocomplete value={customsPlace} onChange={setCustomsPlace} placeholder="если требуется" />
|
||||
</label>
|
||||
<label className="text-sm sm:col-span-2">
|
||||
<span className="mb-1 block font-medium text-text-muted">Сроки</span>
|
||||
<input
|
||||
value={deadline}
|
||||
onChange={(e) => setDeadline(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-3 text-sm text-danger-soft-text">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button onClick={accept} disabled={loading !== null || !cargoDescription || !routeFrom || !routeTo} className="btn btn-primary">
|
||||
{loading === "accept" ? "Создаём…" : "Создать запрос"}
|
||||
</button>
|
||||
<button onClick={reject} disabled={loading !== null} className="btn btn-ghost">
|
||||
{loading === "reject" ? "…" : "Отклонить"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user