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:
1 parent
e9e05d859d
commit
6cce8f890f
49 files changed
+5924
-130
No files matched your search
Generated
+25
-3
@@ -19,6 +19,7 @@
|
||||
"lucide-react": "^0.545.0",
|
||||
"mailparser": "^3.9.15",
|
||||
"next": "16.2.12",
|
||||
"nodemailer": "^9.1.0",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pizzip": "^3.2.0",
|
||||
"react": "19.2.4",
|
||||
@@ -32,6 +33,7 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/web-push": "^3.6.4",
|
||||
@@ -2596,6 +2598,16 @@
|
||||
"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": {
|
||||
"version": "19.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||
@@ -6630,6 +6642,15 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -6911,9 +6932,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
|
||||
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.0.tgz",
|
||||
"integrity": "sha512-xj1Ri5Sau3qpPffHJwi2bY0oWVVWYq62Ph17l+2v1xXpxjqTls3YPc3L8dub9mcJpxj+1ucNnuYVqLlgh4pmDA==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"lucide-react": "^0.545.0",
|
||||
"mailparser": "^3.9.15",
|
||||
"next": "16.2.12",
|
||||
"nodemailer": "^9.1.0",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pizzip": "^3.2.0",
|
||||
"react": "19.2.4",
|
||||
@@ -36,6 +37,7 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/web-push": "^3.6.4",
|
||||
|
||||
@@ -2,10 +2,28 @@
|
||||
|
||||
import { useState } from "react";
|
||||
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 }) {
|
||||
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);
|
||||
|
||||
async function convert() {
|
||||
@@ -22,19 +40,12 @@ export function InquiryActions({ inquiryId, status }: { inquiryId: string; statu
|
||||
router.push(`/orders/${order.id}`);
|
||||
}
|
||||
|
||||
async function decline() {
|
||||
setLoading("decline");
|
||||
await fetch(`/api/inquiries/${inquiryId}/decline`, { method: "POST" });
|
||||
setLoading(null);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function toggleAnswered(answered: boolean) {
|
||||
setLoading("answered");
|
||||
await fetch(`/api/inquiries/${inquiryId}/answered`, {
|
||||
method: "POST",
|
||||
async function changeStatus(next: string) {
|
||||
setLoading("status");
|
||||
await fetch(`/api/inquiries/${inquiryId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ answered }),
|
||||
body: JSON.stringify({ status: next }),
|
||||
});
|
||||
setLoading(null);
|
||||
router.refresh();
|
||||
@@ -43,22 +54,31 @@ export function InquiryActions({ inquiryId, status }: { inquiryId: string; statu
|
||||
if (status === "converted" || status === "declined") return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="text-text-muted">Стадия</span>
|
||||
<select
|
||||
value={status}
|
||||
disabled={loading !== null}
|
||||
onChange={(e) => changeStatus(e.target.value)}
|
||||
className="rounded-md border border-border bg-surface px-2 py-1.5 outline-none focus:border-accent"
|
||||
>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{status === "new" ? (
|
||||
<button onClick={() => toggleAnswered(true)} disabled={loading !== null} className="btn btn-ghost">
|
||||
{loading === "answered" ? "…" : "Отметить отвеченным"}
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => toggleAnswered(false)} disabled={loading !== null} className="btn btn-ghost">
|
||||
{loading === "answered" ? "…" : "Вернуть в «Новые»"}
|
||||
</button>
|
||||
)}
|
||||
{status === "signing" && (
|
||||
<button onClick={convert} disabled={loading !== null} className="btn btn-primary">
|
||||
{loading === "convert" ? "Создаём заявку…" : "Создать заявку"}
|
||||
</button>
|
||||
<button onClick={decline} disabled={loading !== null} className="btn btn-ghost">
|
||||
{loading === "decline" ? "…" : "Отменить запрос"}
|
||||
</button>
|
||||
)}
|
||||
<DeclineWithReason url={`/api/inquiries/${inquiryId}/decline`} reasons={DECLINE_REASONS} />
|
||||
</div>
|
||||
{error && <p className="text-sm text-danger-soft-text">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AddressAutocomplete } from "@/components/address-autocomplete";
|
||||
import { SearchableSelect, type SearchableOption } from "@/components/searchable-select";
|
||||
|
||||
interface Inquiry {
|
||||
id: string;
|
||||
@@ -14,9 +15,41 @@ interface Inquiry {
|
||||
transportType: "consolidated" | "tent" | "trawl" | "backhaul" | "container" | null;
|
||||
comment: 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 [cargoDescription, setCargoDescription] = useState(inquiry.cargoDescription);
|
||||
const [routeFrom, setRouteFrom] = useState(inquiry.routeFrom);
|
||||
@@ -26,6 +59,17 @@ export function InquiryEditForm({ inquiry }: { inquiry: Inquiry }) {
|
||||
const [transportType, setTransportType] = useState(inquiry.transportType ?? "");
|
||||
const [comment, setComment] = useState(inquiry.comment ?? "");
|
||||
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 [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -48,6 +92,17 @@ export function InquiryEditForm({ inquiry }: { inquiry: Inquiry }) {
|
||||
transportType: transportType || undefined,
|
||||
comment: comment || 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>
|
||||
|
||||
<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>}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -3,7 +3,9 @@ import Link from "next/link";
|
||||
import { ArrowLeft, FileText, Download } from "lucide-react";
|
||||
import { getCurrentSession } from "@/lib/auth/session";
|
||||
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 { InquiryActions } from "./inquiry-actions";
|
||||
import { InquiryAttachmentUpload } from "./attachment-upload";
|
||||
@@ -11,12 +13,23 @@ import { InquiryEditForm } from "./inquiry-edit-form";
|
||||
import { QuotesList } from "./quotes-list";
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
new: "Новый",
|
||||
quoted: "Отвечен",
|
||||
kp_preparation: "Подготовить КП",
|
||||
kp_response: "Получить ОС по КП",
|
||||
negotiation: "Переговоры",
|
||||
signing: "Подписание документов",
|
||||
converted: "В заявке",
|
||||
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> = {
|
||||
email: "Почта",
|
||||
phone: "Телефон",
|
||||
@@ -28,7 +41,7 @@ export default async function InquiryDetailPage({ params }: { params: Promise<{
|
||||
if (!session) redirect("/login");
|
||||
|
||||
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();
|
||||
|
||||
return (
|
||||
@@ -45,9 +58,11 @@ export default async function InquiryDetailPage({ params }: { params: Promise<{
|
||||
{inquiry.customer?.name} · менеджер {inquiry.manager?.name}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`badge badge-${inquiry.status === "converted" ? "closed" : inquiry.status === "declined" ? "declined" : inquiry.status === "quoted" ? "open" : "new"}`}>
|
||||
{STATUS_LABEL[inquiry.status]}
|
||||
</span>
|
||||
<span className={`badge badge-${STATUS_BADGE[inquiry.status]}`}>{STATUS_LABEL[inquiry.status]}</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<StageInfoPanel info={INQUIRY_STAGE_INFO[inquiry.status]} />
|
||||
</div>
|
||||
|
||||
<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,
|
||||
comment: inquiry.comment,
|
||||
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>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ interface Quote {
|
||||
interface InquiryItem {
|
||||
id: string;
|
||||
number: string;
|
||||
status: "new" | "quoted" | "converted" | "declined";
|
||||
status: "kp_preparation" | "kp_response" | "negotiation" | "signing" | "converted" | "declined";
|
||||
cargoDescription: string;
|
||||
routeFrom: string;
|
||||
routeTo: string;
|
||||
@@ -110,7 +110,7 @@ export function InquiriesBoard({ inquiries, columns }: { inquiries: InquiryItem[
|
||||
</span>
|
||||
</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) => {
|
||||
const items = filtered.filter((i) => i.status === col.status);
|
||||
return (
|
||||
|
||||
@@ -9,9 +9,11 @@ import { listPendingDrafts } from "@/lib/inquiries/drafts";
|
||||
import { InquiriesToolbar } from "./toolbar";
|
||||
import { InquiriesBoard } from "./board";
|
||||
|
||||
const COLUMNS: { status: "new" | "quoted" | "converted" | "declined"; label: string }[] = [
|
||||
{ status: "new", label: "Новые" },
|
||||
{ status: "quoted", label: "Отвечен" },
|
||||
const COLUMNS: { status: "kp_preparation" | "kp_response" | "negotiation" | "signing" | "converted" | "declined"; label: string }[] = [
|
||||
{ status: "kp_preparation", label: "Подготовить КП" },
|
||||
{ status: "kp_response", label: "Получить ОС по КП" },
|
||||
{ status: "negotiation", label: "Переговоры" },
|
||||
{ status: "signing", label: "Подписание документов" },
|
||||
{ status: "converted", label: "В заявке" },
|
||||
{ status: "declined", label: "Отклонён" },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
export type Role = "admin" | "manager" | "accountant";
|
||||
@@ -9,6 +9,7 @@ export type Role = "admin" | "manager" | "accountant";
|
||||
// Role restriction is temporarily disabled everywhere (see requireRole) —
|
||||
// every link is shown to every authenticated user regardless of 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: "/orders", label: "Заявки", icon: Truck, 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>
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,19 @@ import { notFound, redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
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 { listCarriers } from "@/lib/counterparties/service";
|
||||
import { listCarriers, listCounterparties } from "@/lib/counterparties/service";
|
||||
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 { IncomeSection } from "./income-section";
|
||||
import { ExpenseSection } from "./expense-section";
|
||||
import { EventsSection } from "./events-section";
|
||||
import { DocumentsSection } from "./documents-section";
|
||||
import { SignChecklistSection } from "./sign-checklist-section";
|
||||
import { DealFieldsForm } from "./deal-fields-form";
|
||||
|
||||
export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getCurrentSession();
|
||||
@@ -22,8 +25,21 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
|
||||
if (!order) notFound();
|
||||
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 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 (
|
||||
<div>
|
||||
@@ -39,10 +55,17 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
|
||||
{order.customer?.name} · менеджер {order.manager?.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`badge badge-${STATUS_BADGE[order.status]}`}>{STATUS_LABEL[order.status]}</span>
|
||||
<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 className="mb-5 card p-4">
|
||||
<StatusControls
|
||||
@@ -89,6 +112,30 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
|
||||
</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">
|
||||
<IncomeSection orderId={order.id} lines={order.incomeLines} />
|
||||
<ExpenseSection
|
||||
|
||||
@@ -2,18 +2,30 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DeclineWithReason } from "@/components/decline-with-reason";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "new", label: "Новая" },
|
||||
{ value: "in_progress", label: "На исполнении" },
|
||||
{ value: "done", label: "Завершена" },
|
||||
{ value: "closed", label: "Закрыта" },
|
||||
{ value: "new", label: "Новая заявка" },
|
||||
{ value: "carrier_placement", label: "Размещение у подрядчика" },
|
||||
{ value: "pickup_from_supplier", 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 = [
|
||||
{ value: "unpaid", label: "Не оплачен" },
|
||||
{ value: "partial", label: "Частично" },
|
||||
{ value: "paid", label: "Оплачен" },
|
||||
];
|
||||
const DECLINE_REASONS = [
|
||||
{ value: "cargo_stolen", label: "Украли груз" },
|
||||
{ value: "cargo_not_shipped", label: "Груз не вышел фактически" },
|
||||
{ value: "other", label: "Другая причина отказа" },
|
||||
];
|
||||
|
||||
export function StatusControls({
|
||||
orderId,
|
||||
@@ -48,10 +60,11 @@ export function StatusControls({
|
||||
<span className="text-text-muted">Статус</span>
|
||||
<select
|
||||
value={status}
|
||||
disabled={loading}
|
||||
disabled={loading || status === "declined"}
|
||||
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"
|
||||
>
|
||||
{status === "declined" && <option value="declined">Отказ</option>}
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
@@ -85,6 +98,10 @@ export function StatusControls({
|
||||
<input type="checkbox" checked={edoUploaded} disabled={loading} onChange={(e) => patch({ edoUploaded: e.target.checked })} />
|
||||
<span className="text-text-muted">Выгружено в ЭДО</span>
|
||||
</label>
|
||||
|
||||
{status !== "declined" && status !== "completed" && (
|
||||
<DeclineWithReason url={`/api/orders/${orderId}/decline`} reasons={DECLINE_REASONS} onDeclined={() => router.refresh()} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,16 +5,30 @@ import { listOrders, computeMargin } from "@/lib/orders/service";
|
||||
import { getOrderDocStatus } from "@/lib/documents/checklist";
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
new: "Новая",
|
||||
in_progress: "На исполнении",
|
||||
done: "Завершена",
|
||||
closed: "Закрыта",
|
||||
new: "Новая заявка",
|
||||
carrier_placement: "Размещение у подрядчика",
|
||||
pickup_from_supplier: "Отгрузка от поставщика",
|
||||
in_transit: "В пути",
|
||||
border: "Граница",
|
||||
terminal_warehouse: "Терминал/СВХ",
|
||||
delivery_to_client: "Доставка клиенту",
|
||||
payment_updoc: "Оплата/УПД",
|
||||
receivables: "Дебиторка",
|
||||
completed: "Завершена",
|
||||
declined: "Отказ",
|
||||
};
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
new: "badge-new",
|
||||
in_progress: "badge-open",
|
||||
done: "badge-done",
|
||||
closed: "badge-closed",
|
||||
carrier_placement: "badge-new",
|
||||
pickup_from_supplier: "badge-open",
|
||||
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 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;
|
||||
imapHost: string | null;
|
||||
verifiedAt: number | null;
|
||||
smtpHost: string | null;
|
||||
smtpPort: number | null;
|
||||
}
|
||||
|
||||
export function MailboxForm({ status }: { status: Status }) {
|
||||
@@ -18,6 +20,8 @@ export function MailboxForm({ status }: { status: Status }) {
|
||||
const [user, setUser] = useState(status.user ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -29,7 +33,15 @@ export function MailboxForm({ status }: { status: Status }) {
|
||||
const res = await fetch("/api/settings/mailbox", {
|
||||
method: "POST",
|
||||
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);
|
||||
@@ -109,6 +121,30 @@ export function MailboxForm({ status }: { status: Status }) {
|
||||
<span className="text-text-muted">Разрешить недоверенный TLS-сертификат</span>
|
||||
</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>}
|
||||
|
||||
<div className="flex gap-2 sm:col-span-2">
|
||||
|
||||
@@ -2,11 +2,19 @@ import { notFound } from "next/navigation";
|
||||
import { Truck, MapPin } from "lucide-react";
|
||||
import { getOrderByPublicToken } from "@/lib/tracking/service";
|
||||
|
||||
// Клиенту показываем упрощённые формулировки, а не внутренние 10 стадий воронки заявки.
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
new: "Принята в работу",
|
||||
in_progress: "В пути",
|
||||
done: "Выгружена",
|
||||
closed: "Завершена",
|
||||
carrier_placement: "Принята в работу",
|
||||
pickup_from_supplier: "Груз погружен",
|
||||
in_transit: "В пути",
|
||||
border: "На границе",
|
||||
terminal_warehouse: "На терминале",
|
||||
delivery_to_client: "На выгрузке",
|
||||
payment_updoc: "Выгружена",
|
||||
receivables: "Выгружена",
|
||||
completed: "Завершена",
|
||||
declined: "Отменена",
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1,14 +1,28 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireRole } from "@/lib/auth/require";
|
||||
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"]);
|
||||
if (!session) return response;
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { requireRole } from "@/lib/auth/require";
|
||||
import { getInquiry, updateInquiry } from "@/lib/inquiries/service";
|
||||
|
||||
const schema = z.object({
|
||||
status: z.enum(["kp_preparation", "kp_response", "negotiation", "signing"]).optional(),
|
||||
cargoDescription: z.string().min(1).optional(),
|
||||
routeFrom: 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(),
|
||||
comment: 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 }> }) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -7,7 +7,18 @@ import { getOrder, updateOrder } from "@/lib/orders/service";
|
||||
import { canAccessOrder } from "@/lib/orders/visibility";
|
||||
|
||||
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(),
|
||||
docsReceived: z.boolean().optional(),
|
||||
edoUploaded: z.boolean().optional(),
|
||||
@@ -17,6 +28,20 @@ const schema = z.object({
|
||||
poaVehiclePlate: z.string().optional(),
|
||||
poaVehicleModel: z.string().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 }> }) {
|
||||
|
||||
@@ -18,6 +18,8 @@ const schema = z.object({
|
||||
user: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
allowInsecureTls: z.boolean().optional(),
|
||||
smtpHost: z.string().optional(),
|
||||
smtpPort: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -15,6 +15,13 @@
|
||||
"when": 1788197632163,
|
||||
"tag": "0001_normal_captain_stacy",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1788199359419,
|
||||
"tag": "0002_bumpy_albert_cleary",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+134
-3
@@ -13,6 +13,11 @@ const timestamps = {
|
||||
.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}), поэтому уникален и стабилен — не переиспользовать после смены сотрудника. */
|
||||
export const users = sqliteTable("users", {
|
||||
id: id(),
|
||||
@@ -99,6 +104,57 @@ export const counterpartyContacts = sqliteTable("counterparty_contacts", {
|
||||
...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. */
|
||||
export const inquiries = sqliteTable("inquiries", {
|
||||
id: id(),
|
||||
@@ -125,11 +181,31 @@ export const inquiries = sqliteTable("inquiries", {
|
||||
source: text("source", { enum: ["email", "phone", "messenger"] })
|
||||
.notNull()
|
||||
.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()
|
||||
.default("new"),
|
||||
.default("kp_preparation"),
|
||||
firstRespondedAt: integer("first_responded_at", { mode: "timestamp_ms" }),
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -147,6 +223,7 @@ export const inquiryCargoItems = sqliteTable("inquiry_cargo_items", {
|
||||
widthMm: real("width_mm"),
|
||||
heightMm: real("height_mm"),
|
||||
weightKg: real("weight_kg"),
|
||||
tnvedCode: text("tnved_code"),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
@@ -211,14 +288,48 @@ export const orders = sqliteTable("orders", {
|
||||
poaVehiclePlate: text("poa_vehicle_plate"),
|
||||
poaVehicleModel: text("poa_vehicle_model"),
|
||||
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()
|
||||
.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"] })
|
||||
.notNull()
|
||||
.default("unpaid"),
|
||||
docsReceived: integer("docs_received", { 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")
|
||||
.notNull()
|
||||
.unique()
|
||||
@@ -390,6 +501,11 @@ export const mailboxConfig = sqliteTable("mailbox_config", {
|
||||
allowInsecureTls: integer("allow_insecure_tls", { mode: "boolean" }).notNull().default(false),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
verifiedAt: integer("verified_at", { mode: "timestamp_ms" }),
|
||||
// Исходящая почта (для email-уведомлений клиенту на смене статуса заявки)
|
||||
// — тот же почтовый аккаунт (user/passwordEnc), просто SMTP вместо IMAP.
|
||||
// Нет ни того, ни другого — фича молча не отправляет, как и push без VAPID.
|
||||
smtpHost: text("smtp_host"),
|
||||
smtpPort: integer("smtp_port"),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
@@ -538,6 +654,11 @@ export const inquiriesRelations = relations(inquiries, ({ one, many }) => ({
|
||||
quotes: many(inquiryQuotes),
|
||||
cargoItems: many(inquiryCargoItems),
|
||||
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 }) => ({
|
||||
@@ -564,6 +685,16 @@ export const ordersRelations = relations(orders, ({ one, many }) => ({
|
||||
attachments: many(attachments),
|
||||
events: many(orderEvents),
|
||||
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 }) => ({
|
||||
|
||||
@@ -1,19 +1,48 @@
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
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 { ensureChecklist } from "@/lib/documents/checklist";
|
||||
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 {
|
||||
quantity?: number;
|
||||
lengthMm?: number;
|
||||
widthMm?: number;
|
||||
heightMm?: 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;
|
||||
managerId: string;
|
||||
cargoDescription: string;
|
||||
@@ -27,9 +56,10 @@ export interface CreateInquiryInput {
|
||||
deadline?: string;
|
||||
source: "email" | "phone" | "messenger";
|
||||
cargoItems?: CargoItemInput[];
|
||||
leadId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateInquiryInput {
|
||||
export interface UpdateInquiryInput extends SharedDealFields {
|
||||
cargoDescription?: string;
|
||||
routeFrom?: string;
|
||||
routeTo?: string;
|
||||
@@ -39,11 +69,24 @@ export interface UpdateInquiryInput {
|
||||
clientRate?: number | null;
|
||||
comment?: 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() {
|
||||
return db.query.inquiries.findMany({
|
||||
with: { customer: true, manager: true, quotes: { with: { carrier: true } }, cargoItems: true },
|
||||
with: INQUIRY_RELATIONS,
|
||||
orderBy: desc(inquiries.createdAt),
|
||||
});
|
||||
}
|
||||
@@ -51,13 +94,7 @@ export async function listInquiries() {
|
||||
export async function getInquiry(id: string) {
|
||||
return db.query.inquiries.findFirst({
|
||||
where: eq(inquiries.id, id),
|
||||
with: {
|
||||
customer: true,
|
||||
manager: true,
|
||||
quotes: { with: { carrier: true } },
|
||||
cargoItems: true,
|
||||
attachments: { with: { uploadedBy: { columns: { id: true, name: true } } } },
|
||||
},
|
||||
with: { ...INQUIRY_RELATIONS, attachments: { with: { uploadedBy: { columns: { id: true, name: true } } } } },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,8 +114,14 @@ export async function createInquiry(input: CreateInquiryInput) {
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Стадия начинается на kp_preparation; firstRespondedAt проставляется автоматически при первом уходе с неё (заменяет прежний ручной тумблер "Отметить отвеченным" — общий статус-дропдаун теперь покрывает этот переход сам). */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -95,25 +138,11 @@ export async function deleteQuote(quoteId: string) {
|
||||
await db.delete(inquiryQuotes).where(eq(inquiryQuotes.id, quoteId));
|
||||
}
|
||||
|
||||
export async function declineInquiry(inquiryId: string) {
|
||||
await db.update(inquiries).set({ status: "declined" }).where(eq(inquiries.id, inquiryId));
|
||||
}
|
||||
|
||||
/** 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
|
||||
export async function declineInquiry(inquiryId: string, reason: InquiryDeclineReason, detail?: string) {
|
||||
await db
|
||||
.update(inquiries)
|
||||
.set(
|
||||
answered
|
||||
? { status: "quoted", firstRespondedAt: inquiry.firstRespondedAt ?? new Date() }
|
||||
: { status: "new" },
|
||||
)
|
||||
.where(eq(inquiries.id, inquiryId))
|
||||
.returning();
|
||||
return row;
|
||||
.set({ status: "declined", declineReason: reason, declineReasonDetail: detail })
|
||||
.where(eq(inquiries.id, inquiryId));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,6 +173,17 @@ export async function convertInquiryToOrder(inquiryId: string) {
|
||||
customsPlace: inquiry.customsPlace,
|
||||
transportMode: inquiry.transportMode,
|
||||
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();
|
||||
|
||||
|
||||
@@ -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: "Клиент подписал документы — создаётся заказ (заявка).",
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: "Получены все необходимые сведения для начала обработки запроса.",
|
||||
},
|
||||
};
|
||||
@@ -9,6 +9,15 @@ export interface MailboxSettings {
|
||||
user: string;
|
||||
password: string;
|
||||
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. */
|
||||
@@ -22,6 +31,26 @@ export async function getMailboxSettings(): Promise<MailboxSettings | null> {
|
||||
user: config.user,
|
||||
password: decryptCredential(config.passwordEnc),
|
||||
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,
|
||||
enabled: true,
|
||||
verifiedAt: new Date(),
|
||||
smtpHost: settings.smtpHost,
|
||||
smtpPort: settings.smtpPort,
|
||||
};
|
||||
|
||||
const existing = await db.query.mailboxConfig.findFirst();
|
||||
@@ -59,5 +90,7 @@ export async function getMailboxStatus() {
|
||||
user: config?.user ?? null,
|
||||
imapHost: config?.imapHost ?? null,
|
||||
verifiedAt: config?.verifiedAt?.getTime() ?? null,
|
||||
smtpHost: config?.smtpHost ?? null,
|
||||
smtpPort: config?.smtpPort ?? null,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}.`,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Лиды нумеруются отдельно от запросов/заявок — без кода менеджера (лид ещё
|
||||
* не обязательно закреплён за ответственным на момент создания). Формат
|
||||
* 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> {
|
||||
const manager = await db.query.users.findFirst({ where: eq(users.id, managerId) });
|
||||
if (!manager?.managerNumber) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
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 { 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 {
|
||||
id: string;
|
||||
@@ -11,7 +16,16 @@ export interface OrderVisibility {
|
||||
/** Менеджер видит только свои заявки; админ и бухгалтер — все (нужно для финансов/отчётов). */
|
||||
export async function listOrders(visibility: OrderVisibility) {
|
||||
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),
|
||||
});
|
||||
if (visibility.role === "manager") {
|
||||
@@ -32,10 +46,23 @@ export async function getOrder(id: string) {
|
||||
attachments: true,
|
||||
events: { with: { author: true }, orderBy: desc(orderEvents.createdAt) },
|
||||
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 для сравнения с лимитом подписи менеджера. */
|
||||
export function sumWithVat(lines: { sum: number; vatRate: number }[]): number {
|
||||
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: "Оплачен" };
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
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(
|
||||
id: string,
|
||||
input: Partial<{
|
||||
status: "new" | "in_progress" | "done" | "closed";
|
||||
status: OrderStatus;
|
||||
paidStatus: "unpaid" | "partial" | "paid";
|
||||
docsReceived: boolean;
|
||||
edoUploaded: boolean;
|
||||
@@ -77,6 +128,20 @@ export async function updateOrder(
|
||||
poaVehiclePlate: string;
|
||||
poaVehicleModel: string;
|
||||
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) });
|
||||
@@ -89,6 +154,7 @@ export async function updateOrder(
|
||||
if (before) {
|
||||
if (input.status && input.status !== before.status) {
|
||||
await logSystemEvent(id, `Статус изменён: ${STATUS_LABEL[before.status]} → ${STATUS_LABEL[input.status]}`);
|
||||
void notifyClientOfStatus(id, input.status);
|
||||
}
|
||||
if (input.paidStatus && input.paidStatus !== before.paidStatus) {
|
||||
await logSystemEvent(id, `Оплата: ${PAID_LABEL[before.paidStatus]} → ${PAID_LABEL[input.paidStatus]}`);
|
||||
|
||||
@@ -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: "Груз на выгрузке",
|
||||
};
|
||||
@@ -1,8 +1,16 @@
|
||||
import { eq, gte } from "drizzle-orm";
|
||||
import { eq, gte, inArray } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { orders, users } from "@/lib/db/schema";
|
||||
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() {
|
||||
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);
|
||||
acc.turnover += margin.incomeTotal;
|
||||
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.status === "closed") acc.closed += 1;
|
||||
if (o.status === "completed") acc.closed += 1;
|
||||
return acc;
|
||||
},
|
||||
{ turnover: 0, margin: 0, done: 0, paid: 0, closed: 0 },
|
||||
@@ -50,7 +58,7 @@ export async function periodicReport(days: number) {
|
||||
/** Заявки в пути "прямо сейчас" — для ежедневного контроля. */
|
||||
export async function inTransitToday() {
|
||||
return db.query.orders.findMany({
|
||||
where: eq(orders.status, "in_progress"),
|
||||
where: inArray(orders.status, [...IN_TRANSIT_STATUSES]),
|
||||
with: { customer: true, manager: true },
|
||||
});
|
||||
}
|
||||
@@ -69,7 +77,7 @@ export async function requestSlaReport() {
|
||||
responded.length /
|
||||
60_000
|
||||
: 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 };
|
||||
});
|
||||
}
|
||||
@@ -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 { orders, trackingUpdates, orderEvents } from "@/lib/db/schema";
|
||||
import { publishEvent } from "@/lib/events/bus";
|
||||
|
||||
/** Единая лента слежения — все не закрытые заявки с их последним обновлением. Заменяет три отдельных Excel-файла (Fidelis/Liebherr/Квинтмади). */
|
||||
/** Единая лента слежения — все не завершённые/не отклонённые заявки с их последним обновлением. Заменяет три отдельных Excel-файла (Fidelis/Liebherr/Квинтмади). */
|
||||
export async function listActiveTracking() {
|
||||
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 } },
|
||||
orderBy: desc(orders.updatedAt),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user