Add document-signature authority + traffic-light order status (Phase 1)
Per-user configurable signing rights (canSign + optional ruble limit) with a facsimile-signature image upload; a per-order checklist of the 4 minimum documents (Договор/Заявка-договор+Поручение, УПД, ЭТрН, Экспедиторская расписка) with a red/yellow/green status derived from present+signed state. Signing checks the order's income-line sum against the signer's limit; if they're not authorized, the request is redirected to everyone who can sign (push notification + order comment) instead of silently failing. Phase 2 (the 3-funnel stage/field redesign from the flowchart screenshots, plus subcontractor mailing) is separate follow-up work. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
This commit is contained in:
1 parent
579528edf7
commit
e9e05d859d
22 files changed
+3385
-5
No files matched your search
@@ -11,6 +11,7 @@ import { IncomeSection } from "./income-section";
|
|||||||
import { ExpenseSection } from "./expense-section";
|
import { ExpenseSection } from "./expense-section";
|
||||||
import { EventsSection } from "./events-section";
|
import { EventsSection } from "./events-section";
|
||||||
import { DocumentsSection } from "./documents-section";
|
import { DocumentsSection } from "./documents-section";
|
||||||
|
import { SignChecklistSection } from "./sign-checklist-section";
|
||||||
|
|
||||||
export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const session = await getCurrentSession();
|
const session = await getCurrentSession();
|
||||||
@@ -97,6 +98,19 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-5">
|
||||||
|
<SignChecklistSection
|
||||||
|
orderId={order.id}
|
||||||
|
rows={order.documentChecklist.map((r) => ({
|
||||||
|
kind: r.kind,
|
||||||
|
present: r.present,
|
||||||
|
signedByUserId: r.signedByUserId,
|
||||||
|
signedAt: r.signedAt,
|
||||||
|
signedBy: r.signedBy ? { id: r.signedBy.id, name: r.signedBy.name } : null,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<DocumentsSection
|
<DocumentsSection
|
||||||
orderId={order.id}
|
orderId={order.id}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export type DocKind = "contract" | "upd" | "etrn" | "forwarder_receipt";
|
||||||
|
|
||||||
|
const DOC_KIND_LABELS: Record<DocKind, string> = {
|
||||||
|
contract: "Договор / Заявка-договор + Поручение",
|
||||||
|
upd: "УПД",
|
||||||
|
etrn: "ЭТрН",
|
||||||
|
forwarder_receipt: "Экспедиторская расписка",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ChecklistRow {
|
||||||
|
kind: DocKind;
|
||||||
|
present: boolean;
|
||||||
|
signedByUserId: string | null;
|
||||||
|
signedAt: string | Date | null;
|
||||||
|
signedBy: { id: string; name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<"red" | "yellow" | "green", string> = {
|
||||||
|
red: "Не собрано",
|
||||||
|
yellow: "Собрано, не подписано",
|
||||||
|
green: "Собрано и подписано",
|
||||||
|
};
|
||||||
|
const STATUS_DOT: Record<"red" | "yellow" | "green", string> = {
|
||||||
|
red: "bg-danger",
|
||||||
|
yellow: "bg-warning",
|
||||||
|
green: "bg-success",
|
||||||
|
};
|
||||||
|
|
||||||
|
function computeStatus(rows: ChecklistRow[]): "red" | "yellow" | "green" {
|
||||||
|
if (rows.length === 0 || rows.some((r) => !r.present)) return "red";
|
||||||
|
if (rows.some((r) => !r.signedByUserId)) return "yellow";
|
||||||
|
return "green";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignChecklistSection({ orderId, rows: initialRows }: { orderId: string; rows: ChecklistRow[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [rows, setRows] = useState(initialRows);
|
||||||
|
const [loadingKind, setLoadingKind] = useState<DocKind | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const status = computeStatus(rows);
|
||||||
|
|
||||||
|
async function togglePresent(kind: DocKind, present: boolean) {
|
||||||
|
setLoadingKind(kind);
|
||||||
|
setRows((prev) => prev.map((r) => (r.kind === kind ? { ...r, present } : r)));
|
||||||
|
await fetch(`/api/orders/${orderId}/documents/${kind}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ present }),
|
||||||
|
});
|
||||||
|
setLoadingKind(null);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sign(kind: DocKind) {
|
||||||
|
setLoadingKind(kind);
|
||||||
|
setNotice(null);
|
||||||
|
const res = await fetch(`/api/orders/${orderId}/documents/${kind}/sign`, { method: "POST" });
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setLoadingKind(null);
|
||||||
|
if (data?.signed) {
|
||||||
|
setNotice(null);
|
||||||
|
} else if (data?.requested) {
|
||||||
|
setNotice("Нет прав на подпись этой суммы — запрос отправлен тем, кто может подписать.");
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<span className={`h-2.5 w-2.5 rounded-full ${STATUS_DOT[status]}`} />
|
||||||
|
<h2 className="text-sm font-semibold text-text-muted">Документы по заявке — {STATUS_LABEL[status]}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div key={row.kind} className="flex flex-wrap items-center gap-3 rounded-md border border-border px-3 py-2 text-sm">
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={row.present}
|
||||||
|
disabled={loadingKind === row.kind}
|
||||||
|
onChange={(e) => togglePresent(row.kind, e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{DOC_KIND_LABELS[row.kind]}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{row.signedBy ? (
|
||||||
|
<span className="flex items-center gap-2 text-xs text-text-muted">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element -- маленькая приватная иконка-факсимиле */}
|
||||||
|
<img
|
||||||
|
src={`/api/users/${row.signedBy.id}/facsimile`}
|
||||||
|
alt=""
|
||||||
|
className="h-6 w-12 rounded border border-border object-contain bg-surface"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Подписал(а) {row.signedBy.name}
|
||||||
|
{row.signedAt ? `, ${new Date(row.signedAt).toLocaleDateString("ru-RU")}` : ""}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => sign(row.kind)}
|
||||||
|
disabled={!row.present || loadingKind === row.kind}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
>
|
||||||
|
Подписать
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notice && <p className="mt-3 text-sm text-warning-soft-text">{notice}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getCurrentSession } from "@/lib/auth/session";
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
import { listOrders, computeMargin } from "@/lib/orders/service";
|
import { listOrders, computeMargin } from "@/lib/orders/service";
|
||||||
|
import { getOrderDocStatus } from "@/lib/documents/checklist";
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
new: "Новая",
|
new: "Новая",
|
||||||
@@ -16,6 +17,7 @@ const STATUS_BADGE: Record<string, string> = {
|
|||||||
closed: "badge-closed",
|
closed: "badge-closed",
|
||||||
};
|
};
|
||||||
const PAID_LABEL: Record<string, string> = { unpaid: "Не оплачен", partial: "Частично", paid: "Оплачен" };
|
const PAID_LABEL: Record<string, string> = { unpaid: "Не оплачен", partial: "Частично", paid: "Оплачен" };
|
||||||
|
const DOC_STATUS_DOT: Record<"red" | "yellow" | "green", string> = { red: "bg-danger", yellow: "bg-warning", green: "bg-success" };
|
||||||
|
|
||||||
export default async function OrdersPage() {
|
export default async function OrdersPage() {
|
||||||
const session = await getCurrentSession();
|
const session = await getCurrentSession();
|
||||||
@@ -37,12 +39,14 @@ export default async function OrdersPage() {
|
|||||||
<th className="px-4 py-3 font-medium">Менеджер</th>
|
<th className="px-4 py-3 font-medium">Менеджер</th>
|
||||||
<th className="px-4 py-3 font-medium">Статус</th>
|
<th className="px-4 py-3 font-medium">Статус</th>
|
||||||
<th className="px-4 py-3 font-medium">Оплата</th>
|
<th className="px-4 py-3 font-medium">Оплата</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Доки</th>
|
||||||
<th className="px-4 py-3 font-medium text-right">Маржа</th>
|
<th className="px-4 py-3 font-medium text-right">Маржа</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{orders.map((o) => {
|
{orders.map((o) => {
|
||||||
const margin = computeMargin(o);
|
const margin = computeMargin(o);
|
||||||
|
const docStatus = getOrderDocStatus(o.documentChecklist);
|
||||||
return (
|
return (
|
||||||
<tr key={o.id} className="border-b border-border last:border-0 hover:bg-surface-hover">
|
<tr key={o.id} className="border-b border-border last:border-0 hover:bg-surface-hover">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
@@ -59,13 +63,16 @@ export default async function OrdersPage() {
|
|||||||
<span className={`badge ${STATUS_BADGE[o.status]}`}>{STATUS_LABEL[o.status]}</span>
|
<span className={`badge ${STATUS_BADGE[o.status]}`}>{STATUS_LABEL[o.status]}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-text-muted">{PAID_LABEL[o.paidStatus]}</td>
|
<td className="px-4 py-3 text-text-muted">{PAID_LABEL[o.paidStatus]}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-block h-2.5 w-2.5 rounded-full ${DOC_STATUS_DOT[docStatus]}`} />
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-right font-medium">{margin.marginWithVat.toLocaleString("ru-RU")} ₽</td>
|
<td className="px-4 py-3 text-right font-medium">{margin.marginWithVat.toLocaleString("ru-RU")} ₽</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{orders.length === 0 && (
|
{orders.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-4 py-8 text-center text-text-faint">
|
<td colSpan={8} className="px-4 py-8 text-center text-text-faint">
|
||||||
Пока нет заявок. Создайте их из запроса.
|
Пока нет заявок. Создайте их из запроса.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export function FacsimileUploader({ userId, hasFacsimile }: { userId: string; hasFacsimile: boolean }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Кэш-бастер для <img> после перезаписи файла — меняется только после
|
||||||
|
// успешной загрузки, не на каждый рендер (Date.now() напрямую в JSX
|
||||||
|
// нарушает чистоту рендера).
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
|
||||||
|
async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("file", file);
|
||||||
|
const res = await fetch("/api/profile/facsimile", { method: "POST", body: form });
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? "Не удалось загрузить факсимиле");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVersion((v) => v + 1);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{hasFacsimile ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- маленькое приватное изображение по requireSession, next/image тут не даёт выгоды
|
||||||
|
<img src={`/api/users/${userId}/facsimile?v=${version}`} alt="Факсимиле" className="h-14 w-28 rounded-md border border-border object-contain bg-surface" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-14 w-28 items-center justify-center rounded-md border border-dashed border-border text-xs text-text-faint">
|
||||||
|
Нет подписи
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<button type="button" onClick={() => inputRef.current?.click()} disabled={loading} className="btn btn-ghost">
|
||||||
|
{loading ? "Загружаем…" : "Загрузить факсимиле"}
|
||||||
|
</button>
|
||||||
|
<input ref={inputRef} type="file" accept="image/png,image/jpeg,image/webp,image/gif" hidden onChange={handleChange} />
|
||||||
|
{error && <p className="mt-2 text-sm text-danger-soft-text">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getCurrentSession } from "@/lib/auth/session";
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
import { AvatarUploader } from "./avatar-uploader";
|
import { AvatarUploader } from "./avatar-uploader";
|
||||||
|
import { FacsimileUploader } from "./facsimile-uploader";
|
||||||
|
|
||||||
const ROLE_LABEL: Record<string, string> = { admin: "Администратор", manager: "Менеджер", accountant: "Бухгалтер" };
|
const ROLE_LABEL: Record<string, string> = { admin: "Администратор", manager: "Менеджер", accountant: "Бухгалтер" };
|
||||||
|
|
||||||
@@ -18,6 +19,16 @@ export default async function ProfilePage() {
|
|||||||
<AvatarUploader userId={user.id} name={user.name} hasAvatar={Boolean(user.avatarPath)} />
|
<AvatarUploader userId={user.id} name={user.name} hasAvatar={Boolean(user.avatarPath)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card mb-5 p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-text-muted">Факсимильная подпись</h2>
|
||||||
|
<FacsimileUploader userId={user.id} hasFacsimile={Boolean(user.facsimilePath)} />
|
||||||
|
{user.canSign && (
|
||||||
|
<p className="mt-3 text-xs text-text-muted">
|
||||||
|
Право подписи: {user.signLimitRub == null ? "без ограничения по сумме" : `до ${user.signLimitRub.toLocaleString("ru-RU")} ₽`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
<dl className="flex flex-col gap-2 text-sm">
|
<dl className="flex flex-col gap-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { getCurrentSession } from "@/lib/auth/session";
|
import { getCurrentSession } from "@/lib/auth/session";
|
||||||
import { listUsers } from "@/lib/users/service";
|
import { listUsers } from "@/lib/users/service";
|
||||||
import { UsersToolbar } from "./toolbar";
|
import { UsersToolbar } from "./toolbar";
|
||||||
|
import { SignRightsCell } from "./sign-rights-cell";
|
||||||
|
|
||||||
const ROLE_LABEL: Record<string, string> = {
|
const ROLE_LABEL: Record<string, string> = {
|
||||||
admin: "Администратор",
|
admin: "Администратор",
|
||||||
@@ -32,6 +33,7 @@ export default async function UsersPage() {
|
|||||||
<th className="px-4 py-3 font-medium">Email</th>
|
<th className="px-4 py-3 font-medium">Email</th>
|
||||||
<th className="px-4 py-3 font-medium">Роль</th>
|
<th className="px-4 py-3 font-medium">Роль</th>
|
||||||
<th className="px-4 py-3 font-medium">№ менеджера</th>
|
<th className="px-4 py-3 font-medium">№ менеджера</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Подпись</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -41,6 +43,9 @@ export default async function UsersPage() {
|
|||||||
<td className="px-4 py-3 text-text-muted">{u.email}</td>
|
<td className="px-4 py-3 text-text-muted">{u.email}</td>
|
||||||
<td className="px-4 py-3 text-text-muted">{ROLE_LABEL[u.role]}</td>
|
<td className="px-4 py-3 text-text-muted">{ROLE_LABEL[u.role]}</td>
|
||||||
<td className="px-4 py-3 text-text-muted">{u.managerNumber ?? "—"}</td>
|
<td className="px-4 py-3 text-text-muted">{u.managerNumber ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<SignRightsCell userId={u.id} canSign={u.canSign} signLimitRub={u.signLimitRub} />
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export function SignRightsCell({
|
||||||
|
userId,
|
||||||
|
canSign: initialCanSign,
|
||||||
|
signLimitRub: initialLimit,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
canSign: boolean;
|
||||||
|
signLimitRub: number | null;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [canSign, setCanSign] = useState(initialCanSign);
|
||||||
|
const [limit, setLimit] = useState(initialLimit == null ? "" : String(initialLimit));
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function patch(body: { canSign?: boolean; signLimitRub?: number | null }) {
|
||||||
|
setLoading(true);
|
||||||
|
await fetch(`/api/users/${userId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={canSign}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCanSign(e.target.checked);
|
||||||
|
void patch({ canSign: e.target.checked });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-text-muted">Может подписывать</span>
|
||||||
|
</label>
|
||||||
|
{canSign && (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1000}
|
||||||
|
placeholder="без лимита"
|
||||||
|
value={limit}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={(e) => setLimit(e.target.value)}
|
||||||
|
onBlur={() => patch({ signLimitRub: limit === "" ? null : Number(limit) })}
|
||||||
|
className="w-28 rounded-md border border-border bg-surface px-2 py-1 text-xs outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireRole } from "@/lib/auth/require";
|
||||||
|
import { getOrder } from "@/lib/orders/service";
|
||||||
|
import { canAccessOrder } from "@/lib/orders/visibility";
|
||||||
|
import { DOC_KINDS, setDocPresent, type DocKind } from "@/lib/documents/checklist";
|
||||||
|
|
||||||
|
const schema = z.object({ present: z.boolean() });
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string; kind: string }> }) {
|
||||||
|
const { session, response } = await requireRole(["admin", "manager", "accountant"]);
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const { id, kind } = await params;
|
||||||
|
if (!DOC_KINDS.includes(kind as DocKind)) {
|
||||||
|
return NextResponse.json({ error: "Неизвестный тип документа" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await getOrder(id);
|
||||||
|
if (!order) return NextResponse.json({ error: "Заявка не найдена" }, { status: 404 });
|
||||||
|
if (!canAccessOrder(order, session.user)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(await request.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Некорректные данные" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await setDocPresent(id, kind as DocKind, parsed.data.present);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireRole } from "@/lib/auth/require";
|
||||||
|
import { getOrder } from "@/lib/orders/service";
|
||||||
|
import { canAccessOrder } from "@/lib/orders/visibility";
|
||||||
|
import { DOC_KINDS, signDocument, type DocKind } from "@/lib/documents/checklist";
|
||||||
|
|
||||||
|
export async function POST(_request: Request, { params }: { params: Promise<{ id: string; kind: string }> }) {
|
||||||
|
const { session, response } = await requireRole(["admin", "manager", "accountant"]);
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const { id, kind } = await params;
|
||||||
|
if (!DOC_KINDS.includes(kind as DocKind)) {
|
||||||
|
return NextResponse.json({ error: "Неизвестный тип документа" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = await getOrder(id);
|
||||||
|
if (!order) return NextResponse.json({ error: "Заявка не найдена" }, { status: 404 });
|
||||||
|
if (!canAccessOrder(order, session.user)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
const result = await signDocument(id, kind as DocKind, session.user.id);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { saveFacsimile } from "@/lib/users/service";
|
||||||
|
|
||||||
|
const ALLOWED_EXT: Record<string, string> = {
|
||||||
|
"image/png": "png",
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/webp": "webp",
|
||||||
|
"image/gif": "gif",
|
||||||
|
};
|
||||||
|
const MAX_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const form = await request.formData().catch(() => null);
|
||||||
|
const file = form?.get("file");
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return NextResponse.json({ error: "Файл не найден" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (file.size > MAX_BYTES) {
|
||||||
|
return NextResponse.json({ error: "Файл слишком большой (максимум 5 МБ)" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const extension = ALLOWED_EXT[file.type];
|
||||||
|
if (!extension) {
|
||||||
|
return NextResponse.json({ error: "Поддерживаются только PNG, JPEG, WEBP, GIF" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = Buffer.from(await file.arrayBuffer());
|
||||||
|
await saveFacsimile(session.user.id, content, extension);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireSession } from "@/lib/auth/require";
|
||||||
|
import { getUser } from "@/lib/users/service";
|
||||||
|
|
||||||
|
const MIME_BY_EXT: Record<string, string> = {
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".gif": "image/gif",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (!session) return response;
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const user = await getUser(id);
|
||||||
|
if (!user?.facsimilePath || !fs.existsSync(user.facsimilePath)) {
|
||||||
|
return NextResponse.json({ error: "Нет факсимиле" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mime = MIME_BY_EXT[path.extname(user.facsimilePath).toLowerCase()] ?? "application/octet-stream";
|
||||||
|
const file = fs.readFileSync(user.facsimilePath);
|
||||||
|
return new NextResponse(new Uint8Array(file), {
|
||||||
|
headers: { "Content-Type": mime, "Cache-Control": "private, max-age=300" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { requireRole } from "@/lib/auth/require";
|
||||||
|
import { updateSignRights } from "@/lib/users/service";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
canSign: z.boolean().optional(),
|
||||||
|
signLimitRub: z.number().positive().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Пока только права подписи — общий PATCH профиля пользователя (имя/роль/пароль) не нужен, эти поля не редактируются через UI. */
|
||||||
|
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { session, response } = await requireRole(["admin"]);
|
||||||
|
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 updateSignRights(id, parsed.data);
|
||||||
|
if (!row) return NextResponse.json({ error: "Пользователь не найден" }, { status: 404 });
|
||||||
|
const { passwordHash: _passwordHash, ...safeRow } = row;
|
||||||
|
return NextResponse.json(safeRow);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TABLE `order_document_checklist` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`order_id` text NOT NULL,
|
||||||
|
`kind` text NOT NULL,
|
||||||
|
`present` integer DEFAULT false NOT NULL,
|
||||||
|
`attachment_id` text,
|
||||||
|
`signed_by_user_id` text,
|
||||||
|
`signed_at` integer,
|
||||||
|
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
|
||||||
|
FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`attachment_id`) REFERENCES `attachments`(`id`) ON UPDATE no action ON DELETE set null,
|
||||||
|
FOREIGN KEY (`signed_by_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `order_document_checklist_order_kind` ON `order_document_checklist` (`order_id`,`kind`);--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `facsimile_path` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `can_sign` integer DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `sign_limit_rub` real;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -8,6 +8,13 @@
|
|||||||
"when": 1788193891885,
|
"when": 1788193891885,
|
||||||
"tag": "0000_equal_elektra",
|
"tag": "0000_equal_elektra",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788197632163,
|
||||||
|
"tag": "0001_normal_captain_stacy",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,14 @@ export const users = sqliteTable("users", {
|
|||||||
role: text("role", { enum: ["admin", "manager", "accountant"] }).notNull(),
|
role: text("role", { enum: ["admin", "manager", "accountant"] }).notNull(),
|
||||||
managerNumber: integer("manager_number").unique(),
|
managerNumber: integer("manager_number").unique(),
|
||||||
avatarPath: text("avatar_path"),
|
avatarPath: text("avatar_path"),
|
||||||
|
// Право факсимильной подписи документов по заявке (см. orderDocumentChecklist)
|
||||||
|
// — отдельно от role, т.к. это лимит по сумме сделки, а не по должности.
|
||||||
|
// signLimitRub: null при canSign=true значит "без ограничения" (ген./зам.
|
||||||
|
// директор, глав. бух); конкретную сумму для менеджеров задаёт админ в
|
||||||
|
// Настройки → Пользователи, не хардкодится.
|
||||||
|
facsimilePath: text("facsimile_path"),
|
||||||
|
canSign: integer("can_sign", { mode: "boolean" }).notNull().default(false),
|
||||||
|
signLimitRub: real("sign_limit_rub"),
|
||||||
lastSeenAt: integer("last_seen_at", { mode: "timestamp_ms" }),
|
lastSeenAt: integer("last_seen_at", { mode: "timestamp_ms" }),
|
||||||
...timestamps,
|
...timestamps,
|
||||||
});
|
});
|
||||||
@@ -305,6 +313,32 @@ export const orderEvents = sqliteTable("order_events", {
|
|||||||
...timestamps,
|
...timestamps,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чек-лист минимального набора документов по заявке (Договор/Заявка-договор+Поручение,
|
||||||
|
* УПД, ЭТрН, Экспедиторская расписка) — не путать с documentTemplates (шаблоны для
|
||||||
|
* генерации) и с orderIncomeLines.docsSigned (это про закрывающие по конкретному
|
||||||
|
* счёту клиенту, отдельная сущность). "contract" — один пункт на весь пакет
|
||||||
|
* договорных документов, а не отдельно на каждый вариант шаблона.
|
||||||
|
* present=false у любого пункта -> статус заявки "red"; все present, но не все
|
||||||
|
* подписаны -> "yellow"; все present и подписаны -> "green" (см. lib/documents/checklist.ts).
|
||||||
|
*/
|
||||||
|
export const orderDocumentChecklist = sqliteTable(
|
||||||
|
"order_document_checklist",
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
orderId: text("order_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => orders.id, { onDelete: "cascade" }),
|
||||||
|
kind: text("kind", { enum: ["contract", "upd", "etrn", "forwarder_receipt"] }).notNull(),
|
||||||
|
present: integer("present", { mode: "boolean" }).notNull().default(false),
|
||||||
|
attachmentId: text("attachment_id").references(() => attachments.id, { onDelete: "set null" }),
|
||||||
|
signedByUserId: text("signed_by_user_id").references(() => users.id),
|
||||||
|
signedAt: integer("signed_at", { mode: "timestamp_ms" }),
|
||||||
|
...timestamps,
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex("order_document_checklist_order_kind").on(t.orderId, t.kind)],
|
||||||
|
);
|
||||||
|
|
||||||
/** Загруженные .docx-шаблоны компании для генерации документов (docxtemplater). Раздельные kind на "наша компания = Экспедитор" / "наша компания = Заказчик" — это буквально два разных документа (роль компании определяет, чьи реквизиты куда подставляются), не одна форма с параметром. */
|
/** Загруженные .docx-шаблоны компании для генерации документов (docxtemplater). Раздельные kind на "наша компания = Экспедитор" / "наша компания = Заказчик" — это буквально два разных документа (роль компании определяет, чьи реквизиты куда подставляются), не одна форма с параметром. */
|
||||||
export const documentTemplates = sqliteTable("document_templates", {
|
export const documentTemplates = sqliteTable("document_templates", {
|
||||||
id: id(),
|
id: id(),
|
||||||
@@ -480,6 +514,7 @@ export const usersRelations = relations(users, ({ many }) => ({
|
|||||||
orders: many(orders),
|
orders: many(orders),
|
||||||
conversationParticipants: many(conversationParticipants),
|
conversationParticipants: many(conversationParticipants),
|
||||||
chatMessages: many(chatMessages),
|
chatMessages: many(chatMessages),
|
||||||
|
signedDocuments: many(orderDocumentChecklist),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const pushSubscriptionsRelations = relations(pushSubscriptions, ({ one }) => ({
|
export const pushSubscriptionsRelations = relations(pushSubscriptions, ({ one }) => ({
|
||||||
@@ -528,6 +563,13 @@ export const ordersRelations = relations(orders, ({ one, many }) => ({
|
|||||||
trackingUpdates: many(trackingUpdates),
|
trackingUpdates: many(trackingUpdates),
|
||||||
attachments: many(attachments),
|
attachments: many(attachments),
|
||||||
events: many(orderEvents),
|
events: many(orderEvents),
|
||||||
|
documentChecklist: many(orderDocumentChecklist),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const orderDocumentChecklistRelations = relations(orderDocumentChecklist, ({ one }) => ({
|
||||||
|
order: one(orders, { fields: [orderDocumentChecklist.orderId], references: [orders.id] }),
|
||||||
|
attachment: one(attachments, { fields: [orderDocumentChecklist.attachmentId], references: [attachments.id] }),
|
||||||
|
signedBy: one(users, { fields: [orderDocumentChecklist.signedByUserId], references: [users.id] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const orderIncomeLinesRelations = relations(orderIncomeLines, ({ one }) => ({
|
export const orderIncomeLinesRelations = relations(orderIncomeLines, ({ one }) => ({
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db/client";
|
||||||
|
import { orderDocumentChecklist, orders, users } from "@/lib/db/schema";
|
||||||
|
import { sumWithVat, logSystemEvent } from "@/lib/orders/service";
|
||||||
|
import { publishEvent } from "@/lib/events/bus";
|
||||||
|
|
||||||
|
export type DocKind = "contract" | "upd" | "etrn" | "forwarder_receipt";
|
||||||
|
|
||||||
|
export const DOC_KINDS: DocKind[] = ["contract", "upd", "etrn", "forwarder_receipt"];
|
||||||
|
|
||||||
|
export const DOC_KIND_LABELS: Record<DocKind, string> = {
|
||||||
|
contract: "Договор / Заявка-договор + Поручение",
|
||||||
|
upd: "УПД",
|
||||||
|
etrn: "ЭТрН",
|
||||||
|
forwarder_receipt: "Экспедиторская расписка",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Idempotent — вставляет недостающие пункты чек-листа. Основной путь — eager-seed в convertInquiryToOrder, это подстраховка на случай, если набор обязательных доков когда-то менялся после создания заявки. */
|
||||||
|
export async function ensureChecklist(orderId: string): Promise<void> {
|
||||||
|
const existing = await db.query.orderDocumentChecklist.findMany({ where: eq(orderDocumentChecklist.orderId, orderId) });
|
||||||
|
const existingKinds = new Set(existing.map((r) => r.kind));
|
||||||
|
const missing = DOC_KINDS.filter((k) => !existingKinds.has(k));
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
await db.insert(orderDocumentChecklist).values(missing.map((kind) => ({ orderId, kind })));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocStatus = "red" | "yellow" | "green";
|
||||||
|
|
||||||
|
/** Красный — не собран хотя бы один документ; жёлтый — все собраны, но не все подписаны; зелёный — все собраны и подписаны. */
|
||||||
|
export function getOrderDocStatus(checklist: { present: boolean; signedByUserId: string | null }[]): DocStatus {
|
||||||
|
if (checklist.length === 0 || checklist.some((c) => !c.present)) return "red";
|
||||||
|
if (checklist.some((c) => !c.signedByUserId)) return "yellow";
|
||||||
|
return "green";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Для списка заявок — один запрос на все заявки разом вместо N+1. */
|
||||||
|
export async function getDocStatusByOrderId(orderIds: string[]): Promise<Map<string, DocStatus>> {
|
||||||
|
if (orderIds.length === 0) return new Map();
|
||||||
|
const rows = await db.query.orderDocumentChecklist.findMany({ where: inArray(orderDocumentChecklist.orderId, orderIds) });
|
||||||
|
const byOrder = new Map<string, { present: boolean; signedByUserId: string | null }[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const list = byOrder.get(row.orderId) ?? [];
|
||||||
|
list.push({ present: row.present, signedByUserId: row.signedByUserId });
|
||||||
|
byOrder.set(row.orderId, list);
|
||||||
|
}
|
||||||
|
const result = new Map<string, DocStatus>();
|
||||||
|
for (const id of orderIds) {
|
||||||
|
result.set(id, getOrderDocStatus(byOrder.get(id) ?? []));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDocPresent(orderId: string, kind: DocKind, present: boolean): Promise<void> {
|
||||||
|
await ensureChecklist(orderId);
|
||||||
|
await db
|
||||||
|
.update(orderDocumentChecklist)
|
||||||
|
.set({ present })
|
||||||
|
.where(and(eq(orderDocumentChecklist.orderId, orderId), eq(orderDocumentChecklist.kind, kind)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUserSign(user: { canSign: boolean; signLimitRub: number | null }, orderSum: number): boolean {
|
||||||
|
return user.canSign && (user.signLimitRub == null || user.signLimitRub >= orderSum);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrderWithSum(orderId: string) {
|
||||||
|
const order = await db.query.orders.findFirst({ where: eq(orders.id, orderId), with: { incomeLines: true } });
|
||||||
|
if (!order) return null;
|
||||||
|
return { order, sum: sumWithVat(order.incomeLines) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Все, кто вправе подписать заявку на данную сумму — получатели уведомления, когда инициатор сам подписать не может. */
|
||||||
|
async function listEligibleSigners(orderSum: number, excludeUserId?: string) {
|
||||||
|
const rows = await db.query.users.findMany({ where: eq(users.canSign, true) });
|
||||||
|
return rows.filter((u) => u.id !== excludeUserId && canUserSign(u, orderSum));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Возвращает {signed:true} если подписавший имел право; иначе документ НЕ подписывается — вместо этого уходит запрос всем, кто может подписать (push + системный комментарий на заявке), и возвращается {signed:false, requested:true}. */
|
||||||
|
export async function signDocument(
|
||||||
|
orderId: string,
|
||||||
|
kind: DocKind,
|
||||||
|
signerId: string,
|
||||||
|
): Promise<{ signed: boolean; requested?: boolean }> {
|
||||||
|
await ensureChecklist(orderId);
|
||||||
|
|
||||||
|
const [signer, loaded] = await Promise.all([
|
||||||
|
db.query.users.findFirst({ where: eq(users.id, signerId) }),
|
||||||
|
getOrderWithSum(orderId),
|
||||||
|
]);
|
||||||
|
if (!signer || !loaded) throw new Error("Заявка или пользователь не найдены");
|
||||||
|
|
||||||
|
if (canUserSign(signer, loaded.sum)) {
|
||||||
|
await db
|
||||||
|
.update(orderDocumentChecklist)
|
||||||
|
.set({ signedByUserId: signerId, signedAt: new Date() })
|
||||||
|
.where(and(eq(orderDocumentChecklist.orderId, orderId), eq(orderDocumentChecklist.kind, kind)));
|
||||||
|
await logSystemEvent(orderId, `${signer.name} подписал(а) «${DOC_KIND_LABELS[kind]}»`);
|
||||||
|
return { signed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
await requestSignature(orderId, kind, signerId);
|
||||||
|
return { signed: false, requested: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestSignature(orderId: string, kind: DocKind, requesterId: string): Promise<void> {
|
||||||
|
const [requester, loaded] = await Promise.all([
|
||||||
|
db.query.users.findFirst({ where: eq(users.id, requesterId) }),
|
||||||
|
getOrderWithSum(orderId),
|
||||||
|
]);
|
||||||
|
if (!requester || !loaded) return;
|
||||||
|
|
||||||
|
const eligible = await listEligibleSigners(loaded.sum, requesterId);
|
||||||
|
await logSystemEvent(
|
||||||
|
orderId,
|
||||||
|
`${requester.name} запросил(а) подпись «${DOC_KIND_LABELS[kind]}» — нет прав на сумму сделки ${loaded.sum.toLocaleString("ru-RU")} ₽`,
|
||||||
|
);
|
||||||
|
publishEvent({ type: "order.signature_requested", orderId, kind, requesterId, actorId: requesterId, signerIds: eligible.map((u) => u.id) });
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export type AppEvent =
|
|||||||
| { type: "order.created"; orderId: string; managerId: string }
|
| { type: "order.created"; orderId: string; managerId: string }
|
||||||
| { type: "tracking.updated"; orderId: string; actorId?: string }
|
| { type: "tracking.updated"; orderId: string; actorId?: string }
|
||||||
| { type: "order.event"; orderId: string; actorId?: string }
|
| { type: "order.event"; orderId: string; actorId?: string }
|
||||||
|
| { type: "order.signature_requested"; orderId: string; kind: string; requesterId: string; actorId: string; signerIds: string[] }
|
||||||
| { type: "email_draft.created"; draftId: string }
|
| { type: "email_draft.created"; draftId: string }
|
||||||
| { type: "chat.message"; conversationId: string; actorId: string }
|
| { type: "chat.message"; conversationId: string; actorId: string }
|
||||||
| { type: "chat.typing"; conversationId: string; userId: string; userName: string }
|
| { type: "chat.typing"; conversationId: string; userId: string; userName: string }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { eq, desc } from "drizzle-orm";
|
|||||||
import { db } from "@/lib/db/client";
|
import { db } from "@/lib/db/client";
|
||||||
import { inquiries, inquiryQuotes, inquiryCargoItems, orders } from "@/lib/db/schema";
|
import { inquiries, inquiryQuotes, inquiryCargoItems, orders } from "@/lib/db/schema";
|
||||||
import { formatInquiryNumber, formatOrderNumber, getManagerNumber, nextInquirySeq, nextOrderSeq } from "@/lib/orders/numbering";
|
import { formatInquiryNumber, formatOrderNumber, getManagerNumber, nextInquirySeq, nextOrderSeq } from "@/lib/orders/numbering";
|
||||||
|
import { ensureChecklist } from "@/lib/documents/checklist";
|
||||||
import { publishEvent } from "@/lib/events/bus";
|
import { publishEvent } from "@/lib/events/bus";
|
||||||
|
|
||||||
export interface CargoItemInput {
|
export interface CargoItemInput {
|
||||||
@@ -147,6 +148,7 @@ export async function convertInquiryToOrder(inquiryId: string) {
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
await db.update(inquiries).set({ status: "converted", convertedOrderId: order.id }).where(eq(inquiries.id, inquiryId));
|
await db.update(inquiries).set({ status: "converted", convertedOrderId: order.id }).where(eq(inquiries.id, inquiryId));
|
||||||
|
await ensureChecklist(order.id);
|
||||||
publishEvent({ type: "order.created", orderId: order.id, managerId: order.managerId });
|
publishEvent({ type: "order.created", orderId: order.id, managerId: order.managerId });
|
||||||
|
|
||||||
return order;
|
return order;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface OrderVisibility {
|
|||||||
/** Менеджер видит только свои заявки; админ и бухгалтер — все (нужно для финансов/отчётов). */
|
/** Менеджер видит только свои заявки; админ и бухгалтер — все (нужно для финансов/отчётов). */
|
||||||
export async function listOrders(visibility: OrderVisibility) {
|
export async function listOrders(visibility: OrderVisibility) {
|
||||||
const rows = await db.query.orders.findMany({
|
const rows = await db.query.orders.findMany({
|
||||||
with: { customer: true, manager: true, incomeLines: true, expenseLines: true },
|
with: { customer: true, manager: true, incomeLines: true, expenseLines: true, documentChecklist: true },
|
||||||
orderBy: desc(orders.createdAt),
|
orderBy: desc(orders.createdAt),
|
||||||
});
|
});
|
||||||
if (visibility.role === "manager") {
|
if (visibility.role === "manager") {
|
||||||
@@ -31,13 +31,19 @@ export async function getOrder(id: string) {
|
|||||||
trackingUpdates: { orderBy: desc(trackingUpdates.createdAt) },
|
trackingUpdates: { orderBy: desc(trackingUpdates.createdAt) },
|
||||||
attachments: true,
|
attachments: true,
|
||||||
events: { with: { author: true }, orderBy: desc(orderEvents.createdAt) },
|
events: { with: { author: true }, orderBy: desc(orderEvents.createdAt) },
|
||||||
|
documentChecklist: { with: { signedBy: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Сумма строк с учётом НДС — используется и в марже, и в 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);
|
||||||
|
}
|
||||||
|
|
||||||
export function computeMargin(order: { incomeLines: { sum: number; vatRate: number }[]; expenseLines: { sum: number; vatRate: number }[] }) {
|
export function computeMargin(order: { incomeLines: { sum: number; vatRate: number }[]; expenseLines: { sum: number; vatRate: number }[] }) {
|
||||||
const incomeTotal = order.incomeLines.reduce((acc, l) => acc + l.sum * (1 + l.vatRate / 100), 0);
|
const incomeTotal = sumWithVat(order.incomeLines);
|
||||||
const expenseTotal = order.expenseLines.reduce((acc, l) => acc + l.sum * (1 + l.vatRate / 100), 0);
|
const expenseTotal = sumWithVat(order.expenseLines);
|
||||||
const incomeNoVat = order.incomeLines.reduce((acc, l) => acc + l.sum, 0);
|
const incomeNoVat = order.incomeLines.reduce((acc, l) => acc + l.sum, 0);
|
||||||
const expenseNoVat = order.expenseLines.reduce((acc, l) => acc + l.sum, 0);
|
const expenseNoVat = order.expenseLines.reduce((acc, l) => acc + l.sum, 0);
|
||||||
return {
|
return {
|
||||||
@@ -51,7 +57,7 @@ export function computeMargin(order: { incomeLines: { sum: number; vatRate: numb
|
|||||||
const STATUS_LABEL: Record<string, string> = { new: "Новая", in_progress: "На исполнении", done: "Завершена", closed: "Закрыта" };
|
const STATUS_LABEL: Record<string, string> = { new: "Новая", in_progress: "На исполнении", done: "Завершена", closed: "Закрыта" };
|
||||||
const PAID_LABEL: Record<string, string> = { unpaid: "Не оплачен", partial: "Частично", paid: "Оплачен" };
|
const PAID_LABEL: Record<string, string> = { unpaid: "Не оплачен", partial: "Частично", paid: "Оплачен" };
|
||||||
|
|
||||||
async function logSystemEvent(orderId: string, body: string) {
|
export async function logSystemEvent(orderId: string, body: string) {
|
||||||
await db.insert(orderEvents).values({ orderId, kind: "system", body });
|
await db.insert(orderEvents).values({ orderId, kind: "system", body });
|
||||||
publishEvent({ type: "order.event", orderId });
|
publishEvent({ type: "order.event", orderId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { subscribeEvents, type AppEvent } from "@/lib/events/bus";
|
import { subscribeEvents, type AppEvent } from "@/lib/events/bus";
|
||||||
import { getConversationParticipantIds, getUnreadChatCountForUser } from "@/lib/chat/service";
|
import { getConversationParticipantIds, getUnreadChatCountForUser } from "@/lib/chat/service";
|
||||||
|
import { DOC_KIND_LABELS, type DocKind } from "@/lib/documents/checklist";
|
||||||
import { sendPushToAllUsers, sendPushToUser, type PushPayload } from "./service";
|
import { sendPushToAllUsers, sendPushToUser, type PushPayload } from "./service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,6 +77,21 @@ export function ensurePushNotifierStarted(): void {
|
|||||||
void handleChatMessage(event);
|
void handleChatMessage(event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Не через toPushPayload/sendPushToAllUsers: получатели — не "все кроме
|
||||||
|
// автора", а только те, у кого сумма сделки не превышает лимит подписи
|
||||||
|
// (уже посчитано в documents/checklist.ts, чтобы не дублировать эту
|
||||||
|
// логику здесь).
|
||||||
|
if (event.type === "order.signature_requested") {
|
||||||
|
const payload: PushPayload = {
|
||||||
|
title: `Нужна подпись: ${DOC_KIND_LABELS[event.kind as DocKind]}`,
|
||||||
|
body: "",
|
||||||
|
url: `/orders/${event.orderId}`,
|
||||||
|
};
|
||||||
|
for (const userId of event.signerIds) {
|
||||||
|
void sendPushToUser(userId, payload);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// task.reminder is per-assignee, not broadcast — everything else goes to everyone.
|
// task.reminder is per-assignee, not broadcast — everything else goes to everyone.
|
||||||
if (event.type === "task.reminder") {
|
if (event.type === "task.reminder") {
|
||||||
void sendPushToUser(event.assigneeId, { title: "Напоминание о задаче", body: event.title, url: "/tasks" });
|
void sendPushToUser(event.assigneeId, { title: "Напоминание о задаче", body: event.title, url: "/tasks" });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { hashPassword } from "@/lib/auth/password";
|
|||||||
|
|
||||||
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
|
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
|
||||||
const avatarsDir = path.join(dataDir, "avatars");
|
const avatarsDir = path.join(dataDir, "avatars");
|
||||||
|
const facsimilesDir = path.join(dataDir, "facsimiles");
|
||||||
|
|
||||||
export interface CreateUserInput {
|
export interface CreateUserInput {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -58,6 +59,26 @@ export async function saveAvatar(userId: string, content: Buffer, extension: str
|
|||||||
return storedPath;
|
return storedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Перезаписывает предыдущую факсимильную подпись — тот же принцип, что и saveAvatar. */
|
||||||
|
export async function saveFacsimile(userId: string, content: Buffer, extension: string) {
|
||||||
|
fs.mkdirSync(facsimilesDir, { recursive: true });
|
||||||
|
|
||||||
|
const existing = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
||||||
|
if (existing?.facsimilePath && fs.existsSync(existing.facsimilePath)) {
|
||||||
|
fs.unlinkSync(existing.facsimilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedPath = path.join(facsimilesDir, `${userId}.${extension}`);
|
||||||
|
fs.writeFileSync(storedPath, content);
|
||||||
|
await db.update(users).set({ facsimilePath: storedPath }).where(eq(users.id, userId));
|
||||||
|
return storedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSignRights(userId: string, input: { canSign?: boolean; signLimitRub?: number | null }) {
|
||||||
|
const [row] = await db.update(users).set(input).where(eq(users.id, userId)).returning();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
export async function touchLastSeen(userId: string) {
|
export async function touchLastSeen(userId: string) {
|
||||||
await db.update(users).set({ lastSeenAt: new Date() }).where(eq(users.id, userId));
|
await db.update(users).set({ lastSeenAt: new Date() }).where(eq(users.id, userId));
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user