Add Lead -> Request -> Order funnel redesign (Phase 2)

New leads entity (own numbering VTH{yy}-{seq}, 2-stage funnel, converts
into inquiries) sitting ahead of the existing Request/Order pipeline.
Request funnel expanded from 4 to 6 statuses (kp_preparation -> kp_response
-> negotiation -> signing -> converted/declined) and Order funnel from 4
to 11 (new -> ... -> completed/declined), matching the stage names/fields/
decline-reasons from the flowcharts. Decline now always requires a reason
(+ free-text detail for "other"), reused across all three entities via one
DeclineWithReason component. Added shared deal-reference fields (Incoterms,
payment terms, carrier1-3, insurance company, customs broker) to both
inquiries and orders, carried over on conversion. Added a minimal outbound
SMTP capability (reusing the existing mailbox credentials) so order
status changes can best-effort email the client, matching the stages the
diagrams call out. Per-stage informational panels (requirements + transition
criteria) shown on each entity's detail page — structure only, no SLA-timer
automation yet (explicitly deferred, per this session's scoping).

Fixed a drizzle-kit migration-generation bug by hand: the generated
INSERT...SELECT for the inquiries table rebuild referenced brand-new
columns on the pre-migration source table, which don't exist there yet.

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 18:24:06 +00:00
1 parent e9e05d859d
commit 6cce8f890f
49 files changed
+5931 -137

No files matched your search

+25 -3
View File
@@ -19,6 +19,7 @@
"lucide-react": "^0.545.0", "lucide-react": "^0.545.0",
"mailparser": "^3.9.15", "mailparser": "^3.9.15",
"next": "16.2.12", "next": "16.2.12",
"nodemailer": "^9.1.0",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
"pizzip": "^3.2.0", "pizzip": "^3.2.0",
"react": "19.2.4", "react": "19.2.4",
@@ -32,6 +33,7 @@
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/mailparser": "^3.4.6", "@types/mailparser": "^3.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
@@ -2596,6 +2598,16 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.18", "version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
@@ -6630,6 +6642,15 @@
"tlds": "1.261.0" "tlds": "1.261.0"
} }
}, },
"node_modules/mailparser/node_modules/nodemailer": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -6911,9 +6932,10 @@
} }
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "9.0.5", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.0.tgz",
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==", "integrity": "sha512-xj1Ri5Sau3qpPffHJwi2bY0oWVVWYq62Ph17l+2v1xXpxjqTls3YPc3L8dub9mcJpxj+1ucNnuYVqLlgh4pmDA==",
"license": "MIT-0",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
} }
+2
View File
@@ -23,6 +23,7 @@
"lucide-react": "^0.545.0", "lucide-react": "^0.545.0",
"mailparser": "^3.9.15", "mailparser": "^3.9.15",
"next": "16.2.12", "next": "16.2.12",
"nodemailer": "^9.1.0",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
"pizzip": "^3.2.0", "pizzip": "^3.2.0",
"react": "19.2.4", "react": "19.2.4",
@@ -36,6 +37,7 @@
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/mailparser": "^3.4.6", "@types/mailparser": "^3.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
@@ -2,10 +2,28 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { DeclineWithReason } from "@/components/decline-with-reason";
const STATUS_OPTIONS = [
{ value: "kp_preparation", label: "Подготовить и отправить КП" },
{ value: "kp_response", label: "Получить ОС по КП" },
{ value: "negotiation", label: "Переговоры" },
{ value: "signing", label: "Подписание документов" },
];
const DECLINE_REASONS = [
{ value: "too_expensive", label: "Дорого" },
{ value: "unreachable", label: "Пропал/Не выходит на связь" },
{ value: "lost_to_competitor", label: "Ушли к конкурентам" },
{ value: "postponed", label: "Отложили заявку в обработку" },
{ value: "pricing_calc", label: "Калькуляция" },
{ value: "slow_processing", label: "Долгое время обработки запроса" },
{ value: "other", label: "Другая причина отказа" },
];
export function InquiryActions({ inquiryId, status }: { inquiryId: string; status: string }) { export function InquiryActions({ inquiryId, status }: { inquiryId: string; status: string }) {
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState<"convert" | "decline" | "answered" | null>(null); const [loading, setLoading] = useState<"convert" | "status" | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
async function convert() { async function convert() {
@@ -22,19 +40,12 @@ export function InquiryActions({ inquiryId, status }: { inquiryId: string; statu
router.push(`/orders/${order.id}`); router.push(`/orders/${order.id}`);
} }
async function decline() { async function changeStatus(next: string) {
setLoading("decline"); setLoading("status");
await fetch(`/api/inquiries/${inquiryId}/decline`, { method: "POST" }); await fetch(`/api/inquiries/${inquiryId}`, {
setLoading(null); method: "PATCH",
router.refresh();
}
async function toggleAnswered(answered: boolean) {
setLoading("answered");
await fetch(`/api/inquiries/${inquiryId}/answered`, {
method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ answered }), body: JSON.stringify({ status: next }),
}); });
setLoading(null); setLoading(null);
router.refresh(); router.refresh();
@@ -43,22 +54,31 @@ export function InquiryActions({ inquiryId, status }: { inquiryId: string; statu
if (status === "converted" || status === "declined") return null; if (status === "converted" || status === "declined") return null;
return ( return (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-col items-start gap-3">
{status === "new" ? ( <label className="flex items-center gap-2 text-sm">
<button onClick={() => toggleAnswered(true)} disabled={loading !== null} className="btn btn-ghost"> <span className="text-text-muted">Стадия</span>
{loading === "answered" ? "…" : "Отметить отвеченным"} <select
</button> value={status}
) : ( disabled={loading !== null}
<button onClick={() => toggleAnswered(false)} disabled={loading !== null} className="btn btn-ghost"> onChange={(e) => changeStatus(e.target.value)}
{loading === "answered" ? "…" : "Вернуть в «Новые»"} className="rounded-md border border-border bg-surface px-2 py-1.5 outline-none focus:border-accent"
</button> >
)} {STATUS_OPTIONS.map((o) => (
<button onClick={convert} disabled={loading !== null} className="btn btn-primary"> <option key={o.value} value={o.value}>
{loading === "convert" ? "Создаём заявку…" : "Создать заявку"} {o.label}
</button> </option>
<button onClick={decline} disabled={loading !== null} className="btn btn-ghost"> ))}
{loading === "decline" ? "…" : "Отменить запрос"} </select>
</button> </label>
<div className="flex flex-wrap items-center gap-2">
{status === "signing" && (
<button onClick={convert} disabled={loading !== null} className="btn btn-primary">
{loading === "convert" ? "Создаём заявку…" : "Создать заявку"}
</button>
)}
<DeclineWithReason url={`/api/inquiries/${inquiryId}/decline`} reasons={DECLINE_REASONS} />
</div>
{error && <p className="text-sm text-danger-soft-text">{error}</p>} {error && <p className="text-sm text-danger-soft-text">{error}</p>}
</div> </div>
); );
@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AddressAutocomplete } from "@/components/address-autocomplete"; import { AddressAutocomplete } from "@/components/address-autocomplete";
import { SearchableSelect, type SearchableOption } from "@/components/searchable-select";
interface Inquiry { interface Inquiry {
id: string; id: string;
@@ -14,9 +15,41 @@ interface Inquiry {
transportType: "consolidated" | "tent" | "trawl" | "backhaul" | "container" | null; transportType: "consolidated" | "tent" | "trawl" | "backhaul" | "container" | null;
comment: string | null; comment: string | null;
deadline: string | null; deadline: string | null;
incoterm: string | null;
paymentTerms: string | null;
prepaymentPercent: number | null;
carrierPayTerms: string | null;
receivablesDeadline: Date | string | null;
carrier1Id: string | null;
carrier2Id: string | null;
carrier3Id: string | null;
insuranceCompanyId: string | null;
customsBrokerId: string | null;
recipientContactName: string | null;
} }
export function InquiryEditForm({ inquiry }: { inquiry: Inquiry }) { const INCOTERMS = ["EXW", "FCA", "FAS", "FOB", "CFR", "CIF", "CIP", "DAP", "DPU", "DDP"];
const PAYMENT_TERMS_LABEL: Record<string, string> = {
prepayment: "Предоплата",
postpayment: "Постоплата",
partial_prepayment: "Частичная предоплата",
};
function toDateInputValue(date: Date | string | null): string {
if (!date) return "";
const d = new Date(date);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export function InquiryEditForm({
inquiry,
carriers,
counterparties,
}: {
inquiry: Inquiry;
carriers: SearchableOption[];
counterparties: SearchableOption[];
}) {
const router = useRouter(); const router = useRouter();
const [cargoDescription, setCargoDescription] = useState(inquiry.cargoDescription); const [cargoDescription, setCargoDescription] = useState(inquiry.cargoDescription);
const [routeFrom, setRouteFrom] = useState(inquiry.routeFrom); const [routeFrom, setRouteFrom] = useState(inquiry.routeFrom);
@@ -26,6 +59,17 @@ export function InquiryEditForm({ inquiry }: { inquiry: Inquiry }) {
const [transportType, setTransportType] = useState(inquiry.transportType ?? ""); const [transportType, setTransportType] = useState(inquiry.transportType ?? "");
const [comment, setComment] = useState(inquiry.comment ?? ""); const [comment, setComment] = useState(inquiry.comment ?? "");
const [deadline, setDeadline] = useState(inquiry.deadline ?? ""); const [deadline, setDeadline] = useState(inquiry.deadline ?? "");
const [incoterm, setIncoterm] = useState(inquiry.incoterm ?? "");
const [paymentTerms, setPaymentTerms] = useState(inquiry.paymentTerms ?? "");
const [prepaymentPercent, setPrepaymentPercent] = useState(inquiry.prepaymentPercent?.toString() ?? "");
const [carrierPayTerms, setCarrierPayTerms] = useState(inquiry.carrierPayTerms ?? "");
const [receivablesDeadline, setReceivablesDeadline] = useState(toDateInputValue(inquiry.receivablesDeadline));
const [carrier1Id, setCarrier1Id] = useState(inquiry.carrier1Id ?? "");
const [carrier2Id, setCarrier2Id] = useState(inquiry.carrier2Id ?? "");
const [carrier3Id, setCarrier3Id] = useState(inquiry.carrier3Id ?? "");
const [insuranceCompanyId, setInsuranceCompanyId] = useState(inquiry.insuranceCompanyId ?? "");
const [customsBrokerId, setCustomsBrokerId] = useState(inquiry.customsBrokerId ?? "");
const [recipientContactName, setRecipientContactName] = useState(inquiry.recipientContactName ?? "");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -48,6 +92,17 @@ export function InquiryEditForm({ inquiry }: { inquiry: Inquiry }) {
transportType: transportType || undefined, transportType: transportType || undefined,
comment: comment || undefined, comment: comment || undefined,
deadline: deadline || undefined, deadline: deadline || undefined,
incoterm: incoterm || undefined,
paymentTerms: paymentTerms || undefined,
prepaymentPercent: prepaymentPercent ? Number(prepaymentPercent) : undefined,
carrierPayTerms: carrierPayTerms || undefined,
receivablesDeadline: receivablesDeadline || undefined,
carrier1Id: carrier1Id || undefined,
carrier2Id: carrier2Id || undefined,
carrier3Id: carrier3Id || undefined,
insuranceCompanyId: insuranceCompanyId || undefined,
customsBrokerId: customsBrokerId || undefined,
recipientContactName: recipientContactName || undefined,
}), }),
}); });
@@ -141,6 +196,110 @@ export function InquiryEditForm({ inquiry }: { inquiry: Inquiry }) {
/> />
</label> </label>
<div className="mb-4 border-t border-border pt-3">
<p className="mb-2 text-sm font-semibold text-text-muted">Условия сделки</p>
<div className="mb-3 grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Условия отгрузки (Инкотермс)</span>
<select
value={incoterm}
onChange={(e) => setIncoterm(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
>
<option value="">—</option>
{INCOTERMS.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Условия оплаты у клиента</span>
<select
value={paymentTerms}
onChange={(e) => setPaymentTerms(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
>
<option value="">—</option>
{Object.entries(PAYMENT_TERMS_LABEL).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</label>
</div>
{paymentTerms === "partial_prepayment" && (
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Процент предоплаты</span>
<input
type="number"
min={0}
max={100}
value={prepaymentPercent}
onChange={(e) => setPrepaymentPercent(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="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Условия расчёта с подрядчиками</span>
<textarea
value={carrierPayTerms}
onChange={(e) => setCarrierPayTerms(e.target.value)}
rows={2}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
<div className="mb-3 grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Крайняя дата оплаты / дебиторки</span>
<input
type="date"
value={receivablesDeadline}
onChange={(e) => setReceivablesDeadline(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>
<input
value={recipientContactName}
onChange={(e) => setRecipientContactName(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
</div>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Подрядчик логистика 1</span>
<SearchableSelect options={carriers} value={carrier1Id} onChange={setCarrier1Id} placeholder="Выберите подрядчика" />
</label>
<div className="mb-3 grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Страховая компания</span>
<SearchableSelect options={counterparties} value={insuranceCompanyId} onChange={setInsuranceCompanyId} placeholder="Выберите" />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Таможенный брокер</span>
<SearchableSelect options={counterparties} value={customsBrokerId} onChange={setCustomsBrokerId} placeholder="Выберите" />
</label>
</div>
<details>
<summary className="cursor-pointer text-sm font-medium text-text-muted">Ещё подрядчики (скрытое)</summary>
<div className="mt-3 grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Подрядчик логистика 2</span>
<SearchableSelect options={carriers} value={carrier2Id} onChange={setCarrier2Id} placeholder="Выберите" />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Подрядчик логистика 3</span>
<SearchableSelect options={carriers} value={carrier3Id} onChange={setCarrier3Id} placeholder="Выберите" />
</label>
</div>
</details>
</div>
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>} {error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
+35 -7
View File
@@ -3,7 +3,9 @@ import Link from "next/link";
import { ArrowLeft, FileText, Download } from "lucide-react"; import { ArrowLeft, FileText, Download } from "lucide-react";
import { getCurrentSession } from "@/lib/auth/session"; import { getCurrentSession } from "@/lib/auth/session";
import { getInquiry } from "@/lib/inquiries/service"; import { getInquiry } from "@/lib/inquiries/service";
import { listCarriers } from "@/lib/counterparties/service"; import { listCarriers, listCounterparties } from "@/lib/counterparties/service";
import { INQUIRY_STAGE_INFO } from "@/lib/inquiries/stages";
import { StageInfoPanel } from "@/components/stage-info-panel";
import { AddQuoteForm } from "./add-quote-form"; import { AddQuoteForm } from "./add-quote-form";
import { InquiryActions } from "./inquiry-actions"; import { InquiryActions } from "./inquiry-actions";
import { InquiryAttachmentUpload } from "./attachment-upload"; import { InquiryAttachmentUpload } from "./attachment-upload";
@@ -11,12 +13,23 @@ import { InquiryEditForm } from "./inquiry-edit-form";
import { QuotesList } from "./quotes-list"; import { QuotesList } from "./quotes-list";
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
new: "Новый", kp_preparation: "Подготовить КП",
quoted: "Отвечен", kp_response: "Получить ОС по КП",
negotiation: "Переговоры",
signing: "Подписание документов",
converted: "В заявке", converted: "В заявке",
declined: "Отклонён", declined: "Отклонён",
}; };
const STATUS_BADGE: Record<string, string> = {
kp_preparation: "new",
kp_response: "pending",
negotiation: "pending",
signing: "open",
converted: "closed",
declined: "declined",
};
const SOURCE_LABEL: Record<string, string> = { const SOURCE_LABEL: Record<string, string> = {
email: "Почта", email: "Почта",
phone: "Телефон", phone: "Телефон",
@@ -28,7 +41,7 @@ export default async function InquiryDetailPage({ params }: { params: Promise<{
if (!session) redirect("/login"); if (!session) redirect("/login");
const { id } = await params; const { id } = await params;
const [inquiry, carriers] = await Promise.all([getInquiry(id), listCarriers()]); const [inquiry, carriers, allCounterparties] = await Promise.all([getInquiry(id), listCarriers(), listCounterparties()]);
if (!inquiry) notFound(); if (!inquiry) notFound();
return ( return (
@@ -45,9 +58,11 @@ export default async function InquiryDetailPage({ params }: { params: Promise<{
{inquiry.customer?.name} · менеджер {inquiry.manager?.name} {inquiry.customer?.name} · менеджер {inquiry.manager?.name}
</p> </p>
</div> </div>
<span className={`badge badge-${inquiry.status === "converted" ? "closed" : inquiry.status === "declined" ? "declined" : inquiry.status === "quoted" ? "open" : "new"}`}> <span className={`badge badge-${STATUS_BADGE[inquiry.status]}`}>{STATUS_LABEL[inquiry.status]}</span>
{STATUS_LABEL[inquiry.status]} </div>
</span>
<div className="mb-5">
<StageInfoPanel info={INQUIRY_STAGE_INFO[inquiry.status]} />
</div> </div>
<div className="mb-5 grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="mb-5 grid grid-cols-1 gap-4 lg:grid-cols-2">
@@ -64,7 +79,20 @@ export default async function InquiryDetailPage({ params }: { params: Promise<{
transportType: inquiry.transportType, transportType: inquiry.transportType,
comment: inquiry.comment, comment: inquiry.comment,
deadline: inquiry.deadline, deadline: inquiry.deadline,
incoterm: inquiry.incoterm,
paymentTerms: inquiry.paymentTerms,
prepaymentPercent: inquiry.prepaymentPercent,
carrierPayTerms: inquiry.carrierPayTerms,
receivablesDeadline: inquiry.receivablesDeadline,
carrier1Id: inquiry.carrier1Id,
carrier2Id: inquiry.carrier2Id,
carrier3Id: inquiry.carrier3Id,
insuranceCompanyId: inquiry.insuranceCompanyId,
customsBrokerId: inquiry.customsBrokerId,
recipientContactName: inquiry.recipientContactName,
}} }}
carriers={carriers.map((c) => ({ id: c.id, label: c.name }))}
counterparties={allCounterparties.map((c) => ({ id: c.id, label: c.name }))}
/> />
<p className="mt-3 text-xs text-text-faint">Источник: {SOURCE_LABEL[inquiry.source]}</p> <p className="mt-3 text-xs text-text-faint">Источник: {SOURCE_LABEL[inquiry.source]}</p>
+2 -2
View File
@@ -11,7 +11,7 @@ interface Quote {
interface InquiryItem { interface InquiryItem {
id: string; id: string;
number: string; number: string;
status: "new" | "quoted" | "converted" | "declined"; status: "kp_preparation" | "kp_response" | "negotiation" | "signing" | "converted" | "declined";
cargoDescription: string; cargoDescription: string;
routeFrom: string; routeFrom: string;
routeTo: string; routeTo: string;
@@ -110,7 +110,7 @@ export function InquiriesBoard({ inquiries, columns }: { inquiries: InquiryItem[
</span> </span>
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{columns.map((col) => { {columns.map((col) => {
const items = filtered.filter((i) => i.status === col.status); const items = filtered.filter((i) => i.status === col.status);
return ( return (
+5 -3
View File
@@ -9,9 +9,11 @@ import { listPendingDrafts } from "@/lib/inquiries/drafts";
import { InquiriesToolbar } from "./toolbar"; import { InquiriesToolbar } from "./toolbar";
import { InquiriesBoard } from "./board"; import { InquiriesBoard } from "./board";
const COLUMNS: { status: "new" | "quoted" | "converted" | "declined"; label: string }[] = [ const COLUMNS: { status: "kp_preparation" | "kp_response" | "negotiation" | "signing" | "converted" | "declined"; label: string }[] = [
{ status: "new", label: "Новые" }, { status: "kp_preparation", label: "Подготовить КП" },
{ status: "quoted", label: "Отвечен" }, { status: "kp_response", label: "Получить ОС по КП" },
{ status: "negotiation", label: "Переговоры" },
{ status: "signing", label: "Подписание документов" },
{ status: "converted", label: "В заявке" }, { status: "converted", label: "В заявке" },
{ status: "declined", label: "Отклонён" }, { status: "declined", label: "Отклонён" },
]; ];
+127
View File
@@ -0,0 +1,127 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { DeclineWithReason } from "@/components/decline-with-reason";
import { SearchableSelect, type SearchableOption } from "@/components/searchable-select";
const DECLINE_REASONS = [
{ value: "not_reachable", label: "Не выходит на связь" },
{ value: "not_target", label: "Нецелевой клиент" },
{ value: "spam", label: "Спам" },
{ value: "duplicate", label: "Дубль" },
{ value: "other", label: "Другая причина отказа" },
];
const SOURCE_LABEL: Record<string, string> = { email: "Почта", phone: "Телефон", messenger: "Мессенджер" };
export function LeadActions({
leadId,
status,
currentUserId,
clients,
managers,
}: {
leadId: string;
status: string;
currentUserId: string;
clients: SearchableOption[];
managers: SearchableOption[];
}) {
const router = useRouter();
const [loading, setLoading] = useState<"take" | "convert" | null>(null);
const [error, setError] = useState<string | null>(null);
const [convertOpen, setConvertOpen] = useState(false);
const [customerId, setCustomerId] = useState("");
const [managerId, setManagerId] = useState(currentUserId);
const [source, setSource] = useState<"email" | "phone" | "messenger">("phone");
async function takeInWork() {
setLoading("take");
await fetch(`/api/leads/${leadId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "in_progress", responsibleId: currentUserId }),
});
setLoading(null);
router.refresh();
}
async function convert() {
if (!customerId) {
setError("Выберите клиента");
return;
}
setLoading("convert");
setError(null);
const res = await fetch(`/api/leads/${leadId}/convert`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ customerId, managerId, source }),
});
setLoading(null);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось создать запрос");
return;
}
const inquiry = await res.json();
router.push(`/inquiries/${inquiry.id}`);
}
if (status === "converted" || status === "declined") return null;
return (
<div className="flex flex-col gap-3">
{status === "new" && (
<button onClick={takeInWork} disabled={loading !== null} className="btn btn-primary">
{loading === "take" ? "…" : "Взять в работу"}
</button>
)}
{status === "in_progress" && !convertOpen && (
<button onClick={() => setConvertOpen(true)} className="btn btn-primary">
Создать запрос
</button>
)}
{convertOpen && (
<div className="card flex flex-col gap-2 p-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Клиент</span>
<SearchableSelect options={clients} value={customerId} onChange={setCustomerId} placeholder="Выберите клиента" required />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Менеджер</span>
<SearchableSelect options={managers} value={managerId} onChange={setManagerId} placeholder="Выберите менеджера" required />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Источник запроса</span>
<select
value={source}
onChange={(e) => setSource(e.target.value as typeof source)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
>
{Object.entries(SOURCE_LABEL).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</label>
{error && <p className="text-sm text-danger-soft-text">{error}</p>}
<div className="flex gap-2">
<button onClick={convert} disabled={loading !== null} className="btn btn-primary">
{loading === "convert" ? "Создаём…" : "Подтвердить"}
</button>
<button onClick={() => setConvertOpen(false)} className="btn btn-ghost">
Отмена
</button>
</div>
</div>
)}
<DeclineWithReason url={`/api/leads/${leadId}/decline`} reasons={DECLINE_REASONS} />
</div>
);
}
@@ -0,0 +1,283 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
interface Lead {
id: string;
firstName: string | null;
lastName: string | null;
patronymic: string | null;
phone: string | null;
email: string | null;
position: string | null;
companyName: string | null;
transportMode: string | null;
transportType: string | null;
serviceTypes: string[];
cargoClassification: string[];
additionalServices: string | null;
routeFrom: string | null;
routeTo: string | null;
cargoDescription: string | null;
lengthMm: string | null;
widthMm: string | null;
heightMm: string | null;
weightKg: string | null;
volumeM3: string | null;
tnvedCode: string | null;
placesCount: number | null;
cargoCost: number | null;
comment: string | null;
}
const SERVICE_TYPES = [
{ value: "logistics", label: "Логистика" },
{ value: "warehouse", label: "Склад" },
{ value: "customs", label: "Таможня" },
{ value: "loading_works", label: "ПРР" },
{ value: "insurance", label: "Страхование" },
{ value: "repackaging", label: "Переупаковка" },
{ value: "labeling", label: "Маркировка" },
{ value: "other_services", label: "Доп. услуги" },
];
const CARGO_CLASS = [
{ value: "oversized", label: "Негабарит" },
{ value: "assembly", label: "Сборка" },
{ value: "standard", label: "Стандарт" },
];
function toggle(arr: string[], value: string): string[] {
return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value];
}
export function LeadEditForm({ lead }: { lead: Lead }) {
const router = useRouter();
const [firstName, setFirstName] = useState(lead.firstName ?? "");
const [lastName, setLastName] = useState(lead.lastName ?? "");
const [patronymic, setPatronymic] = useState(lead.patronymic ?? "");
const [phone, setPhone] = useState(lead.phone ?? "");
const [email, setEmail] = useState(lead.email ?? "");
const [position, setPosition] = useState(lead.position ?? "");
const [companyName, setCompanyName] = useState(lead.companyName ?? "");
const [transportMode, setTransportMode] = useState(lead.transportMode ?? "");
const [transportType, setTransportType] = useState(lead.transportType ?? "");
const [serviceTypes, setServiceTypes] = useState<string[]>(lead.serviceTypes);
const [cargoClassification, setCargoClassification] = useState<string[]>(lead.cargoClassification);
const [additionalServices, setAdditionalServices] = useState(lead.additionalServices ?? "");
const [routeFrom, setRouteFrom] = useState(lead.routeFrom ?? "");
const [routeTo, setRouteTo] = useState(lead.routeTo ?? "");
const [cargoDescription, setCargoDescription] = useState(lead.cargoDescription ?? "");
const [lengthMm, setLengthMm] = useState(lead.lengthMm ?? "");
const [widthMm, setWidthMm] = useState(lead.widthMm ?? "");
const [heightMm, setHeightMm] = useState(lead.heightMm ?? "");
const [weightKg, setWeightKg] = useState(lead.weightKg ?? "");
const [volumeM3, setVolumeM3] = useState(lead.volumeM3 ?? "");
const [tnvedCode, setTnvedCode] = useState(lead.tnvedCode ?? "");
const [placesCount, setPlacesCount] = useState(lead.placesCount?.toString() ?? "");
const [cargoCost, setCargoCost] = useState(lead.cargoCost?.toString() ?? "");
const [comment, setComment] = useState(lead.comment ?? "");
const [loading, setLoading] = useState(false);
const [saved, setSaved] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setSaved(false);
await fetch(`/api/leads/${lead.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
firstName: firstName || undefined,
lastName: lastName || undefined,
patronymic: patronymic || undefined,
phone: phone || undefined,
email: email || undefined,
position: position || undefined,
companyName: companyName || undefined,
transportMode: transportMode || undefined,
transportType: transportType || undefined,
serviceTypes,
cargoClassification,
additionalServices: additionalServices || undefined,
routeFrom: routeFrom || undefined,
routeTo: routeTo || undefined,
cargoDescription: cargoDescription || undefined,
lengthMm: lengthMm || undefined,
widthMm: widthMm || undefined,
heightMm: heightMm || undefined,
weightKg: weightKg || undefined,
volumeM3: volumeM3 || undefined,
tnvedCode: tnvedCode || undefined,
placesCount: placesCount ? Number(placesCount) : undefined,
cargoCost: cargoCost ? Number(cargoCost) : undefined,
comment: comment || undefined,
}),
});
setLoading(false);
setSaved(true);
router.refresh();
}
return (
<form onSubmit={handleSubmit}>
<div className="mb-3 grid grid-cols-3 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Фамилия</span>
<input value={lastName} onChange={(e) => setLastName(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>
<input value={firstName} onChange={(e) => setFirstName(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>
<input value={patronymic} onChange={(e) => setPatronymic(e.target.value)} className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent" />
</label>
</div>
<div className="mb-3 grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Телефон</span>
<input value={phone} onChange={(e) => setPhone(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">Email</span>
<input value={email} onChange={(e) => setEmail(e.target.value)} className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent" />
</label>
</div>
<div className="mb-3 grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Должность</span>
<input value={position} onChange={(e) => setPosition(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>
<input value={companyName} onChange={(e) => setCompanyName(e.target.value)} className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent" />
</label>
</div>
<div className="mb-3 grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Откуда</span>
<input value={routeFrom} onChange={(e) => setRouteFrom(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>
<input value={routeTo} onChange={(e) => setRouteTo(e.target.value)} className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent" />
</label>
</div>
<div className="mb-3 grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Вид транспорта</span>
<select value={transportMode} onChange={(e) => setTransportMode(e.target.value)} className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent">
<option value="">—</option>
<option value="road">Авто</option>
<option value="rail">ЖД</option>
<option value="sea">Море</option>
<option value="air">Авиа</option>
</select>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Тип перевозки</span>
<select value={transportType} onChange={(e) => setTransportType(e.target.value)} className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent">
<option value="">—</option>
<option value="consolidated">Сборка</option>
<option value="tent">Тент</option>
<option value="trawl">Трал</option>
<option value="backhaul">Догруз</option>
<option value="container">Контейнер</option>
</select>
</label>
</div>
<div className="mb-3">
<span className="mb-1 block text-sm font-medium text-text-muted">Виды услуг</span>
<div className="flex flex-wrap gap-2">
{SERVICE_TYPES.map((s) => (
<label key={s.value} className="flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs">
<input type="checkbox" checked={serviceTypes.includes(s.value)} onChange={() => setServiceTypes((prev) => toggle(prev, s.value))} />
{s.label}
</label>
))}
</div>
</div>
<div className="mb-3">
<span className="mb-1 block text-sm font-medium text-text-muted">Классификация груза</span>
<div className="flex flex-wrap gap-2">
{CARGO_CLASS.map((c) => (
<label key={c.value} className="flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs">
<input type="checkbox" checked={cargoClassification.includes(c.value)} onChange={() => setCargoClassification((prev) => toggle(prev, c.value))} />
{c.label}
</label>
))}
</div>
</div>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Состав груза</span>
<textarea value={cargoDescription} onChange={(e) => setCargoDescription(e.target.value)} rows={2} className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent" />
</label>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Доп. услуги</span>
<textarea value={additionalServices} onChange={(e) => setAdditionalServices(e.target.value)} rows={2} className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent" />
</label>
<div className="mb-3 grid grid-cols-3 gap-2">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Длина, мм</span>
<input value={lengthMm} onChange={(e) => setLengthMm(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>
<input value={widthMm} onChange={(e) => setWidthMm(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>
<input value={heightMm} onChange={(e) => setHeightMm(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>
<input value={weightKg} onChange={(e) => setWeightKg(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>
<input value={volumeM3} onChange={(e) => setVolumeM3(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>
<input value={tnvedCode} onChange={(e) => setTnvedCode(e.target.value)} className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent" />
</label>
</div>
<div className="mb-3 grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Количество мест</span>
<input type="number" value={placesCount} onChange={(e) => setPlacesCount(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>
<input type="number" value={cargoCost} onChange={(e) => setCargoCost(e.target.value)} className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent" />
</label>
</div>
<label className="mb-4 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Комментарий</span>
<textarea value={comment} onChange={(e) => setComment(e.target.value)} rows={2} className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent" />
</label>
<div className="flex items-center gap-3">
<button type="submit" disabled={loading} className="btn btn-primary">
{loading ? "Сохраняем…" : "Сохранить"}
</button>
{saved && <span className="text-sm text-text-muted">Сохранено</span>}
</div>
</form>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { getCurrentSession } from "@/lib/auth/session";
import { getLead } from "@/lib/leads/service";
import { listClients } from "@/lib/counterparties/service";
import { listManagers } from "@/lib/users/service";
import { LEAD_STAGE_INFO } from "@/lib/leads/stages";
import { StageInfoPanel } from "@/components/stage-info-panel";
import { LeadActions } from "./lead-actions";
import { LeadEditForm } from "./lead-edit-form";
const STATUS_LABEL: Record<string, string> = {
new: "Новый запрос",
in_progress: "Запрос в обработке",
converted: "Заявка получена",
declined: "Отклонён",
};
const STATUS_BADGE: Record<string, string> = { new: "new", in_progress: "pending", converted: "closed", declined: "declined" };
export default async function LeadDetailPage({ params }: { params: Promise<{ id: string }> }) {
const session = await getCurrentSession();
if (!session) redirect("/login");
const { id } = await params;
const [lead, clients, managers] = await Promise.all([getLead(id), listClients(), listManagers()]);
if (!lead) notFound();
return (
<div>
<Link href="/leads" className="mb-4 inline-flex items-center gap-1 text-sm text-text-muted hover:text-text">
<ArrowLeft size={14} />
Лиды
</Link>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-display text-xl font-bold tracking-tight">{lead.number}</h1>
<p className="text-sm text-text-muted">{[lead.lastName, lead.firstName].filter(Boolean).join(" ") || lead.companyName || "Без имени"}</p>
</div>
<span className={`badge badge-${STATUS_BADGE[lead.status]}`}>{STATUS_LABEL[lead.status]}</span>
</div>
<div className="mb-5">
<StageInfoPanel info={LEAD_STAGE_INFO[lead.status]} />
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="card p-4">
<h2 className="mb-3 text-sm font-semibold text-text-muted">Данные лида</h2>
<LeadEditForm lead={lead} />
</div>
<div className="card p-4">
<h2 className="mb-3 text-sm font-semibold text-text-muted">Действия</h2>
<LeadActions
leadId={lead.id}
status={lead.status}
currentUserId={session.user.id}
clients={clients.map((c) => ({ id: c.id, label: c.name }))}
managers={managers.map((m) => ({ id: m.id, label: m.name }))}
/>
{lead.status === "converted" && lead.convertedInquiryId && (
<p className="mt-3 text-sm text-text-muted">
Запрос создан:{" "}
<Link href={`/inquiries/${lead.convertedInquiryId}`} className="text-accent hover:underline">
открыть
</Link>
</p>
)}
</div>
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import Link from "next/link";
interface LeadItem {
id: string;
number: string;
status: "new" | "in_progress" | "converted" | "declined";
firstName: string | null;
lastName: string | null;
companyName: string | null;
cargoDescription: string | null;
createdAt: Date;
responsible: { name: string } | null;
}
interface Column {
status: LeadItem["status"];
label: string;
}
function toDateInputValue(date: Date): string {
const d = new Date(date);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export function LeadsBoard({ leads, columns }: { leads: LeadItem[]; columns: Column[] }) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{columns.map((col) => {
const items = leads.filter((l) => l.status === col.status);
return (
<div key={col.status}>
<h2 className="mb-2 flex items-center gap-2 text-sm font-semibold text-text-muted">
{col.label}
<span className="rounded-full bg-surface-hover px-1.5 py-0.5 text-xs">{items.length}</span>
</h2>
<div className="flex flex-col gap-2">
{items.map((lead) => (
<Link key={lead.id} href={`/leads/${lead.id}`} className="card block p-3 hover:border-accent">
<div className="mb-1 flex items-center justify-between">
<span className="font-mono text-xs text-text-faint">{lead.number}</span>
</div>
<div className="mb-1 truncate text-sm font-medium">
{[lead.lastName, lead.firstName].filter(Boolean).join(" ") || lead.companyName || "Без имени"}
</div>
{lead.cargoDescription && <div className="truncate text-xs text-text-muted">{lead.cargoDescription}</div>}
<div className="mt-1 flex items-center justify-between text-xs text-text-faint">
<span>{lead.responsible?.name ?? "—"}</span>
<span>{toDateInputValue(lead.createdAt)}</span>
</div>
</Link>
))}
{items.length === 0 && <p className="text-xs text-text-faint">Пусто</p>}
</div>
</div>
);
})}
</div>
);
}
+182
View File
@@ -0,0 +1,182 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";
const SOURCE_LABEL: Record<string, string> = {
mail: "Почта",
phone: "Телефон",
referral: "Сарафан",
cold_call: "Холодный обзвон",
website: "Сайт",
tg_bot: "ТГ бот",
};
export function NewLeadModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const router = useRouter();
const [lastName, setLastName] = useState("");
const [firstName, setFirstName] = useState("");
const [phone, setPhone] = useState("");
const [email, setEmail] = useState("");
const [companyName, setCompanyName] = useState("");
const [source, setSource] = useState("");
const [cargoDescription, setCargoDescription] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
function reset() {
setLastName("");
setFirstName("");
setPhone("");
setEmail("");
setCompanyName("");
setSource("");
setCargoDescription("");
setError(null);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await fetch("/api/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
lastName: lastName || undefined,
firstName: firstName || undefined,
phone: phone || undefined,
email: email || undefined,
companyName: companyName || undefined,
source: source || undefined,
cargoDescription: cargoDescription || undefined,
}),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось создать лид");
return;
}
const lead = await res.json();
reset();
onClose();
router.push(`/leads/${lead.id}`);
}
return (
<AnimatePresence>
{open && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.form
onClick={(e) => e.stopPropagation()}
onSubmit={handleSubmit}
initial={{ opacity: 0, y: 12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.98 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="card w-full max-w-md p-5"
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold">Новый лид</h2>
<button type="button" onClick={onClose} className="text-text-muted hover:text-text">
<X size={18} />
</button>
</div>
<div className="mb-3 grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Фамилия</span>
<input
value={lastName}
onChange={(e) => setLastName(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>
<input
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
</div>
<div className="mb-3 grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Телефон</span>
<input
value={phone}
onChange={(e) => setPhone(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">Email</span>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
</div>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Компания</span>
<input
value={companyName}
onChange={(e) => setCompanyName(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="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Источник</span>
<select
value={source}
onChange={(e) => setSource(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
>
<option value="">—</option>
{Object.entries(SOURCE_LABEL).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</label>
<label className="mb-4 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Что интересует / состав груза</span>
<textarea
value={cargoDescription}
onChange={(e) => setCargoDescription(e.target.value)}
rows={2}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
{error && <p className="mb-3 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center">
{loading ? "Создаём…" : "Создать"}
</button>
</motion.form>
</motion.div>
)}
</AnimatePresence>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { listLeads } from "@/lib/leads/service";
import { LeadsToolbar } from "./toolbar";
import { LeadsBoard } from "./board";
const COLUMNS: { status: "new" | "in_progress" | "converted" | "declined"; label: string }[] = [
{ status: "new", label: "Новый запрос" },
{ status: "in_progress", label: "Запрос в обработке" },
{ status: "converted", label: "Заявка получена" },
{ status: "declined", label: "Отклонён" },
];
export default async function LeadsPage() {
const session = await getCurrentSession();
if (!session) redirect("/login");
if (session.user.role === "accountant") redirect("/orders");
const leads = await listLeads();
return (
<div>
<div className="mb-5 flex items-center justify-between">
<h1 className="font-display text-xl font-bold tracking-tight">Лиды</h1>
<LeadsToolbar />
</div>
<LeadsBoard leads={leads} columns={COLUMNS} />
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
"use client";
import { useState } from "react";
import { Plus } from "lucide-react";
import { NewLeadModal } from "./new-lead-modal";
export function LeadsToolbar() {
const [open, setOpen] = useState(false);
return (
<>
<button className="btn btn-primary" onClick={() => setOpen(true)}>
<Plus size={16} />
Новый лид
</button>
<NewLeadModal open={open} onClose={() => setOpen(false)} />
</>
);
}
+2 -1
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Inbox, Truck, MapPinned, Wallet, BarChart3, Settings, MessageCircle, ListTodo } from "lucide-react"; import { Inbox, Truck, MapPinned, Wallet, BarChart3, Settings, MessageCircle, ListTodo, UserPlus } from "lucide-react";
import type { AppEvent } from "@/lib/events/bus"; import type { AppEvent } from "@/lib/events/bus";
export type Role = "admin" | "manager" | "accountant"; export type Role = "admin" | "manager" | "accountant";
@@ -9,6 +9,7 @@ export type Role = "admin" | "manager" | "accountant";
// Role restriction is temporarily disabled everywhere (see requireRole) — // Role restriction is temporarily disabled everywhere (see requireRole) —
// every link is shown to every authenticated user regardless of role. // every link is shown to every authenticated user regardless of role.
export const links: { href: string; label: string; icon: typeof Inbox; roles: Role[] }[] = [ export const links: { href: string; label: string; icon: typeof Inbox; roles: Role[] }[] = [
{ href: "/leads", label: "Лиды", icon: UserPlus, roles: ["admin", "manager", "accountant"] },
{ href: "/inquiries", label: "Запросы", icon: Inbox, roles: ["admin", "manager", "accountant"] }, { href: "/inquiries", label: "Запросы", icon: Inbox, roles: ["admin", "manager", "accountant"] },
{ href: "/orders", label: "Заявки", icon: Truck, roles: ["admin", "manager", "accountant"] }, { href: "/orders", label: "Заявки", icon: Truck, roles: ["admin", "manager", "accountant"] },
{ href: "/tracking", label: "Слежение", icon: MapPinned, roles: ["admin", "manager", "accountant"] }, { href: "/tracking", label: "Слежение", icon: MapPinned, roles: ["admin", "manager", "accountant"] },
@@ -0,0 +1,205 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { SearchableSelect, type SearchableOption } from "@/components/searchable-select";
interface OrderDealFields {
incoterm: string | null;
paymentTerms: string | null;
prepaymentPercent: number | null;
carrierPayTerms: string | null;
receivablesDeadline: Date | string | null;
carrier1Id: string | null;
carrier2Id: string | null;
carrier3Id: string | null;
insuranceCompanyId: string | null;
customsBrokerId: string | null;
recipientContactName: string | null;
senderContactName: string | null;
shipDate: Date | string | null;
driverPhone: string | null;
}
const INCOTERMS = ["EXW", "FCA", "FAS", "FOB", "CFR", "CIF", "CIP", "DAP", "DPU", "DDP"];
const PAYMENT_TERMS_LABEL: Record<string, string> = {
prepayment: "Предоплата",
postpayment: "Постоплата",
partial_prepayment: "Частичная предоплата",
};
function toDateInputValue(date: Date | string | null): string {
if (!date) return "";
const d = new Date(date);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export function DealFieldsForm({
orderId,
fields,
carriers,
counterparties,
}: {
orderId: string;
fields: OrderDealFields;
carriers: SearchableOption[];
counterparties: SearchableOption[];
}) {
const router = useRouter();
const [incoterm, setIncoterm] = useState(fields.incoterm ?? "");
const [paymentTerms, setPaymentTerms] = useState(fields.paymentTerms ?? "");
const [prepaymentPercent, setPrepaymentPercent] = useState(fields.prepaymentPercent?.toString() ?? "");
const [carrierPayTerms, setCarrierPayTerms] = useState(fields.carrierPayTerms ?? "");
const [receivablesDeadline, setReceivablesDeadline] = useState(toDateInputValue(fields.receivablesDeadline));
const [carrier1Id, setCarrier1Id] = useState(fields.carrier1Id ?? "");
const [carrier2Id, setCarrier2Id] = useState(fields.carrier2Id ?? "");
const [carrier3Id, setCarrier3Id] = useState(fields.carrier3Id ?? "");
const [insuranceCompanyId, setInsuranceCompanyId] = useState(fields.insuranceCompanyId ?? "");
const [customsBrokerId, setCustomsBrokerId] = useState(fields.customsBrokerId ?? "");
const [recipientContactName, setRecipientContactName] = useState(fields.recipientContactName ?? "");
const [senderContactName, setSenderContactName] = useState(fields.senderContactName ?? "");
const [shipDate, setShipDate] = useState(toDateInputValue(fields.shipDate));
const [driverPhone, setDriverPhone] = useState(fields.driverPhone ?? "");
const [loading, setLoading] = useState(false);
const [saved, setSaved] = useState(false);
async function save() {
setLoading(true);
setSaved(false);
await fetch(`/api/orders/${orderId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
incoterm: incoterm || undefined,
paymentTerms: paymentTerms || undefined,
prepaymentPercent: prepaymentPercent ? Number(prepaymentPercent) : undefined,
carrierPayTerms: carrierPayTerms || undefined,
receivablesDeadline: receivablesDeadline || undefined,
carrier1Id: carrier1Id || undefined,
carrier2Id: carrier2Id || undefined,
carrier3Id: carrier3Id || undefined,
insuranceCompanyId: insuranceCompanyId || undefined,
customsBrokerId: customsBrokerId || undefined,
recipientContactName: recipientContactName || undefined,
senderContactName: senderContactName || undefined,
shipDate: shipDate || undefined,
driverPhone: driverPhone || undefined,
}),
});
setLoading(false);
setSaved(true);
router.refresh();
}
return (
<div className="card p-4">
<h2 className="mb-3 text-sm font-semibold text-text-muted">Условия сделки</h2>
<div className="mb-3 grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Условия отгрузки (Инкотермс)</span>
<select value={incoterm} onChange={(e) => setIncoterm(e.target.value)} className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent">
<option value="">—</option>
{INCOTERMS.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Условия оплаты у клиента</span>
<select value={paymentTerms} onChange={(e) => setPaymentTerms(e.target.value)} className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent">
<option value="">—</option>
{Object.entries(PAYMENT_TERMS_LABEL).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</label>
</div>
<div className="mb-3 grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Дата отправки груза клиенту</span>
<input type="date" value={shipDate} onChange={(e) => setShipDate(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>
<input type="date" value={receivablesDeadline} onChange={(e) => setReceivablesDeadline(e.target.value)} className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent" />
</label>
</div>
{paymentTerms === "partial_prepayment" && (
<label className="mb-3 block max-w-xs text-sm">
<span className="mb-1 block font-medium text-text-muted">Процент предоплаты</span>
<input
type="number"
min={0}
max={100}
value={prepaymentPercent}
onChange={(e) => setPrepaymentPercent(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="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Условия расчёта с подрядчиками</span>
<textarea value={carrierPayTerms} onChange={(e) => setCarrierPayTerms(e.target.value)} rows={2} className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent" />
</label>
<div className="mb-3 grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Контактное лицо отправителя</span>
<input value={senderContactName} onChange={(e) => setSenderContactName(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>
<input value={recipientContactName} onChange={(e) => setRecipientContactName(e.target.value)} className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent" />
</label>
</div>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Телефон водителя</span>
<input value={driverPhone} onChange={(e) => setDriverPhone(e.target.value)} className="w-full max-w-xs rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent" />
</label>
<label className="mb-3 block text-sm">
<span className="mb-1 block font-medium text-text-muted">Подрядчик логистика 1</span>
<SearchableSelect options={carriers} value={carrier1Id} onChange={setCarrier1Id} placeholder="Выберите подрядчика" />
</label>
<div className="mb-3 grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Страховая компания</span>
<SearchableSelect options={counterparties} value={insuranceCompanyId} onChange={setInsuranceCompanyId} placeholder="Выберите" />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Таможенный брокер</span>
<SearchableSelect options={counterparties} value={customsBrokerId} onChange={setCustomsBrokerId} placeholder="Выберите" />
</label>
</div>
<details className="mb-3">
<summary className="cursor-pointer text-sm font-medium text-text-muted">Ещё подрядчики (скрытое)</summary>
<div className="mt-3 grid grid-cols-2 gap-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Подрядчик логистика 2</span>
<SearchableSelect options={carriers} value={carrier2Id} onChange={setCarrier2Id} placeholder="Выберите" />
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Подрядчик логистика 3</span>
<SearchableSelect options={carriers} value={carrier3Id} onChange={setCarrier3Id} placeholder="Выберите" />
</label>
</div>
</details>
<div className="flex items-center gap-3">
<button type="button" onClick={save} disabled={loading} className="btn btn-primary">
{loading ? "Сохраняем…" : "Сохранить"}
</button>
{saved && <span className="text-sm text-text-muted">Сохранено</span>}
</div>
</div>
);
}
+53 -6
View File
@@ -2,16 +2,19 @@ import { notFound, redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { ArrowLeft } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { getCurrentSession } from "@/lib/auth/session"; import { getCurrentSession } from "@/lib/auth/session";
import { getOrder, computeMargin } from "@/lib/orders/service"; import { getOrder, computeMargin, STATUS_LABEL } from "@/lib/orders/service";
import { canAccessOrder } from "@/lib/orders/visibility"; import { canAccessOrder } from "@/lib/orders/visibility";
import { listCarriers } from "@/lib/counterparties/service"; import { listCarriers, listCounterparties } from "@/lib/counterparties/service";
import { listTemplates } from "@/lib/documents/templates"; import { listTemplates } from "@/lib/documents/templates";
import { ORDER_STAGE_INFO } from "@/lib/orders/stages";
import { StageInfoPanel } from "@/components/stage-info-panel";
import { StatusControls } from "./status-controls"; import { StatusControls } from "./status-controls";
import { IncomeSection } from "./income-section"; import { IncomeSection } from "./income-section";
import { ExpenseSection } from "./expense-section"; import { ExpenseSection } from "./expense-section";
import { EventsSection } from "./events-section"; import { EventsSection } from "./events-section";
import { DocumentsSection } from "./documents-section"; import { DocumentsSection } from "./documents-section";
import { SignChecklistSection } from "./sign-checklist-section"; import { SignChecklistSection } from "./sign-checklist-section";
import { DealFieldsForm } from "./deal-fields-form";
export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) { export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
const session = await getCurrentSession(); const session = await getCurrentSession();
@@ -22,8 +25,21 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
if (!order) notFound(); if (!order) notFound();
if (!canAccessOrder(order, session.user)) redirect("/orders"); if (!canAccessOrder(order, session.user)) redirect("/orders");
const [carriers, templates] = await Promise.all([listCarriers(), listTemplates()]); const [carriers, allCounterparties, templates] = await Promise.all([listCarriers(), listCounterparties(), listTemplates()]);
const margin = computeMargin(order); const margin = computeMargin(order);
const STATUS_BADGE: Record<string, string> = {
new: "new",
carrier_placement: "new",
pickup_from_supplier: "open",
in_transit: "open",
border: "open",
terminal_warehouse: "open",
delivery_to_client: "open",
payment_updoc: "pending",
receivables: "pending",
completed: "done",
declined: "declined",
};
return ( return (
<div> <div>
@@ -39,9 +55,16 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
{order.customer?.name} · менеджер {order.manager?.name} {order.customer?.name} · менеджер {order.manager?.name}
</p> </p>
</div> </div>
<Link href={`/t/${order.publicToken}`} target="_blank" className="text-xs text-accent hover:underline"> <div className="flex items-center gap-3">
Ссылка для клиента <span className={`badge badge-${STATUS_BADGE[order.status]}`}>{STATUS_LABEL[order.status]}</span>
</Link> <Link href={`/t/${order.publicToken}`} target="_blank" className="text-xs text-accent hover:underline">
Ссылка для клиента
</Link>
</div>
</div>
<div className="mb-5">
<StageInfoPanel info={ORDER_STAGE_INFO[order.status]} />
</div> </div>
<div className="mb-5 card p-4"> <div className="mb-5 card p-4">
@@ -89,6 +112,30 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
</div> </div>
</div> </div>
<div className="mb-5">
<DealFieldsForm
orderId={order.id}
fields={{
incoterm: order.incoterm,
paymentTerms: order.paymentTerms,
prepaymentPercent: order.prepaymentPercent,
carrierPayTerms: order.carrierPayTerms,
receivablesDeadline: order.receivablesDeadline,
carrier1Id: order.carrier1Id,
carrier2Id: order.carrier2Id,
carrier3Id: order.carrier3Id,
insuranceCompanyId: order.insuranceCompanyId,
customsBrokerId: order.customsBrokerId,
recipientContactName: order.recipientContactName,
senderContactName: order.senderContactName,
shipDate: order.shipDate,
driverPhone: order.driverPhone,
}}
carriers={carriers.map((c) => ({ id: c.id, label: c.name }))}
counterparties={allCounterparties.map((c) => ({ id: c.id, label: c.name }))}
/>
</div>
<div className="mb-5 grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="mb-5 grid grid-cols-1 gap-4 lg:grid-cols-2">
<IncomeSection orderId={order.id} lines={order.incomeLines} /> <IncomeSection orderId={order.id} lines={order.incomeLines} />
<ExpenseSection <ExpenseSection
@@ -2,18 +2,30 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { DeclineWithReason } from "@/components/decline-with-reason";
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
{ value: "new", label: "Новая" }, { value: "new", label: "Новая заявка" },
{ value: "in_progress", label: "На исполнении" }, { value: "carrier_placement", label: "Размещение у подрядчика" },
{ value: "done", label: "Завершена" }, { value: "pickup_from_supplier", label: "Отгрузка от поставщика" },
{ value: "closed", label: "Закрыта" }, { value: "in_transit", label: "В пути" },
{ value: "border", label: "Граница" },
{ value: "terminal_warehouse", label: "Терминал/СВХ" },
{ value: "delivery_to_client", label: "Доставка клиенту" },
{ value: "payment_updoc", label: "Получить оплату/УПД" },
{ value: "receivables", label: "Дебиторка" },
{ value: "completed", label: "Сделка завершена" },
]; ];
const PAID_OPTIONS = [ const PAID_OPTIONS = [
{ value: "unpaid", label: "Не оплачен" }, { value: "unpaid", label: "Не оплачен" },
{ value: "partial", label: "Частично" }, { value: "partial", label: "Частично" },
{ value: "paid", label: "Оплачен" }, { value: "paid", label: "Оплачен" },
]; ];
const DECLINE_REASONS = [
{ value: "cargo_stolen", label: "Украли груз" },
{ value: "cargo_not_shipped", label: "Груз не вышел фактически" },
{ value: "other", label: "Другая причина отказа" },
];
export function StatusControls({ export function StatusControls({
orderId, orderId,
@@ -48,10 +60,11 @@ export function StatusControls({
<span className="text-text-muted">Статус</span> <span className="text-text-muted">Статус</span>
<select <select
value={status} value={status}
disabled={loading} disabled={loading || status === "declined"}
onChange={(e) => patch({ status: e.target.value })} onChange={(e) => patch({ status: e.target.value })}
className="rounded-md border border-border bg-surface px-2 py-1.5 outline-none focus:border-accent" className="rounded-md border border-border bg-surface px-2 py-1.5 outline-none focus:border-accent"
> >
{status === "declined" && <option value="declined">Отказ</option>}
{STATUS_OPTIONS.map((o) => ( {STATUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
{o.label} {o.label}
@@ -85,6 +98,10 @@ export function StatusControls({
<input type="checkbox" checked={edoUploaded} disabled={loading} onChange={(e) => patch({ edoUploaded: e.target.checked })} /> <input type="checkbox" checked={edoUploaded} disabled={loading} onChange={(e) => patch({ edoUploaded: e.target.checked })} />
<span className="text-text-muted">Выгружено в ЭДО</span> <span className="text-text-muted">Выгружено в ЭДО</span>
</label> </label>
{status !== "declined" && status !== "completed" && (
<DeclineWithReason url={`/api/orders/${orderId}/decline`} reasons={DECLINE_REASONS} onDeclined={() => router.refresh()} />
)}
</div> </div>
); );
} }
+21 -7
View File
@@ -5,16 +5,30 @@ import { listOrders, computeMargin } from "@/lib/orders/service";
import { getOrderDocStatus } from "@/lib/documents/checklist"; import { getOrderDocStatus } from "@/lib/documents/checklist";
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
new: "Новая", new: "Новая заявка",
in_progress: "На исполнении", carrier_placement: "Размещение у подрядчика",
done: "Завершена", pickup_from_supplier: "Отгрузка от поставщика",
closed: "Закрыта", in_transit: "В пути",
border: "Граница",
terminal_warehouse: "Терминал/СВХ",
delivery_to_client: "Доставка клиенту",
payment_updoc: "Оплата/УПД",
receivables: "Дебиторка",
completed: "Завершена",
declined: "Отказ",
}; };
const STATUS_BADGE: Record<string, string> = { const STATUS_BADGE: Record<string, string> = {
new: "badge-new", new: "badge-new",
in_progress: "badge-open", carrier_placement: "badge-new",
done: "badge-done", pickup_from_supplier: "badge-open",
closed: "badge-closed", in_transit: "badge-open",
border: "badge-open",
terminal_warehouse: "badge-open",
delivery_to_client: "badge-open",
payment_updoc: "badge-pending",
receivables: "badge-pending",
completed: "badge-done",
declined: "badge-declined",
}; };
const PAID_LABEL: Record<string, string> = { unpaid: "Не оплачен", partial: "Частично", paid: "Оплачен" }; const PAID_LABEL: Record<string, string> = { unpaid: "Не оплачен", partial: "Частично", paid: "Оплачен" };
const DOC_STATUS_DOT: Record<"red" | "yellow" | "green", string> = { red: "bg-danger", yellow: "bg-warning", green: "bg-success" }; const DOC_STATUS_DOT: Record<"red" | "yellow" | "green", string> = { red: "bg-danger", yellow: "bg-warning", green: "bg-success" };
@@ -9,6 +9,8 @@ interface Status {
user: string | null; user: string | null;
imapHost: string | null; imapHost: string | null;
verifiedAt: number | null; verifiedAt: number | null;
smtpHost: string | null;
smtpPort: number | null;
} }
export function MailboxForm({ status }: { status: Status }) { export function MailboxForm({ status }: { status: Status }) {
@@ -18,6 +20,8 @@ export function MailboxForm({ status }: { status: Status }) {
const [user, setUser] = useState(status.user ?? ""); const [user, setUser] = useState(status.user ?? "");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [allowInsecureTls, setAllowInsecureTls] = useState(false); const [allowInsecureTls, setAllowInsecureTls] = useState(false);
const [smtpHost, setSmtpHost] = useState(status.smtpHost ?? "");
const [smtpPort, setSmtpPort] = useState(status.smtpPort?.toString() ?? "465");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -29,7 +33,15 @@ export function MailboxForm({ status }: { status: Status }) {
const res = await fetch("/api/settings/mailbox", { const res = await fetch("/api/settings/mailbox", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imapHost, imapPort: Number(imapPort), user, password, allowInsecureTls }), body: JSON.stringify({
imapHost,
imapPort: Number(imapPort),
user,
password,
allowInsecureTls,
smtpHost: smtpHost || undefined,
smtpPort: smtpPort ? Number(smtpPort) : undefined,
}),
}); });
setLoading(false); setLoading(false);
@@ -109,6 +121,30 @@ export function MailboxForm({ status }: { status: Status }) {
<span className="text-text-muted">Разрешить недоверенный TLS-сертификат</span> <span className="text-text-muted">Разрешить недоверенный TLS-сертификат</span>
</label> </label>
<div className="sm:col-span-2 border-t border-border pt-3">
<p className="mb-2 text-sm font-medium text-text-muted">
Исходящая почта (SMTP) — для уведомлений клиенту о статусе заявки. Тот же логин/пароль, что выше. Необязательно.
</p>
</div>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">SMTP-сервер</span>
<input
value={smtpHost}
onChange={(e) => setSmtpHost(e.target.value)}
placeholder="smtp.yandex.ru"
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>
<input
type="number"
value={smtpPort}
onChange={(e) => setSmtpPort(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
{error && <p className="sm:col-span-2 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>} {error && <p className="sm:col-span-2 rounded-md bg-danger-soft px-3 py-2 text-sm text-danger-soft-text">{error}</p>}
<div className="flex gap-2 sm:col-span-2"> <div className="flex gap-2 sm:col-span-2">
+11 -3
View File
@@ -2,11 +2,19 @@ import { notFound } from "next/navigation";
import { Truck, MapPin } from "lucide-react"; import { Truck, MapPin } from "lucide-react";
import { getOrderByPublicToken } from "@/lib/tracking/service"; import { getOrderByPublicToken } from "@/lib/tracking/service";
// Клиенту показываем упрощённые формулировки, а не внутренние 10 стадий воронки заявки.
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
new: "Принята в работу", new: "Принята в работу",
in_progress: "В пути", carrier_placement: "Принята в работу",
done: "Выгружена", pickup_from_supplier: "Груз погружен",
closed: "Завершена", in_transit: "В пути",
border: "На границе",
terminal_warehouse: "На терминале",
delivery_to_client: "На выгрузке",
payment_updoc: "Выгружена",
receivables: "Выгружена",
completed: "Завершена",
declined: "Отменена",
}; };
export default async function ClientTrackingPage({ params }: { params: Promise<{ token: string }> }) { export default async function ClientTrackingPage({ params }: { params: Promise<{ token: string }> }) {
@@ -1,20 +0,0 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireRole } from "@/lib/auth/require";
import { setInquiryAnswered } from "@/lib/inquiries/service";
const schema = z.object({ answered: z.boolean() });
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireRole(["admin", "manager"]);
if (!session) return response;
const { id } = await params;
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "Некорректные данные" }, { status: 400 });
const row = await setInquiryAnswered(id, parsed.data.answered);
return NextResponse.json(row);
}
+16 -2
View File
@@ -1,14 +1,28 @@
export const runtime = "nodejs"; export const runtime = "nodejs";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod";
import { requireRole } from "@/lib/auth/require"; import { requireRole } from "@/lib/auth/require";
import { declineInquiry } from "@/lib/inquiries/service"; import { declineInquiry } from "@/lib/inquiries/service";
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) { const schema = z.object({
reason: z.enum(["too_expensive", "unreachable", "lost_to_competitor", "postponed", "pricing_calc", "slow_processing", "other"]),
detail: z.string().optional(),
});
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireRole(["admin", "manager"]); const { session, response } = await requireRole(["admin", "manager"]);
if (!session) return response; if (!session) return response;
const { id } = await params; const { id } = await params;
await declineInquiry(id); const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Некорректные данные" }, { status: 400 });
}
if (parsed.data.reason === "other" && !parsed.data.detail) {
return NextResponse.json({ error: "Укажите причину отказа" }, { status: 400 });
}
await declineInquiry(id, parsed.data.reason, parsed.data.detail);
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
+12
View File
@@ -6,6 +6,7 @@ import { requireRole } from "@/lib/auth/require";
import { getInquiry, updateInquiry } from "@/lib/inquiries/service"; import { getInquiry, updateInquiry } from "@/lib/inquiries/service";
const schema = z.object({ const schema = z.object({
status: z.enum(["kp_preparation", "kp_response", "negotiation", "signing"]).optional(),
cargoDescription: z.string().min(1).optional(), cargoDescription: z.string().min(1).optional(),
routeFrom: z.string().min(1).optional(), routeFrom: z.string().min(1).optional(),
routeTo: z.string().min(1).optional(), routeTo: z.string().min(1).optional(),
@@ -15,6 +16,17 @@ const schema = z.object({
clientRate: z.number().positive().nullable().optional(), clientRate: z.number().positive().nullable().optional(),
comment: z.string().optional(), comment: z.string().optional(),
deadline: z.string().optional(), deadline: z.string().optional(),
incoterm: z.enum(["EXW", "FCA", "FAS", "FOB", "CFR", "CIF", "CIP", "DAP", "DPU", "DDP"]).optional(),
paymentTerms: z.enum(["prepayment", "postpayment", "partial_prepayment"]).optional(),
prepaymentPercent: z.number().min(0).max(100).optional(),
carrierPayTerms: z.string().optional(),
receivablesDeadline: z.coerce.date().optional(),
carrier1Id: z.string().optional(),
carrier2Id: z.string().optional(),
carrier3Id: z.string().optional(),
insuranceCompanyId: z.string().optional(),
customsBrokerId: z.string().optional(),
recipientContactName: z.string().optional(),
}); });
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
+31
View File
@@ -0,0 +1,31 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireRole } from "@/lib/auth/require";
import { convertLeadToInquiry } from "@/lib/leads/service";
const schema = z.object({
customerId: z.string().min(1),
managerId: z.string().min(1),
source: z.enum(["email", "phone", "messenger"]),
});
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireRole(["admin", "manager"]);
if (!session) return response;
const { id } = await params;
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Некорректные данные" }, { status: 400 });
}
try {
const inquiry = await convertLeadToInquiry(id, parsed.data);
return NextResponse.json(inquiry, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : "Ошибка конвертации";
return NextResponse.json({ error: message }, { status: 400 });
}
}
+28
View File
@@ -0,0 +1,28 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireRole } from "@/lib/auth/require";
import { declineLead } from "@/lib/leads/service";
const schema = z.object({
reason: z.enum(["not_reachable", "not_target", "spam", "duplicate", "other"]),
detail: z.string().optional(),
});
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireRole(["admin", "manager"]);
if (!session) return response;
const { id } = await params;
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Некорректные данные" }, { status: 400 });
}
if (parsed.data.reason === "other" && !parsed.data.detail) {
return NextResponse.json({ error: "Укажите причину отказа" }, { status: 400 });
}
await declineLead(id, parsed.data.reason, parsed.data.detail);
return NextResponse.json({ ok: true });
}
+60
View File
@@ -0,0 +1,60 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireRole } from "@/lib/auth/require";
import { getLead, updateLead } from "@/lib/leads/service";
const schema = z.object({
status: z.enum(["new", "in_progress"]).optional(),
firstName: z.string().optional(),
lastName: z.string().optional(),
patronymic: z.string().optional(),
phone: z.string().optional(),
email: z.string().optional(),
position: z.string().optional(),
companyName: z.string().optional(),
source: z.enum(["mail", "phone", "referral", "cold_call", "website", "tg_bot"]).optional(),
transportMode: z.enum(["road", "rail", "sea", "air"]).optional(),
transportType: z.enum(["consolidated", "tent", "trawl", "backhaul", "container"]).optional(),
serviceTypes: z.array(z.string()).optional(),
cargoClassification: z.array(z.string()).optional(),
additionalServices: z.string().optional(),
routeFrom: z.string().optional(),
routeTo: z.string().optional(),
cargoDescription: z.string().optional(),
lengthMm: z.string().optional(),
widthMm: z.string().optional(),
heightMm: z.string().optional(),
weightKg: z.string().optional(),
volumeM3: z.string().optional(),
tnvedCode: z.string().optional(),
placesCount: z.number().int().positive().optional(),
cargoCost: z.number().positive().optional(),
comment: z.string().optional(),
responsibleId: z.string().optional(),
});
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireRole(["admin", "manager"]);
if (!session) return response;
const { id } = await params;
const row = await getLead(id);
if (!row) return NextResponse.json({ error: "Лид не найден" }, { status: 404 });
return NextResponse.json(row);
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireRole(["admin", "manager"]);
if (!session) return response;
const { id } = await params;
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Некорректные данные" }, { status: 400 });
}
const row = await updateLead(id, parsed.data);
return NextResponse.json(row);
}
+53
View File
@@ -0,0 +1,53 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireRole } from "@/lib/auth/require";
import { listLeads, createLead } from "@/lib/leads/service";
const createSchema = z.object({
firstName: z.string().optional(),
lastName: z.string().optional(),
patronymic: z.string().optional(),
phone: z.string().optional(),
email: z.string().optional(),
position: z.string().optional(),
companyName: z.string().optional(),
source: z.enum(["mail", "phone", "referral", "cold_call", "website", "tg_bot"]).optional(),
transportMode: z.enum(["road", "rail", "sea", "air"]).optional(),
transportType: z.enum(["consolidated", "tent", "trawl", "backhaul", "container"]).optional(),
serviceTypes: z.array(z.string()).optional(),
cargoClassification: z.array(z.string()).optional(),
additionalServices: z.string().optional(),
routeFrom: z.string().optional(),
routeTo: z.string().optional(),
cargoDescription: z.string().optional(),
lengthMm: z.string().optional(),
widthMm: z.string().optional(),
heightMm: z.string().optional(),
weightKg: z.string().optional(),
volumeM3: z.string().optional(),
tnvedCode: z.string().optional(),
placesCount: z.number().int().positive().optional(),
cargoCost: z.number().positive().optional(),
comment: z.string().optional(),
});
export async function GET() {
const { session, response } = await requireRole(["admin", "manager"]);
if (!session) return response;
return NextResponse.json(await listLeads());
}
export async function POST(request: Request) {
const { session, response } = await requireRole(["admin", "manager"]);
if (!session) return response;
const parsed = createSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Некорректные данные" }, { status: 400 });
}
const row = await createLead(parsed.data);
return NextResponse.json(row, { status: 201 });
}
+28
View File
@@ -0,0 +1,28 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireRole } from "@/lib/auth/require";
import { declineOrder } from "@/lib/orders/service";
const schema = z.object({
reason: z.enum(["cargo_stolen", "cargo_not_shipped", "other"]),
detail: z.string().optional(),
});
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireRole(["admin", "manager"]);
if (!session) return response;
const { id } = await params;
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Некорректные данные" }, { status: 400 });
}
if (parsed.data.reason === "other" && !parsed.data.detail) {
return NextResponse.json({ error: "Укажите причину отказа" }, { status: 400 });
}
await declineOrder(id, parsed.data.reason, parsed.data.detail);
return NextResponse.json({ ok: true });
}
+26 -1
View File
@@ -7,7 +7,18 @@ import { getOrder, updateOrder } from "@/lib/orders/service";
import { canAccessOrder } from "@/lib/orders/visibility"; import { canAccessOrder } from "@/lib/orders/visibility";
const schema = z.object({ const schema = z.object({
status: z.enum(["new", "in_progress", "done", "closed"]).optional(), status: z.enum([
"new",
"carrier_placement",
"pickup_from_supplier",
"in_transit",
"border",
"terminal_warehouse",
"delivery_to_client",
"payment_updoc",
"receivables",
"completed",
]).optional(),
paidStatus: z.enum(["unpaid", "partial", "paid"]).optional(), paidStatus: z.enum(["unpaid", "partial", "paid"]).optional(),
docsReceived: z.boolean().optional(), docsReceived: z.boolean().optional(),
edoUploaded: z.boolean().optional(), edoUploaded: z.boolean().optional(),
@@ -17,6 +28,20 @@ const schema = z.object({
poaVehiclePlate: z.string().optional(), poaVehiclePlate: z.string().optional(),
poaVehicleModel: z.string().optional(), poaVehicleModel: z.string().optional(),
poaValidUntil: z.coerce.date().optional(), poaValidUntil: z.coerce.date().optional(),
driverPhone: z.string().optional(),
incoterm: z.enum(["EXW", "FCA", "FAS", "FOB", "CFR", "CIF", "CIP", "DAP", "DPU", "DDP"]).optional(),
paymentTerms: z.enum(["prepayment", "postpayment", "partial_prepayment"]).optional(),
prepaymentPercent: z.number().min(0).max(100).optional(),
carrierPayTerms: z.string().optional(),
receivablesDeadline: z.coerce.date().optional(),
carrier1Id: z.string().optional(),
carrier2Id: z.string().optional(),
carrier3Id: z.string().optional(),
insuranceCompanyId: z.string().optional(),
customsBrokerId: z.string().optional(),
recipientContactName: z.string().optional(),
senderContactName: z.string().optional(),
shipDate: z.coerce.date().optional(),
}); });
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
+2
View File
@@ -18,6 +18,8 @@ const schema = z.object({
user: z.string().min(1), user: z.string().min(1),
password: z.string().min(1), password: z.string().min(1),
allowInsecureTls: z.boolean().optional(), allowInsecureTls: z.boolean().optional(),
smtpHost: z.string().optional(),
smtpPort: z.number().int().positive().optional(),
}); });
export async function POST(request: Request) { export async function POST(request: Request) {
+97
View File
@@ -0,0 +1,97 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
interface ReasonOption {
value: string;
label: string;
}
/** Общая для запросов/заявок/лидов кнопка отказа с причиной — "Другая причина" требует текстового уточнения (правило из схем воронок). */
export function DeclineWithReason({
url,
reasons,
onDeclined,
}: {
url: string;
reasons: ReasonOption[];
onDeclined?: () => void;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [reason, setReason] = useState(reasons[0]?.value ?? "");
const [detail, setDetail] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit() {
setLoading(true);
setError(null);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason, detail: detail || undefined }),
});
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось отклонить");
return;
}
setOpen(false);
onDeclined?.();
router.refresh();
}
if (!open) {
return (
<button type="button" onClick={() => setOpen(true)} className="btn btn-ghost">
Отказ
</button>
);
}
return (
<div className="card flex flex-col gap-2 p-3">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Причина отказа</span>
<select
value={reason}
onChange={(e) => setReason(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
>
{reasons.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</select>
</label>
{reason === "other" && (
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Уточните причину</span>
<input
value={detail}
onChange={(e) => setDetail(e.target.value)}
className="w-full rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
)}
{error && <p className="text-sm text-danger-soft-text">{error}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={submit}
disabled={loading || (reason === "other" && !detail)}
className="btn btn-primary"
>
{loading ? "Отклоняем…" : "Подтвердить отказ"}
</button>
<button type="button" onClick={() => setOpen(false)} className="btn btn-ghost">
Отмена
</button>
</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
export interface StageInfo {
label: string;
requirements: string[];
transitionCriteria: string;
}
/** Справочная панель "что требуется на этой стадии / критерий перехода" — общий для лидов/запросов/заявок компонент, данные из lib/{leads,inquiries,orders}/stages.ts. */
export function StageInfoPanel({ info }: { info: StageInfo | undefined }) {
if (!info) return null;
return (
<details className="card p-4">
<summary className="cursor-pointer text-sm font-semibold text-text-muted">На стадии «{info.label}» требуется</summary>
<ul className="mt-3 list-disc pl-5 text-sm">
{info.requirements.map((r, i) => (
<li key={i} className="mb-1">
{r}
</li>
))}
</ul>
<p className="mt-3 border-t border-border pt-3 text-sm">
<span className="font-medium text-text-muted">Критерий перехода: </span>
{info.transitionCriteria}
</p>
</details>
);
}
@@ -0,0 +1,108 @@
CREATE TABLE `leads` (
`id` text PRIMARY KEY NOT NULL,
`number` text NOT NULL,
`first_name` text,
`last_name` text,
`patronymic` text,
`birth_date` integer,
`phone` text,
`email` text,
`position` text,
`company_name` text,
`source` text,
`transport_mode` text,
`transport_type` text,
`service_types` text,
`cargo_classification` text,
`additional_services` text,
`route_from` text,
`route_to` text,
`cargo_description` text,
`length_mm` text,
`width_mm` text,
`height_mm` text,
`weight_kg` text,
`volume_m3` text,
`tnved_code` text,
`places_count` integer,
`cargo_cost` real,
`status` text DEFAULT 'new' NOT NULL,
`decline_reason` text,
`decline_reason_detail` text,
`logist_id` text,
`responsible_id` text,
`converted_inquiry_id` text,
`comment` text,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`logist_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`responsible_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `leads_number_unique` ON `leads` (`number`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_inquiries` (
`id` text PRIMARY KEY NOT NULL,
`number` text NOT NULL,
`customer_id` text NOT NULL,
`manager_id` text NOT NULL,
`cargo_description` text NOT NULL,
`route_from` text NOT NULL,
`route_to` text NOT NULL,
`customs_place` text,
`transport_mode` text,
`transport_type` text,
`client_rate` real,
`comment` text,
`deadline` text,
`source` text DEFAULT 'email' NOT NULL,
`status` text DEFAULT 'kp_preparation' NOT NULL,
`first_responded_at` integer,
`converted_order_id` text,
`decline_reason` text,
`decline_reason_detail` text,
`incoterm` text,
`payment_terms` text,
`prepayment_percent` real,
`carrier_pay_terms` text,
`receivables_deadline` integer,
`carrier1_id` text,
`carrier2_id` text,
`carrier3_id` text,
`insurance_company_id` text,
`customs_broker_id` text,
`recipient_contact_name` text,
`lead_id` text,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`customer_id`) REFERENCES `counterparties`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`manager_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`carrier1_id`) REFERENCES `counterparties`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`carrier2_id`) REFERENCES `counterparties`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`carrier3_id`) REFERENCES `counterparties`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`insurance_company_id`) REFERENCES `counterparties`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`customs_broker_id`) REFERENCES `counterparties`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
INSERT INTO `__new_inquiries`("id", "number", "customer_id", "manager_id", "cargo_description", "route_from", "route_to", "customs_place", "transport_mode", "transport_type", "client_rate", "comment", "deadline", "source", "status", "first_responded_at", "converted_order_id", "created_at") SELECT "id", "number", "customer_id", "manager_id", "cargo_description", "route_from", "route_to", "customs_place", "transport_mode", "transport_type", "client_rate", "comment", "deadline", "source", CASE "status" WHEN 'new' THEN 'kp_preparation' WHEN 'quoted' THEN 'kp_response' ELSE "status" END, "first_responded_at", "converted_order_id", "created_at" FROM `inquiries`;--> statement-breakpoint
DROP TABLE `inquiries`;--> statement-breakpoint
ALTER TABLE `__new_inquiries` RENAME TO `inquiries`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE UNIQUE INDEX `inquiries_number_unique` ON `inquiries` (`number`);--> statement-breakpoint
ALTER TABLE `inquiry_cargo_items` ADD `tnved_code` text;--> statement-breakpoint
ALTER TABLE `mailbox_config` ADD `smtp_host` text;--> statement-breakpoint
ALTER TABLE `mailbox_config` ADD `smtp_port` integer;--> statement-breakpoint
ALTER TABLE `orders` ADD `driver_phone` text;--> statement-breakpoint
ALTER TABLE `orders` ADD `decline_reason` text;--> statement-breakpoint
ALTER TABLE `orders` ADD `decline_reason_detail` text;--> statement-breakpoint
ALTER TABLE `orders` ADD `incoterm` text;--> statement-breakpoint
ALTER TABLE `orders` ADD `payment_terms` text;--> statement-breakpoint
ALTER TABLE `orders` ADD `prepayment_percent` real;--> statement-breakpoint
ALTER TABLE `orders` ADD `carrier_pay_terms` text;--> statement-breakpoint
ALTER TABLE `orders` ADD `receivables_deadline` integer;--> statement-breakpoint
ALTER TABLE `orders` ADD `carrier1_id` text REFERENCES counterparties(id);--> statement-breakpoint
ALTER TABLE `orders` ADD `carrier2_id` text REFERENCES counterparties(id);--> statement-breakpoint
ALTER TABLE `orders` ADD `carrier3_id` text REFERENCES counterparties(id);--> statement-breakpoint
ALTER TABLE `orders` ADD `insurance_company_id` text REFERENCES counterparties(id);--> statement-breakpoint
ALTER TABLE `orders` ADD `customs_broker_id` text REFERENCES counterparties(id);--> statement-breakpoint
ALTER TABLE `orders` ADD `recipient_contact_name` text;--> statement-breakpoint
ALTER TABLE `orders` ADD `sender_contact_name` text;--> statement-breakpoint
ALTER TABLE `orders` ADD `ship_date` integer;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -15,6 +15,13 @@
"when": 1788197632163, "when": 1788197632163,
"tag": "0001_normal_captain_stacy", "tag": "0001_normal_captain_stacy",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1788199359419,
"tag": "0002_bumpy_albert_cleary",
"breakpoints": true
} }
] ]
} }
+134 -3
View File
@@ -13,6 +13,11 @@ const timestamps = {
.default(sql`(unixepoch('subsec') * 1000)`), .default(sql`(unixepoch('subsec') * 1000)`),
}; };
// Общий справочник для inquiries и orders (условия поставки/оплаты) — один
// источник правды для enum и для UI-селектов (импортируется напрямую).
export const INCOTERMS = ["EXW", "FCA", "FAS", "FOB", "CFR", "CIF", "CIP", "DAP", "DPU", "DDP"] as const;
export const PAYMENT_TERMS = ["prepayment", "postpayment", "partial_prepayment"] as const;
/** Менеджеры, бухгалтер, администратор. managerNumber — двузначный код менеджера (реальная схема компании, не обязательно подряд), используется в нумерации запросов/заявок (TH{год2}{managerNumber2}-{seq7}), поэтому уникален и стабилен — не переиспользовать после смены сотрудника. */ /** Менеджеры, бухгалтер, администратор. managerNumber — двузначный код менеджера (реальная схема компании, не обязательно подряд), используется в нумерации запросов/заявок (TH{год2}{managerNumber2}-{seq7}), поэтому уникален и стабилен — не переиспользовать после смены сотрудника. */
export const users = sqliteTable("users", { export const users = sqliteTable("users", {
id: id(), id: id(),
@@ -99,6 +104,57 @@ export const counterpartyContacts = sqliteTable("counterparty_contacts", {
...timestamps, ...timestamps,
}); });
/**
* Лид — неквалифицированный контакт, до появления полноценного запроса (inquiries).
* Своя последовательность нумерации (formatLeadNumber в lib/orders/numbering.ts),
* не связана с нумерацией запросов/заявок. serviceTypes/cargoClassification —
* JSON-массив строк в text-колонке (drizzle-kit для SQLite не поддерживает
* нативный array-тип), парсится/сериализуется в lib/leads/service.ts.
* Габариты груза — плоские строки (в отличие от inquiryCargoItems), т.к. лид
* ещё не квалифицирован под структурированный список мест.
*/
export const leads = sqliteTable("leads", {
id: id(),
number: text("number").notNull().unique(),
firstName: text("first_name"),
lastName: text("last_name"),
patronymic: text("patronymic"),
birthDate: integer("birth_date", { mode: "timestamp_ms" }),
phone: text("phone"),
email: text("email"),
position: text("position"),
companyName: text("company_name"),
source: text("source", { enum: ["mail", "phone", "referral", "cold_call", "website", "tg_bot"] }),
transportMode: text("transport_mode", { enum: ["road", "rail", "sea", "air"] }),
transportType: text("transport_type", {
enum: ["consolidated", "tent", "trawl", "backhaul", "container"],
}),
serviceTypes: text("service_types"),
cargoClassification: text("cargo_classification"),
additionalServices: text("additional_services"),
routeFrom: text("route_from"),
routeTo: text("route_to"),
cargoDescription: text("cargo_description"),
lengthMm: text("length_mm"),
widthMm: text("width_mm"),
heightMm: text("height_mm"),
weightKg: text("weight_kg"),
volumeM3: text("volume_m3"),
tnvedCode: text("tnved_code"),
placesCount: integer("places_count"),
cargoCost: real("cargo_cost"),
status: text("status", { enum: ["new", "in_progress", "converted", "declined"] })
.notNull()
.default("new"),
declineReason: text("decline_reason", { enum: ["not_reachable", "not_target", "spam", "duplicate", "other"] }),
declineReasonDetail: text("decline_reason_detail"),
logistId: text("logist_id").references(() => users.id),
responsibleId: text("responsible_id").references(() => users.id),
convertedInquiryId: text("converted_inquiry_id"),
comment: text("comment"),
...timestamps,
});
/** Запрос от клиента, до появления ставки/заявки. Номер — {managerNumber}-{seq}, отдельная последовательность на менеджера (как в текущей 1С), считается в orders/counters.ts. */ /** Запрос от клиента, до появления ставки/заявки. Номер — {managerNumber}-{seq}, отдельная последовательность на менеджера (как в текущей 1С), считается в orders/counters.ts. */
export const inquiries = sqliteTable("inquiries", { export const inquiries = sqliteTable("inquiries", {
id: id(), id: id(),
@@ -125,11 +181,31 @@ export const inquiries = sqliteTable("inquiries", {
source: text("source", { enum: ["email", "phone", "messenger"] }) source: text("source", { enum: ["email", "phone", "messenger"] })
.notNull() .notNull()
.default("email"), .default("email"),
status: text("status", { enum: ["new", "quoted", "converted", "declined"] }) // Воронка "Запрос/КП" — kp_preparation это стартовая стадия (замена
// прежней "new"); firstRespondedAt по-прежнему отдельное поле,
// проставляется автоматически при первом уходе со стартовой стадии
// (см. updateInquiry в lib/inquiries/service.ts), а не отдельным тумблером.
status: text("status", { enum: ["kp_preparation", "kp_response", "negotiation", "signing", "converted", "declined"] })
.notNull() .notNull()
.default("new"), .default("kp_preparation"),
firstRespondedAt: integer("first_responded_at", { mode: "timestamp_ms" }), firstRespondedAt: integer("first_responded_at", { mode: "timestamp_ms" }),
convertedOrderId: text("converted_order_id"), convertedOrderId: text("converted_order_id"),
declineReason: text("decline_reason", {
enum: ["too_expensive", "unreachable", "lost_to_competitor", "postponed", "pricing_calc", "slow_processing", "other"],
}),
declineReasonDetail: text("decline_reason_detail"),
incoterm: text("incoterm", { enum: INCOTERMS }),
paymentTerms: text("payment_terms", { enum: PAYMENT_TERMS }),
prepaymentPercent: real("prepayment_percent"),
carrierPayTerms: text("carrier_pay_terms"),
receivablesDeadline: integer("receivables_deadline", { mode: "timestamp_ms" }),
carrier1Id: text("carrier1_id").references(() => counterparties.id),
carrier2Id: text("carrier2_id").references(() => counterparties.id),
carrier3Id: text("carrier3_id").references(() => counterparties.id),
insuranceCompanyId: text("insurance_company_id").references(() => counterparties.id),
customsBrokerId: text("customs_broker_id").references(() => counterparties.id),
recipientContactName: text("recipient_contact_name"),
leadId: text("lead_id"),
...timestamps, ...timestamps,
}); });
@@ -147,6 +223,7 @@ export const inquiryCargoItems = sqliteTable("inquiry_cargo_items", {
widthMm: real("width_mm"), widthMm: real("width_mm"),
heightMm: real("height_mm"), heightMm: real("height_mm"),
weightKg: real("weight_kg"), weightKg: real("weight_kg"),
tnvedCode: text("tnved_code"),
...timestamps, ...timestamps,
}); });
@@ -211,14 +288,48 @@ export const orders = sqliteTable("orders", {
poaVehiclePlate: text("poa_vehicle_plate"), poaVehiclePlate: text("poa_vehicle_plate"),
poaVehicleModel: text("poa_vehicle_model"), poaVehicleModel: text("poa_vehicle_model"),
poaValidUntil: integer("poa_valid_until", { mode: "timestamp_ms" }), poaValidUntil: integer("poa_valid_until", { mode: "timestamp_ms" }),
status: text("status", { enum: ["new", "in_progress", "done", "closed"] }) driverPhone: text("driver_phone"),
// Воронка "Заявка" (см. lib/orders/stages.ts для описания каждой стадии
// из схемы пользователя). completed заменяет прежние done/closed —
// единственное "успешное" терминальное состояние, decline — через
// declineReason (заявка не переходит в completed при отказе).
status: text("status", {
enum: [
"new",
"carrier_placement",
"pickup_from_supplier",
"in_transit",
"border",
"terminal_warehouse",
"delivery_to_client",
"payment_updoc",
"receivables",
"completed",
"declined",
],
})
.notNull() .notNull()
.default("new"), .default("new"),
declineReason: text("decline_reason", { enum: ["cargo_stolen", "cargo_not_shipped", "other"] }),
declineReasonDetail: text("decline_reason_detail"),
paidStatus: text("paid_status", { enum: ["unpaid", "partial", "paid"] }) paidStatus: text("paid_status", { enum: ["unpaid", "partial", "paid"] })
.notNull() .notNull()
.default("unpaid"), .default("unpaid"),
docsReceived: integer("docs_received", { mode: "boolean" }).notNull().default(false), docsReceived: integer("docs_received", { mode: "boolean" }).notNull().default(false),
edoUploaded: integer("edo_uploaded", { mode: "boolean" }).notNull().default(false), edoUploaded: integer("edo_uploaded", { mode: "boolean" }).notNull().default(false),
incoterm: text("incoterm", { enum: INCOTERMS }),
paymentTerms: text("payment_terms", { enum: PAYMENT_TERMS }),
prepaymentPercent: real("prepayment_percent"),
carrierPayTerms: text("carrier_pay_terms"),
receivablesDeadline: integer("receivables_deadline", { mode: "timestamp_ms" }),
carrier1Id: text("carrier1_id").references(() => counterparties.id),
carrier2Id: text("carrier2_id").references(() => counterparties.id),
carrier3Id: text("carrier3_id").references(() => counterparties.id),
insuranceCompanyId: text("insurance_company_id").references(() => counterparties.id),
customsBrokerId: text("customs_broker_id").references(() => counterparties.id),
recipientContactName: text("recipient_contact_name"),
senderContactName: text("sender_contact_name"),
shipDate: integer("ship_date", { mode: "timestamp_ms" }),
publicToken: text("public_token") publicToken: text("public_token")
.notNull() .notNull()
.unique() .unique()
@@ -390,6 +501,11 @@ export const mailboxConfig = sqliteTable("mailbox_config", {
allowInsecureTls: integer("allow_insecure_tls", { mode: "boolean" }).notNull().default(false), allowInsecureTls: integer("allow_insecure_tls", { mode: "boolean" }).notNull().default(false),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
verifiedAt: integer("verified_at", { mode: "timestamp_ms" }), verifiedAt: integer("verified_at", { mode: "timestamp_ms" }),
// Исходящая почта (для email-уведомлений клиенту на смене статуса заявки)
// — тот же почтовый аккаунт (user/passwordEnc), просто SMTP вместо IMAP.
// Нет ни того, ни другого — фича молча не отправляет, как и push без VAPID.
smtpHost: text("smtp_host"),
smtpPort: integer("smtp_port"),
...timestamps, ...timestamps,
}); });
@@ -538,6 +654,11 @@ export const inquiriesRelations = relations(inquiries, ({ one, many }) => ({
quotes: many(inquiryQuotes), quotes: many(inquiryQuotes),
cargoItems: many(inquiryCargoItems), cargoItems: many(inquiryCargoItems),
attachments: many(inquiryAttachments), attachments: many(inquiryAttachments),
carrier1: one(counterparties, { fields: [inquiries.carrier1Id], references: [counterparties.id] }),
carrier2: one(counterparties, { fields: [inquiries.carrier2Id], references: [counterparties.id] }),
carrier3: one(counterparties, { fields: [inquiries.carrier3Id], references: [counterparties.id] }),
insuranceCompany: one(counterparties, { fields: [inquiries.insuranceCompanyId], references: [counterparties.id] }),
customsBroker: one(counterparties, { fields: [inquiries.customsBrokerId], references: [counterparties.id] }),
})); }));
export const inquiryQuotesRelations = relations(inquiryQuotes, ({ one }) => ({ export const inquiryQuotesRelations = relations(inquiryQuotes, ({ one }) => ({
@@ -564,6 +685,16 @@ export const ordersRelations = relations(orders, ({ one, many }) => ({
attachments: many(attachments), attachments: many(attachments),
events: many(orderEvents), events: many(orderEvents),
documentChecklist: many(orderDocumentChecklist), documentChecklist: many(orderDocumentChecklist),
carrier1: one(counterparties, { fields: [orders.carrier1Id], references: [counterparties.id] }),
carrier2: one(counterparties, { fields: [orders.carrier2Id], references: [counterparties.id] }),
carrier3: one(counterparties, { fields: [orders.carrier3Id], references: [counterparties.id] }),
insuranceCompany: one(counterparties, { fields: [orders.insuranceCompanyId], references: [counterparties.id] }),
customsBroker: one(counterparties, { fields: [orders.customsBrokerId], references: [counterparties.id] }),
}));
export const leadsRelations = relations(leads, ({ one }) => ({
logist: one(users, { fields: [leads.logistId], references: [users.id] }),
responsible: one(users, { fields: [leads.responsibleId], references: [users.id] }),
})); }));
export const orderDocumentChecklistRelations = relations(orderDocumentChecklist, ({ one }) => ({ export const orderDocumentChecklistRelations = relations(orderDocumentChecklist, ({ one }) => ({
+70 -30
View File
@@ -1,19 +1,48 @@
import { eq, desc } from "drizzle-orm"; import { eq, desc } from "drizzle-orm";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { inquiries, inquiryQuotes, inquiryCargoItems, orders } from "@/lib/db/schema"; import { inquiries, inquiryQuotes, inquiryCargoItems, orders, type INCOTERMS, type PAYMENT_TERMS } from "@/lib/db/schema";
import { formatInquiryNumber, formatOrderNumber, getManagerNumber, nextInquirySeq, nextOrderSeq } from "@/lib/orders/numbering"; import { formatInquiryNumber, formatOrderNumber, getManagerNumber, nextInquirySeq, nextOrderSeq } from "@/lib/orders/numbering";
import { ensureChecklist } from "@/lib/documents/checklist"; import { ensureChecklist } from "@/lib/documents/checklist";
import { publishEvent } from "@/lib/events/bus"; import { publishEvent } from "@/lib/events/bus";
export type Incoterm = (typeof INCOTERMS)[number];
export type PaymentTerms = (typeof PAYMENT_TERMS)[number];
export type InquiryDeclineReason =
| "too_expensive"
| "unreachable"
| "lost_to_competitor"
| "postponed"
| "pricing_calc"
| "slow_processing"
| "other";
export interface CargoItemInput { export interface CargoItemInput {
quantity?: number; quantity?: number;
lengthMm?: number; lengthMm?: number;
widthMm?: number; widthMm?: number;
heightMm?: number; heightMm?: number;
weightKg?: number; weightKg?: number;
tnvedCode?: string;
} }
export interface CreateInquiryInput { // Общие для Запроса и Заявки поля-справочники (условия поставки/оплаты,
// подрядчики) — общий тип, чтобы не дублировать сигнатуру между
// CreateInquiryInput/UpdateInquiryInput и orders/service.ts.
export interface SharedDealFields {
incoterm?: Incoterm;
paymentTerms?: PaymentTerms;
prepaymentPercent?: number;
carrierPayTerms?: string;
receivablesDeadline?: Date;
carrier1Id?: string;
carrier2Id?: string;
carrier3Id?: string;
insuranceCompanyId?: string;
customsBrokerId?: string;
recipientContactName?: string;
}
export interface CreateInquiryInput extends SharedDealFields {
customerId: string; customerId: string;
managerId: string; managerId: string;
cargoDescription: string; cargoDescription: string;
@@ -27,9 +56,10 @@ export interface CreateInquiryInput {
deadline?: string; deadline?: string;
source: "email" | "phone" | "messenger"; source: "email" | "phone" | "messenger";
cargoItems?: CargoItemInput[]; cargoItems?: CargoItemInput[];
leadId?: string;
} }
export interface UpdateInquiryInput { export interface UpdateInquiryInput extends SharedDealFields {
cargoDescription?: string; cargoDescription?: string;
routeFrom?: string; routeFrom?: string;
routeTo?: string; routeTo?: string;
@@ -39,11 +69,24 @@ export interface UpdateInquiryInput {
clientRate?: number | null; clientRate?: number | null;
comment?: string; comment?: string;
deadline?: string; deadline?: string;
status?: "kp_preparation" | "kp_response" | "negotiation" | "signing";
} }
const INQUIRY_RELATIONS = {
customer: true,
manager: true,
quotes: { with: { carrier: true } },
cargoItems: true,
carrier1: true,
carrier2: true,
carrier3: true,
insuranceCompany: true,
customsBroker: true,
} as const;
export async function listInquiries() { export async function listInquiries() {
return db.query.inquiries.findMany({ return db.query.inquiries.findMany({
with: { customer: true, manager: true, quotes: { with: { carrier: true } }, cargoItems: true }, with: INQUIRY_RELATIONS,
orderBy: desc(inquiries.createdAt), orderBy: desc(inquiries.createdAt),
}); });
} }
@@ -51,13 +94,7 @@ export async function listInquiries() {
export async function getInquiry(id: string) { export async function getInquiry(id: string) {
return db.query.inquiries.findFirst({ return db.query.inquiries.findFirst({
where: eq(inquiries.id, id), where: eq(inquiries.id, id),
with: { with: { ...INQUIRY_RELATIONS, attachments: { with: { uploadedBy: { columns: { id: true, name: true } } } } },
customer: true,
manager: true,
quotes: { with: { carrier: true } },
cargoItems: true,
attachments: { with: { uploadedBy: { columns: { id: true, name: true } } } },
},
}); });
} }
@@ -77,8 +114,14 @@ export async function createInquiry(input: CreateInquiryInput) {
return row; return row;
} }
/** Стадия начинается на kp_preparation; firstRespondedAt проставляется автоматически при первом уходе с неё (заменяет прежний ручной тумблер "Отметить отвеченным" — общий статус-дропдаун теперь покрывает этот переход сам). */
export async function updateInquiry(id: string, input: UpdateInquiryInput) { export async function updateInquiry(id: string, input: UpdateInquiryInput) {
const [row] = await db.update(inquiries).set(input).where(eq(inquiries.id, id)).returning(); const patch: typeof input & { firstRespondedAt?: Date } = { ...input };
if (input.status && input.status !== "kp_preparation") {
const existing = await db.query.inquiries.findFirst({ where: eq(inquiries.id, id) });
if (existing && !existing.firstRespondedAt) patch.firstRespondedAt = new Date();
}
const [row] = await db.update(inquiries).set(patch).where(eq(inquiries.id, id)).returning();
return row; return row;
} }
@@ -95,25 +138,11 @@ export async function deleteQuote(quoteId: string) {
await db.delete(inquiryQuotes).where(eq(inquiryQuotes.id, quoteId)); await db.delete(inquiryQuotes).where(eq(inquiryQuotes.id, quoteId));
} }
export async function declineInquiry(inquiryId: string) { export async function declineInquiry(inquiryId: string, reason: InquiryDeclineReason, detail?: string) {
await db.update(inquiries).set({ status: "declined" }).where(eq(inquiries.id, inquiryId)); await db
}
/** Manual status control for the "new" <-> "quoted" pair — "converted"/"declined" have their own dedicated flows (convertInquiryToOrder/declineInquiry) since they carry side effects beyond a status flip. */
export async function setInquiryAnswered(inquiryId: string, answered: boolean) {
const inquiry = await db.query.inquiries.findFirst({ where: eq(inquiries.id, inquiryId) });
if (!inquiry || inquiry.status === "converted" || inquiry.status === "declined") return inquiry;
const [row] = await db
.update(inquiries) .update(inquiries)
.set( .set({ status: "declined", declineReason: reason, declineReasonDetail: detail })
answered .where(eq(inquiries.id, inquiryId));
? { status: "quoted", firstRespondedAt: inquiry.firstRespondedAt ?? new Date() }
: { status: "new" },
)
.where(eq(inquiries.id, inquiryId))
.returning();
return row;
} }
/** /**
@@ -144,6 +173,17 @@ export async function convertInquiryToOrder(inquiryId: string) {
customsPlace: inquiry.customsPlace, customsPlace: inquiry.customsPlace,
transportMode: inquiry.transportMode, transportMode: inquiry.transportMode,
transportType: inquiry.transportType, transportType: inquiry.transportType,
incoterm: inquiry.incoterm,
paymentTerms: inquiry.paymentTerms,
prepaymentPercent: inquiry.prepaymentPercent,
carrierPayTerms: inquiry.carrierPayTerms,
receivablesDeadline: inquiry.receivablesDeadline,
carrier1Id: inquiry.carrier1Id,
carrier2Id: inquiry.carrier2Id,
carrier3Id: inquiry.carrier3Id,
insuranceCompanyId: inquiry.insuranceCompanyId,
customsBrokerId: inquiry.customsBrokerId,
recipientContactName: inquiry.recipientContactName,
}) })
.returning(); .returning();
+45
View File
@@ -0,0 +1,45 @@
/** Информационная справка по стадиям воронки "Запрос" — из присланных пользователем схем. Не блокирует переход, только подсказка в UI (см. Phase 2 plan: "структура сначала, автоматизация отдельным шагом"). */
export interface StageInfo {
label: string;
requirements: string[];
transitionCriteria: string;
}
export const INQUIRY_STAGE_INFO: Record<string, StageInfo> = {
kp_preparation: {
label: "Подготовить и отправить КП",
requirements: [
"Связаться с клиентом",
"Получить предложение от подрядчиков, ставки на доставку груза",
"Если своё таможенное оформление: запросить документы для ТО, подготовить поставку к оформлению, рассчитать таможенный платёж",
"Проанализировать предложения подрядчиков, сформировать приоритетное предложение",
"Подготовить КП и отправить клиенту по почте либо ЭДО",
],
transitionCriteria: "Клиент получил КП и дал обратную связь.",
},
kp_response: {
label: "Получить ОС по КП",
requirements: [
"Уточнить, какой у клиента есть вариант ставки",
"Перезапросить у подрядчиков предложения по ОС по КП",
"Ещё раз оговорить условия с подрядчиком",
],
transitionCriteria: "Клиент готов заключить сделку — переходим к переговорам. Если нет — отказ с указанием причины.",
},
negotiation: {
label: "Переговоры",
requirements: ["Путём переговоров выяснить, на каких условиях клиент готов подписать контракт"],
transitionCriteria: "Клиент готов к подписанию документов. Если нет — отказ с указанием причины.",
},
signing: {
label: "Подписание документов",
requirements: [
"Запросить карточку компании клиента",
"Подготовить типовой договор/заявку, отправить клиенту по почте либо ЭДО",
"Получить от клиента заполненный вариант поручения экспедитору (или прислать свой вариант)",
"Проверить данные для связи с клиентом и получателем (ФИО, телефоны)",
"Получить подписанный скан от клиента",
],
transitionCriteria: "Клиент подписал документы — создаётся заказ (заявка).",
},
};
+128
View File
@@ -0,0 +1,128 @@
import { eq, desc } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { leads, inquiries } from "@/lib/db/schema";
import { formatLeadNumber, nextLeadSeq } from "@/lib/orders/numbering";
import { publishEvent } from "@/lib/events/bus";
export type LeadSource = "mail" | "phone" | "referral" | "cold_call" | "website" | "tg_bot";
export type LeadDeclineReason = "not_reachable" | "not_target" | "spam" | "duplicate" | "other";
export interface LeadInput {
firstName?: string;
lastName?: string;
patronymic?: string;
birthDate?: Date;
phone?: string;
email?: string;
position?: string;
companyName?: string;
source?: LeadSource;
transportMode?: "road" | "rail" | "sea" | "air";
transportType?: "consolidated" | "tent" | "trawl" | "backhaul" | "container";
// Массивы хранятся как JSON-строка (см. doc-комментарий у leads в schema.ts) — сериализация тут, а не в API-роуте, чтобы вызывающий код всегда работал со string[].
serviceTypes?: string[];
cargoClassification?: string[];
additionalServices?: string;
routeFrom?: string;
routeTo?: string;
cargoDescription?: string;
lengthMm?: string;
widthMm?: string;
heightMm?: string;
weightKg?: string;
volumeM3?: string;
tnvedCode?: string;
placesCount?: number;
cargoCost?: number;
logistId?: string;
responsibleId?: string;
comment?: string;
status?: "new" | "in_progress";
}
function serialize<T extends { serviceTypes?: string[]; cargoClassification?: string[] }>(input: T) {
return {
...input,
serviceTypes: input.serviceTypes ? JSON.stringify(input.serviceTypes) : undefined,
cargoClassification: input.cargoClassification ? JSON.stringify(input.cargoClassification) : undefined,
};
}
function deserialize<T extends { serviceTypes: string | null; cargoClassification: string | null }>(row: T) {
return {
...row,
serviceTypes: row.serviceTypes ? (JSON.parse(row.serviceTypes) as string[]) : [],
cargoClassification: row.cargoClassification ? (JSON.parse(row.cargoClassification) as string[]) : [],
};
}
export async function listLeads() {
const rows = await db.query.leads.findMany({
with: { logist: true, responsible: true },
orderBy: desc(leads.createdAt),
});
return rows.map(deserialize);
}
export async function getLead(id: string) {
const row = await db.query.leads.findFirst({ where: eq(leads.id, id), with: { logist: true, responsible: true } });
return row ? deserialize(row) : null;
}
export async function createLead(input: LeadInput) {
const seq = await nextLeadSeq();
const number = formatLeadNumber(seq);
const [row] = await db.insert(leads).values({ ...serialize(input), number }).returning();
return deserialize(row);
}
/** При взятии в работу назначить responsibleId — этим же вызовом стадия переводится на in_progress (см. lead-actions.tsx). */
export async function updateLead(id: string, input: LeadInput) {
const [row] = await db.update(leads).set(serialize(input)).where(eq(leads.id, id)).returning();
return row ? deserialize(row) : null;
}
export async function declineLead(id: string, reason: LeadDeclineReason, detail?: string) {
await db.update(leads).set({ status: "declined", declineReason: reason, declineReasonDetail: detail }).where(eq(leads.id, id));
}
/**
* Лид -> Запрос. customerId обязателен (как и в createInquiry) — привязку к
* конкретному контрагенту-клиенту делает менеджер в момент конвертации
* (companyName на лиде — просто текст с этапа "неквалифицированного"
* контакта, не гарантированно совпадает с уже заведённым контрагентом).
*/
export async function convertLeadToInquiry(
leadId: string,
extra: { customerId: string; managerId: string; source: "email" | "phone" | "messenger" },
) {
const lead = await db.query.leads.findFirst({ where: eq(leads.id, leadId) });
if (!lead) throw new Error("Лид не найден");
if (lead.status === "converted") throw new Error("Лид уже конвертирован в запрос");
const { formatInquiryNumber, getManagerNumber, nextInquirySeq } = await import("@/lib/orders/numbering");
const managerNumber = await getManagerNumber(extra.managerId);
const seq = await nextInquirySeq(extra.managerId);
const number = formatInquiryNumber(managerNumber, seq);
const [inquiry] = await db
.insert(inquiries)
.values({
number,
customerId: extra.customerId,
managerId: extra.managerId,
source: extra.source,
cargoDescription: lead.cargoDescription || "—",
routeFrom: lead.routeFrom || "—",
routeTo: lead.routeTo || "—",
transportMode: lead.transportMode,
transportType: lead.transportType,
leadId: lead.id,
})
.returning();
await db.update(leads).set({ status: "converted", convertedInquiryId: inquiry.id }).where(eq(leads.id, leadId));
publishEvent({ type: "inquiry.created", inquiryId: inquiry.id, managerId: inquiry.managerId });
return inquiry;
}
+19
View File
@@ -0,0 +1,19 @@
import type { StageInfo } from "@/components/stage-info-panel";
export const LEAD_STAGE_INFO: Record<string, StageInfo> = {
new: {
label: "Новый запрос",
requirements: ["При взятии клиента в работу назначить себя ответственным и перевести карточку на следующую стадию"],
transitionCriteria: "Клиент взят в работу.",
},
in_progress: {
label: "Запрос в обработке",
requirements: [
"Связаться с клиентом, ответить на вопросы",
"Квалифицировать (наш/не наш)",
"Уточнить сферу деятельности клиента, определить нужные услуги",
"Уточнить у клиента состав груза (наименование, код ТНВЭД, габариты, вес, объём, упаковка)",
],
transitionCriteria: "Получены все необходимые сведения для начала обработки запроса.",
},
};
+33
View File
@@ -9,6 +9,15 @@ export interface MailboxSettings {
user: string; user: string;
password: string; password: string;
allowInsecureTls: boolean; allowInsecureTls: boolean;
smtpHost?: string;
smtpPort?: number;
}
export interface SmtpSettings {
host: string;
port: number;
user: string;
password: string;
} }
/** Settings for the running IMAP listener — null if never configured or disabled. */ /** Settings for the running IMAP listener — null if never configured or disabled. */
@@ -22,6 +31,26 @@ export async function getMailboxSettings(): Promise<MailboxSettings | null> {
user: config.user, user: config.user,
password: decryptCredential(config.passwordEnc), password: decryptCredential(config.passwordEnc),
allowInsecureTls: config.allowInsecureTls, allowInsecureTls: config.allowInsecureTls,
smtpHost: config.smtpHost ?? undefined,
smtpPort: config.smtpPort ?? undefined,
};
}
/**
* Настройки исходящей почты — тот же аккаунт (user/password), что и для
* IMAP, независимо от того, включён ли сейчас приём (config.enabled). Null,
* если smtpHost/smtpPort не заполнены — вызывающий код (lib/mail/smtp.ts)
* молча не отправляет, как push без VAPID.
*/
export async function getSmtpSettings(): Promise<SmtpSettings | null> {
const config = await db.query.mailboxConfig.findFirst();
if (!config || !config.smtpHost || !config.smtpPort) return null;
return {
host: config.smtpHost,
port: config.smtpPort,
user: config.user,
password: decryptCredential(config.passwordEnc),
}; };
} }
@@ -34,6 +63,8 @@ export async function saveMailboxSettings(settings: MailboxSettings): Promise<vo
allowInsecureTls: settings.allowInsecureTls, allowInsecureTls: settings.allowInsecureTls,
enabled: true, enabled: true,
verifiedAt: new Date(), verifiedAt: new Date(),
smtpHost: settings.smtpHost,
smtpPort: settings.smtpPort,
}; };
const existing = await db.query.mailboxConfig.findFirst(); const existing = await db.query.mailboxConfig.findFirst();
@@ -59,5 +90,7 @@ export async function getMailboxStatus() {
user: config?.user ?? null, user: config?.user ?? null,
imapHost: config?.imapHost ?? null, imapHost: config?.imapHost ?? null,
verifiedAt: config?.verifiedAt?.getTime() ?? null, verifiedAt: config?.verifiedAt?.getTime() ?? null,
smtpHost: config?.smtpHost ?? null,
smtpPort: config?.smtpPort ?? null,
}; };
} }
+27
View File
@@ -0,0 +1,27 @@
import nodemailer from "nodemailer";
import { getSmtpSettings } from "@/lib/mail/config";
/**
* Исходящая почта — best-effort, как push-уведомления без VAPID: если SMTP
* не настроен (getSmtpSettings вернул null) или отправка упала, функция
* просто логирует и возвращает false, никогда не бросает исключение в
* вызывающий код (не должна ронять смену статуса заявки).
*/
export async function sendMail({ to, subject, text }: { to: string; subject: string; text: string }): Promise<boolean> {
const settings = await getSmtpSettings();
if (!settings) return false;
try {
const transport = nodemailer.createTransport({
host: settings.host,
port: settings.port,
secure: settings.port === 465,
auth: { user: settings.user, pass: settings.password },
});
await transport.sendMail({ from: settings.user, to, subject, text });
return true;
} catch (err) {
console.error("[smtp] send failed for", to, err);
return false;
}
}
+29
View File
@@ -0,0 +1,29 @@
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { orders, counterparties } from "@/lib/db/schema";
import { sendMail } from "@/lib/mail/smtp";
import { CLIENT_NOTIFY_STAGES } from "@/lib/orders/stages";
/**
* Письмо клиенту при смене статуса — только для стадий, явно отмеченных в
* схемах воронки блоком "Уведомление клиента на почту" (см.
* CLIENT_NOTIFY_STAGES). Best-effort: не отправляет и молча выходит, если
* SMTP не настроен или у клиента нет contactEmail — тем же принципом, что
* push без VAPID.
*/
export async function notifyClientOfStatus(orderId: string, status: string): Promise<void> {
const statusText = CLIENT_NOTIFY_STAGES[status];
if (!statusText) return;
const order = await db.query.orders.findFirst({ where: eq(orders.id, orderId) });
if (!order) return;
const customer = await db.query.counterparties.findFirst({ where: eq(counterparties.id, order.customerId) });
if (!customer?.contactEmail) return;
const vehicle = order.poaVehiclePlate ? `Автомобиль (${order.poaVehiclePlate}), водитель ${order.poaDriverName ?? "—"}. ` : "";
await sendMail({
to: customer.contactEmail,
subject: `Заявка ${order.number}: обновление статуса`,
text: `Добрый день, ${customer.contactName ?? customer.name}!\n${vehicle}Статус: ${statusText}.`,
});
}
+17 -1
View File
@@ -1,6 +1,6 @@
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { inquiries, orders, users } from "@/lib/db/schema"; import { inquiries, orders, leads, users } from "@/lib/db/schema";
/** /**
* Запрос и заявка используют один и тот же формат номера — * Запрос и заявка используют один и тот же формат номера —
@@ -43,6 +43,22 @@ export function formatOrderNumber(managerNumber: number, seq: number, date: Date
return formatThNumber(managerNumber, seq, date); return formatThNumber(managerNumber, seq, date);
} }
/**
* Лиды нумеруются отдельно от запросов/заявок — без кода менеджера (лид ещё
* не обязательно закреплён за ответственным на момент создания). Формат
* VTH{год2}-{seq7} — латиница вместо кириллического "ВТХ" из схемы, чтобы не
* смешивать алфавиты в машинном номере (как и с EL/TH у соседних сущностей).
*/
export async function nextLeadSeq(): Promise<number> {
const [row] = await db.select({ count: sql<number>`count(*)` }).from(leads);
return (row?.count ?? 0) + 1;
}
export function formatLeadNumber(seq: number, date: Date = new Date()): string {
const yy = String(date.getFullYear() % 100).padStart(2, "0");
return `VTH${yy}-${String(seq).padStart(7, "0")}`;
}
export async function getManagerNumber(managerId: string): Promise<number> { export async function getManagerNumber(managerId: string): Promise<number> {
const manager = await db.query.users.findFirst({ where: eq(users.id, managerId) }); const manager = await db.query.users.findFirst({ where: eq(users.id, managerId) });
if (!manager?.managerNumber) { if (!manager?.managerNumber) {
+70 -4
View File
@@ -1,7 +1,12 @@
import { eq, desc } from "drizzle-orm"; import { eq, desc } from "drizzle-orm";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { orders, orderIncomeLines, orderExpenseLines, orderEvents, trackingUpdates } from "@/lib/db/schema"; import { orders, orderIncomeLines, orderExpenseLines, orderEvents, trackingUpdates, type INCOTERMS, type PAYMENT_TERMS } from "@/lib/db/schema";
import { publishEvent } from "@/lib/events/bus"; import { publishEvent } from "@/lib/events/bus";
import { notifyClientOfStatus } from "@/lib/orders/notify";
export type Incoterm = (typeof INCOTERMS)[number];
export type OrderPaymentTerms = (typeof PAYMENT_TERMS)[number];
export type OrderDeclineReason = "cargo_stolen" | "cargo_not_shipped" | "other";
export interface OrderVisibility { export interface OrderVisibility {
id: string; id: string;
@@ -11,7 +16,16 @@ export interface OrderVisibility {
/** Менеджер видит только свои заявки; админ и бухгалтер — все (нужно для финансов/отчётов). */ /** Менеджер видит только свои заявки; админ и бухгалтер — все (нужно для финансов/отчётов). */
export async function listOrders(visibility: OrderVisibility) { export async function listOrders(visibility: OrderVisibility) {
const rows = await db.query.orders.findMany({ const rows = await db.query.orders.findMany({
with: { customer: true, manager: true, incomeLines: true, expenseLines: true, documentChecklist: true }, with: {
customer: true,
manager: true,
incomeLines: true,
expenseLines: true,
documentChecklist: true,
carrier1: true,
insuranceCompany: true,
customsBroker: true,
},
orderBy: desc(orders.createdAt), orderBy: desc(orders.createdAt),
}); });
if (visibility.role === "manager") { if (visibility.role === "manager") {
@@ -32,10 +46,23 @@ export async function getOrder(id: string) {
attachments: true, attachments: true,
events: { with: { author: true }, orderBy: desc(orderEvents.createdAt) }, events: { with: { author: true }, orderBy: desc(orderEvents.createdAt) },
documentChecklist: { with: { signedBy: true } }, documentChecklist: { with: { signedBy: true } },
carrier1: true,
carrier2: true,
carrier3: true,
insuranceCompany: true,
customsBroker: true,
}, },
}); });
} }
export async function declineOrder(orderId: string, reason: OrderDeclineReason, detail?: string) {
await db
.update(orders)
.set({ status: "declined", declineReason: reason, declineReasonDetail: detail, updatedAt: new Date() })
.where(eq(orders.id, orderId));
await logSystemEvent(orderId, `Заявка отклонена: ${reason === "cargo_stolen" ? "Украли груз" : reason === "cargo_not_shipped" ? "Груз не вышел фактически" : detail}`);
}
/** Сумма строк с учётом НДС — используется и в марже, и в documents/checklist.ts для сравнения с лимитом подписи менеджера. */ /** Сумма строк с учётом НДС — используется и в марже, и в documents/checklist.ts для сравнения с лимитом подписи менеджера. */
export function sumWithVat(lines: { sum: number; vatRate: number }[]): number { export function sumWithVat(lines: { sum: number; vatRate: number }[]): number {
return lines.reduce((acc, l) => acc + l.sum * (1 + l.vatRate / 100), 0); return lines.reduce((acc, l) => acc + l.sum * (1 + l.vatRate / 100), 0);
@@ -54,7 +81,19 @@ export function computeMargin(order: { incomeLines: { sum: number; vatRate: numb
}; };
} }
const STATUS_LABEL: Record<string, string> = { new: "Новая", in_progress: "На исполнении", done: "Завершена", closed: "Закрыта" }; export const STATUS_LABEL: Record<string, string> = {
new: "Новая заявка",
carrier_placement: "Размещение у подрядчика",
pickup_from_supplier: "Отгрузка от поставщика",
in_transit: "В пути",
border: "Граница",
terminal_warehouse: "Терминал/СВХ",
delivery_to_client: "Доставка клиенту",
payment_updoc: "Получить оплату/УПД",
receivables: "Дебиторка",
completed: "Сделка завершена",
declined: "Отказ",
};
const PAID_LABEL: Record<string, string> = { unpaid: "Не оплачен", partial: "Частично", paid: "Оплачен" }; const PAID_LABEL: Record<string, string> = { unpaid: "Не оплачен", partial: "Частично", paid: "Оплачен" };
export async function logSystemEvent(orderId: string, body: string) { export async function logSystemEvent(orderId: string, body: string) {
@@ -62,10 +101,22 @@ export async function logSystemEvent(orderId: string, body: string) {
publishEvent({ type: "order.event", orderId }); publishEvent({ type: "order.event", orderId });
} }
export type OrderStatus =
| "new"
| "carrier_placement"
| "pickup_from_supplier"
| "in_transit"
| "border"
| "terminal_warehouse"
| "delivery_to_client"
| "payment_updoc"
| "receivables"
| "completed";
export async function updateOrder( export async function updateOrder(
id: string, id: string,
input: Partial<{ input: Partial<{
status: "new" | "in_progress" | "done" | "closed"; status: OrderStatus;
paidStatus: "unpaid" | "partial" | "paid"; paidStatus: "unpaid" | "partial" | "paid";
docsReceived: boolean; docsReceived: boolean;
edoUploaded: boolean; edoUploaded: boolean;
@@ -77,6 +128,20 @@ export async function updateOrder(
poaVehiclePlate: string; poaVehiclePlate: string;
poaVehicleModel: string; poaVehicleModel: string;
poaValidUntil: Date; poaValidUntil: Date;
driverPhone: string;
incoterm: Incoterm;
paymentTerms: OrderPaymentTerms;
prepaymentPercent: number;
carrierPayTerms: string;
receivablesDeadline: Date;
carrier1Id: string;
carrier2Id: string;
carrier3Id: string;
insuranceCompanyId: string;
customsBrokerId: string;
recipientContactName: string;
senderContactName: string;
shipDate: Date;
}>, }>,
) { ) {
const before = await db.query.orders.findFirst({ where: eq(orders.id, id) }); const before = await db.query.orders.findFirst({ where: eq(orders.id, id) });
@@ -89,6 +154,7 @@ export async function updateOrder(
if (before) { if (before) {
if (input.status && input.status !== before.status) { if (input.status && input.status !== before.status) {
await logSystemEvent(id, `Статус изменён: ${STATUS_LABEL[before.status]} → ${STATUS_LABEL[input.status]}`); await logSystemEvent(id, `Статус изменён: ${STATUS_LABEL[before.status]} → ${STATUS_LABEL[input.status]}`);
void notifyClientOfStatus(id, input.status);
} }
if (input.paidStatus && input.paidStatus !== before.paidStatus) { if (input.paidStatus && input.paidStatus !== before.paidStatus) {
await logSystemEvent(id, `Оплата: ${PAID_LABEL[before.paidStatus]} → ${PAID_LABEL[input.paidStatus]}`); await logSystemEvent(id, `Оплата: ${PAID_LABEL[before.paidStatus]} → ${PAID_LABEL[input.paidStatus]}`);
+72
View File
@@ -0,0 +1,72 @@
import type { StageInfo } from "@/components/stage-info-panel";
/** Справка по стадиям воронки "Заявка" — из присланных пользователем схем, дословно сжато. Информационно, не блокирует переход (см. Phase 2 plan). */
export const ORDER_STAGE_INFO: Record<string, StageInfo> = {
new: {
label: "Новая заявка",
requirements: ["При необходимости сменить логиста — если логист не меняется, сразу переводим на следующий этап"],
transitionCriteria: "В сделке актуальный логист.",
},
carrier_placement: {
label: "Размещение у подрядчика",
requirements: [
"Менеджер: незамедлительно проинформировать логиста о новой заявке; выставить счёт клиенту, если нужна предоплата",
"Логист: разместить заявку у утверждённого подрядчика, подтвердить документы, согласовать условия",
"Получить данные на авто и водителя, счёт за услуги от подрядчика",
],
transitionCriteria: "Подписанные документы и счета получены от подрядчика.",
},
pickup_from_supplier: {
label: "Отгрузка от поставщика",
requirements: [
"Связаться с клиентом, подтвердить дату отгрузки",
"Логист: уточнить готовность водителя, застраховать при необходимости, проверить разрешительные документы",
"Получить фото с погрузки, проверить транспортные документы",
],
transitionCriteria: "Груз погружен, ТН получена, все транспортные документы заполнены корректно.",
},
in_transit: {
label: "В пути",
requirements: ["Информировать клиента о возможных задержках", "Отслеживать статус доставки груза", "Если своё ТО — подготовить документы, провести таможенный платёж, передать комплект брокеру"],
transitionCriteria: "Груз прибыл на границу / терминал / СВХ / напрямую к клиенту.",
},
border: {
label: "Граница",
requirements: ["Информировать клиента о возможных задержках", "Удостовериться в правильности заполненной документации"],
transitionCriteria: "Груз прошёл границу, прибыл на СВХ РФ.",
},
terminal_warehouse: {
label: "Терминал/СВХ",
requirements: ["Документы по прибытию груза к таможенному оформлению", "Получить доп. документы для согласования с органами при необходимости", "Согласовать дату доставки получателю"],
transitionCriteria: "Груз растаможен/перегружен/перемаркирован, согласована дата доставки.",
},
delivery_to_client: {
label: "Доставка клиенту",
requirements: ["Подтверждение по доставке груза клиенту", "Согласовать дату, время, адрес, контактное лицо доставки"],
transitionCriteria: "Подписаны документы по приёму груза, есть фото/сканы (ТТН/ЦМР/ТН).",
},
payment_updoc: {
label: "Получить оплату/УПД",
requirements: ["Получить от клиента подписанный УПД (ЭДО, скан или оригинал)", "Проверить поступление оплаты по счетам"],
transitionCriteria: "Клиент запросил отсрочку → Дебиторка. Оплата и УПД получены → Сделка завершена.",
},
receivables: {
label: "Дебиторка",
requirements: ["Уточнить информацию об оплате счёта к согласованной дате", "При переносе — обновить крайний срок оплаты"],
transitionCriteria: "100% оплаты получено.",
},
completed: {
label: "Сделка завершена",
requirements: ["Связываться с клиентом 2 раза в неделю (пн/чт), если платёж отложен"],
transitionCriteria: "—",
},
};
/** Стадии, на которых уходит письмо клиенту (см. lib/orders/notify.ts) — только те, что явно отмечены в схемах блоком "Уведомление клиента на почту". */
export const CLIENT_NOTIFY_STAGES: Record<string, string> = {
pickup_from_supplier: "Груз погружен",
in_transit: "Груз на границе",
border: "Груз на терминале",
terminal_warehouse: "Груз на терминале",
delivery_to_client: "Груз на выгрузке",
};
+13 -5
View File
@@ -1,8 +1,16 @@
import { eq, gte } from "drizzle-orm"; import { eq, gte, inArray } from "drizzle-orm";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { orders, users } from "@/lib/db/schema"; import { orders, users } from "@/lib/db/schema";
import { computeMargin } from "@/lib/orders/service"; import { computeMargin } from "@/lib/orders/service";
// Старая 4-статусная модель (new/in_progress/done/closed) заменена 10
// стадиями (см. Phase 2 plan) — "done" здесь означает "груз доставлен или
// далее по воронке" (delivery_to_client и позже), "closed" — единственное
// финальное успешное состояние "completed". Сохраняет форму отчёта (два
// показателя), не меняя её смысл радикально.
const DELIVERED_OR_LATER = ["delivery_to_client", "payment_updoc", "receivables", "completed"] as const;
const IN_TRANSIT_STATUSES = ["carrier_placement", "pickup_from_supplier", "in_transit", "border", "terminal_warehouse"] as const;
/** По менеджерам: кол-во заявок и маржа — основа для расчёта премий. */ /** По менеджерам: кол-во заявок и маржа — основа для расчёта премий. */
export async function managerReport() { export async function managerReport() {
const managers = await db.query.users.findMany({ where: eq(users.role, "manager") }); const managers = await db.query.users.findMany({ where: eq(users.role, "manager") });
@@ -36,9 +44,9 @@ export async function periodicReport(days: number) {
const margin = computeMargin(o); const margin = computeMargin(o);
acc.turnover += margin.incomeTotal; acc.turnover += margin.incomeTotal;
acc.margin += margin.marginWithVat; acc.margin += margin.marginWithVat;
if (o.status === "done" || o.status === "closed") acc.done += 1; if ((DELIVERED_OR_LATER as readonly string[]).includes(o.status)) acc.done += 1;
if (o.paidStatus === "paid") acc.paid += 1; if (o.paidStatus === "paid") acc.paid += 1;
if (o.status === "closed") acc.closed += 1; if (o.status === "completed") acc.closed += 1;
return acc; return acc;
}, },
{ turnover: 0, margin: 0, done: 0, paid: 0, closed: 0 }, { turnover: 0, margin: 0, done: 0, paid: 0, closed: 0 },
@@ -50,7 +58,7 @@ export async function periodicReport(days: number) {
/** Заявки в пути "прямо сейчас" — для ежедневного контроля. */ /** Заявки в пути "прямо сейчас" — для ежедневного контроля. */
export async function inTransitToday() { export async function inTransitToday() {
return db.query.orders.findMany({ return db.query.orders.findMany({
where: eq(orders.status, "in_progress"), where: inArray(orders.status, [...IN_TRANSIT_STATUSES]),
with: { customer: true, manager: true }, with: { customer: true, manager: true },
}); });
} }
@@ -69,7 +77,7 @@ export async function requestSlaReport() {
responded.length / responded.length /
60_000 60_000
: null; : null;
const pending = own.filter((i) => i.status === "new").length; const pending = own.filter((i) => i.status === "kp_preparation").length;
return { manager: m, total: own.length, avgResponseMinutes, pending }; return { manager: m, total: own.length, avgResponseMinutes, pending };
}); });
} }
+3 -3
View File
@@ -1,12 +1,12 @@
import { eq, ne, desc } from "drizzle-orm"; import { eq, notInArray, desc } from "drizzle-orm";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { orders, trackingUpdates, orderEvents } from "@/lib/db/schema"; import { orders, trackingUpdates, orderEvents } from "@/lib/db/schema";
import { publishEvent } from "@/lib/events/bus"; import { publishEvent } from "@/lib/events/bus";
/** Единая лента слежения — все не закрытые заявки с их последним обновлением. Заменяет три отдельных Excel-файла (Fidelis/Liebherr/Квинтмади). */ /** Единая лента слежения — все не завершённые/не отклонённые заявки с их последним обновлением. Заменяет три отдельных Excel-файла (Fidelis/Liebherr/Квинтмади). */
export async function listActiveTracking() { export async function listActiveTracking() {
const activeOrders = await db.query.orders.findMany({ const activeOrders = await db.query.orders.findMany({
where: ne(orders.status, "closed"), where: notInArray(orders.status, ["completed", "declined"]),
with: { customer: true, manager: true, trackingUpdates: { orderBy: desc(trackingUpdates.createdAt), limit: 1 } }, with: { customer: true, manager: true, trackingUpdates: { orderBy: desc(trackingUpdates.createdAt), limit: 1 } },
orderBy: desc(orders.updatedAt), orderBy: desc(orders.updatedAt),
}); });