From 6cce8f890f052a4890804c75b348860c0ad7df28 Mon Sep 17 00:00:00 2001 From: ogrechko Date: Mon, 31 Aug 2026 18:24:06 +0000 Subject: [PATCH] Add Lead -> Request -> Order funnel redesign (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG --- package-lock.json | 28 +- package.json | 2 + .../inquiries/[id]/inquiry-actions.tsx | 78 +- .../inquiries/[id]/inquiry-edit-form.tsx | 161 +- src/app/(admin)/inquiries/[id]/page.tsx | 42 +- src/app/(admin)/inquiries/board.tsx | 4 +- src/app/(admin)/inquiries/page.tsx | 8 +- src/app/(admin)/leads/[id]/lead-actions.tsx | 127 + src/app/(admin)/leads/[id]/lead-edit-form.tsx | 283 ++ src/app/(admin)/leads/[id]/page.tsx | 75 + src/app/(admin)/leads/board.tsx | 59 + src/app/(admin)/leads/new-lead-modal.tsx | 182 + src/app/(admin)/leads/page.tsx | 31 + src/app/(admin)/leads/toolbar.tsx | 18 + src/app/(admin)/nav-data.ts | 3 +- .../(admin)/orders/[id]/deal-fields-form.tsx | 205 + src/app/(admin)/orders/[id]/page.tsx | 59 +- .../(admin)/orders/[id]/status-controls.tsx | 27 +- src/app/(admin)/orders/page.tsx | 28 +- .../(admin)/settings/email/mailbox-form.tsx | 38 +- src/app/(portal)/t/[token]/page.tsx | 14 +- src/app/api/inquiries/[id]/answered/route.ts | 20 - src/app/api/inquiries/[id]/decline/route.ts | 18 +- src/app/api/inquiries/[id]/route.ts | 12 + src/app/api/leads/[id]/convert/route.ts | 31 + src/app/api/leads/[id]/decline/route.ts | 28 + src/app/api/leads/[id]/route.ts | 60 + src/app/api/leads/route.ts | 53 + src/app/api/orders/[id]/decline/route.ts | 28 + src/app/api/orders/[id]/route.ts | 27 +- src/app/api/settings/mailbox/route.ts | 2 + src/components/decline-with-reason.tsx | 97 + src/components/stage-info-panel.tsx | 26 + .../migrations/0002_bumpy_albert_cleary.sql | 108 + src/lib/db/migrations/meta/0002_snapshot.json | 3373 +++++++++++++++++ src/lib/db/migrations/meta/_journal.json | 7 + src/lib/db/schema.ts | 137 +- src/lib/inquiries/service.ts | 100 +- src/lib/inquiries/stages.ts | 45 + src/lib/leads/service.ts | 128 + src/lib/leads/stages.ts | 19 + src/lib/mail/config.ts | 33 + src/lib/mail/smtp.ts | 27 + src/lib/orders/notify.ts | 29 + src/lib/orders/numbering.ts | 18 +- src/lib/orders/service.ts | 74 +- src/lib/orders/stages.ts | 72 + src/lib/reports/service.ts | 18 +- src/lib/tracking/service.ts | 6 +- 49 files changed, 5931 insertions(+), 137 deletions(-) create mode 100644 src/app/(admin)/leads/[id]/lead-actions.tsx create mode 100644 src/app/(admin)/leads/[id]/lead-edit-form.tsx create mode 100644 src/app/(admin)/leads/[id]/page.tsx create mode 100644 src/app/(admin)/leads/board.tsx create mode 100644 src/app/(admin)/leads/new-lead-modal.tsx create mode 100644 src/app/(admin)/leads/page.tsx create mode 100644 src/app/(admin)/leads/toolbar.tsx create mode 100644 src/app/(admin)/orders/[id]/deal-fields-form.tsx delete mode 100644 src/app/api/inquiries/[id]/answered/route.ts create mode 100644 src/app/api/leads/[id]/convert/route.ts create mode 100644 src/app/api/leads/[id]/decline/route.ts create mode 100644 src/app/api/leads/[id]/route.ts create mode 100644 src/app/api/leads/route.ts create mode 100644 src/app/api/orders/[id]/decline/route.ts create mode 100644 src/components/decline-with-reason.tsx create mode 100644 src/components/stage-info-panel.tsx create mode 100644 src/lib/db/migrations/0002_bumpy_albert_cleary.sql create mode 100644 src/lib/db/migrations/meta/0002_snapshot.json create mode 100644 src/lib/inquiries/stages.ts create mode 100644 src/lib/leads/service.ts create mode 100644 src/lib/leads/stages.ts create mode 100644 src/lib/mail/smtp.ts create mode 100644 src/lib/orders/notify.ts create mode 100644 src/lib/orders/stages.ts diff --git a/package-lock.json b/package-lock.json index 846af9e..cbc8cc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/package.json b/package.json index eb2bc2d..02df20a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(admin)/inquiries/[id]/inquiry-actions.tsx b/src/app/(admin)/inquiries/[id]/inquiry-actions.tsx index d8f9d4b..53487d8 100644 --- a/src/app/(admin)/inquiries/[id]/inquiry-actions.tsx +++ b/src/app/(admin)/inquiries/[id]/inquiry-actions.tsx @@ -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(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 ( -
- {status === "new" ? ( - - ) : ( - - )} - - +
+ + +
+ {status === "signing" && ( + + )} + +
{error &&

{error}

}
); diff --git a/src/app/(admin)/inquiries/[id]/inquiry-edit-form.tsx b/src/app/(admin)/inquiries/[id]/inquiry-edit-form.tsx index 06994d6..d745bcd 100644 --- a/src/app/(admin)/inquiries/[id]/inquiry-edit-form.tsx +++ b/src/app/(admin)/inquiries/[id]/inquiry-edit-form.tsx @@ -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 = { + 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(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 }) { /> +
+

Условия сделки

+
+ + +
+ {paymentTerms === "partial_prepayment" && ( + + )} +