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 { EventsSection } from "./events-section";
|
||||
import { DocumentsSection } from "./documents-section";
|
||||
import { SignChecklistSection } from "./sign-checklist-section";
|
||||
|
||||
export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getCurrentSession();
|
||||
@@ -97,6 +98,19 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
|
||||
/>
|
||||
</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">
|
||||
<DocumentsSection
|
||||
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 { getCurrentSession } from "@/lib/auth/session";
|
||||
import { listOrders, computeMargin } from "@/lib/orders/service";
|
||||
import { getOrderDocStatus } from "@/lib/documents/checklist";
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
new: "Новая",
|
||||
@@ -16,6 +17,7 @@ const STATUS_BADGE: Record<string, string> = {
|
||||
closed: "badge-closed",
|
||||
};
|
||||
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() {
|
||||
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 text-right">Маржа</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((o) => {
|
||||
const margin = computeMargin(o);
|
||||
const docStatus = getOrderDocStatus(o.documentChecklist);
|
||||
return (
|
||||
<tr key={o.id} className="border-b border-border last:border-0 hover:bg-surface-hover">
|
||||
<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>
|
||||
</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>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{orders.length === 0 && (
|
||||
<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>
|
||||
</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 { getCurrentSession } from "@/lib/auth/session";
|
||||
import { AvatarUploader } from "./avatar-uploader";
|
||||
import { FacsimileUploader } from "./facsimile-uploader";
|
||||
|
||||
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)} />
|
||||
</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">
|
||||
<dl className="flex flex-col gap-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
|
||||
import { getCurrentSession } from "@/lib/auth/session";
|
||||
import { listUsers } from "@/lib/users/service";
|
||||
import { UsersToolbar } from "./toolbar";
|
||||
import { SignRightsCell } from "./sign-rights-cell";
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
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">Роль</th>
|
||||
<th className="px-4 py-3 font-medium">№ менеджера</th>
|
||||
<th className="px-4 py-3 font-medium">Подпись</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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">{ROLE_LABEL[u.role]}</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>
|
||||
))}
|
||||
</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,
|
||||
"tag": "0000_equal_elektra",
|
||||
"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(),
|
||||
managerNumber: integer("manager_number").unique(),
|
||||
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" }),
|
||||
...timestamps,
|
||||
});
|
||||
@@ -305,6 +313,32 @@ export const orderEvents = sqliteTable("order_events", {
|
||||
...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 на "наша компания = Экспедитор" / "наша компания = Заказчик" — это буквально два разных документа (роль компании определяет, чьи реквизиты куда подставляются), не одна форма с параметром. */
|
||||
export const documentTemplates = sqliteTable("document_templates", {
|
||||
id: id(),
|
||||
@@ -480,6 +514,7 @@ export const usersRelations = relations(users, ({ many }) => ({
|
||||
orders: many(orders),
|
||||
conversationParticipants: many(conversationParticipants),
|
||||
chatMessages: many(chatMessages),
|
||||
signedDocuments: many(orderDocumentChecklist),
|
||||
}));
|
||||
|
||||
export const pushSubscriptionsRelations = relations(pushSubscriptions, ({ one }) => ({
|
||||
@@ -528,6 +563,13 @@ export const ordersRelations = relations(orders, ({ one, many }) => ({
|
||||
trackingUpdates: many(trackingUpdates),
|
||||
attachments: many(attachments),
|
||||
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 }) => ({
|
||||
|
||||
@@ -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: "tracking.updated"; 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: "chat.message"; conversationId: string; actorId: 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 { inquiries, inquiryQuotes, inquiryCargoItems, orders } 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 interface CargoItemInput {
|
||||
@@ -147,6 +148,7 @@ export async function convertInquiryToOrder(inquiryId: string) {
|
||||
.returning();
|
||||
|
||||
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 });
|
||||
|
||||
return order;
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 },
|
||||
with: { customer: true, manager: true, incomeLines: true, expenseLines: true, documentChecklist: true },
|
||||
orderBy: desc(orders.createdAt),
|
||||
});
|
||||
if (visibility.role === "manager") {
|
||||
@@ -31,13 +31,19 @@ export async function getOrder(id: string) {
|
||||
trackingUpdates: { orderBy: desc(trackingUpdates.createdAt) },
|
||||
attachments: true,
|
||||
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 }[] }) {
|
||||
const incomeTotal = order.incomeLines.reduce((acc, l) => acc + l.sum * (1 + l.vatRate / 100), 0);
|
||||
const expenseTotal = order.expenseLines.reduce((acc, l) => acc + l.sum * (1 + l.vatRate / 100), 0);
|
||||
const incomeTotal = sumWithVat(order.incomeLines);
|
||||
const expenseTotal = sumWithVat(order.expenseLines);
|
||||
const incomeNoVat = order.incomeLines.reduce((acc, l) => acc + l.sum, 0);
|
||||
const expenseNoVat = order.expenseLines.reduce((acc, l) => acc + l.sum, 0);
|
||||
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 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 });
|
||||
publishEvent({ type: "order.event", orderId });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { subscribeEvents, type AppEvent } from "@/lib/events/bus";
|
||||
import { getConversationParticipantIds, getUnreadChatCountForUser } from "@/lib/chat/service";
|
||||
import { DOC_KIND_LABELS, type DocKind } from "@/lib/documents/checklist";
|
||||
import { sendPushToAllUsers, sendPushToUser, type PushPayload } from "./service";
|
||||
|
||||
/**
|
||||
@@ -76,6 +77,21 @@ export function ensurePushNotifierStarted(): void {
|
||||
void handleChatMessage(event);
|
||||
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.
|
||||
if (event.type === "task.reminder") {
|
||||
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 avatarsDir = path.join(dataDir, "avatars");
|
||||
const facsimilesDir = path.join(dataDir, "facsimiles");
|
||||
|
||||
export interface CreateUserInput {
|
||||
email: string;
|
||||
@@ -58,6 +59,26 @@ export async function saveAvatar(userId: string, content: Buffer, extension: str
|
||||
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) {
|
||||
await db.update(users).set({ lastSeenAt: new Date() }).where(eq(users.id, userId));
|
||||
}
|
||||
Reference in new issue
Block a user