procurement: wire processing workflow UI

This commit is contained in:
lizerk 2026-05-22 13:57:37 +03:00
parent fce272d352
commit 21e5cd2685
5 changed files with 386 additions and 41 deletions

View File

@ -43,6 +43,7 @@ import { Modal } from "@/components/ui";
const statusMeta: Record<string, { label: string; cls: string }> = {
draft: { label: "Черновик", cls: "app-badge" },
waiting: { label: "Ожидает", cls: "app-feedback-warning" },
pending: { label: "На согласовании", cls: "app-feedback-warning" },
approved: { label: "Одобрено", cls: "app-feedback-success" },
in_progress: { label: "В работе", cls: "app-selected" },
@ -147,7 +148,7 @@ function ProcurementRequestActionButtons({
const status = String(request.status || "").toLowerCase();
const isDraft = status === "draft";
const isPending = status === "pending";
const isApproved = status === "approved";
const canStartWork = (status === "approved" || status === "waiting") && !request.executor;
const isInProgress = status === "in_progress";
const canApproveThis = Boolean(request.can_current_user_approve);
@ -207,7 +208,7 @@ function ProcurementRequestActionButtons({
</button>
</>
) : null}
{isApproved && !request.executor ? (
{canStartWork ? (
<button
type="button"
onClick={() => onStart(request.id)}
@ -280,6 +281,8 @@ export default function ProcurementPage() {
handleApprove,
handleCancel,
handleComplete,
handleMarkAllReceived,
handleUpdateItem,
handleDelete,
handleLoadMore,
handleReject,
@ -623,14 +626,12 @@ export default function ProcurementPage() {
const urg = urgencyMeta[req.urgency] ?? urgencyMeta.medium;
const isAuthor = Boolean(resolveUserId(req.requestor) && user?.id && resolveUserId(req.requestor) === user.id);
const isExecutor = Boolean(resolveUserId(req.executor) && user?.id && resolveUserId(req.executor) === user.id);
const isDraft = st === "draft";
const isApproved = st === "approved";
const isInProgress = st === "in_progress";
const isEditableStatus = (st === "draft" || st === "waiting") && !req.executor;
const detail = detailsCache[req.id];
const resolvedDetail = detail || req;
const canApproveThis = Boolean((resolvedDetail.can_current_user_approve ?? req.can_current_user_approve));
const canEditThis = Boolean(isDraft && isAuthor);
const canDeleteThis = Boolean(isDraft && (isAuthor || canManage));
const canEditThis = Boolean(isEditableStatus && isAuthor);
const canDeleteThis = Boolean(isEditableStatus && (isAuthor || canManage));
const canCancelThis = Boolean(isAuthor && !isFinal(st) && st !== "draft");
const hasSecondaryActions = canEditThis || canDeleteThis || canCancelThis;
const expanded = expandedIds.has(req.id);
@ -674,7 +675,7 @@ export default function ProcurementPage() {
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 shrink-0 rounded-full ${st === "completed" ? "bg-teal-500" : st === "approved" ? "bg-emerald-500" : st === "pending" ? "bg-amber-500" : st === "in_progress" ? "bg-sky-500" : st === "rejected" ? "bg-rose-500" : "bg-slate-400"}`} />
<span className={`h-2 w-2 shrink-0 rounded-full ${st === "completed" ? "bg-teal-500" : st === "approved" ? "bg-emerald-500" : st === "pending" || st === "waiting" ? "bg-amber-500" : st === "in_progress" ? "bg-sky-500" : st === "rejected" ? "bg-rose-500" : "bg-slate-400"}`} />
<button
type="button"
onClick={() => void openDetailModal(req.id)}
@ -685,7 +686,10 @@ export default function ProcurementPage() {
</button>
</div>
<div className="app-text-muted mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span className="font-medium text-[var(--foreground)]">{getDeptName(req)}</span>
<span>Отдел: <span className="font-medium text-[var(--foreground)]">{getDeptName(req)}</span></span>
{req.processing_department_name ? (
<span>Отдел-исполнитель: <span className="font-medium text-[var(--foreground)]">{req.processing_department_name}</span></span>
) : null}
<span>{fmt(req.created_at)}</span>
{getRequestAmount(req) && <span className="font-medium text-[var(--foreground)]">{money(getRequestAmount(req))}</span>}
</div>
@ -784,6 +788,11 @@ export default function ProcurementPage() {
</div>
{(itemsCount > 0 || approvalsCount > 0) && (
<div className="col-span-2 flex flex-wrap items-center gap-2 pt-0.5">
{resolvedDetail.fulfillment_status_display ? (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
{resolvedDetail.fulfillment_status_display}
</span>
) : null}
{itemsCount > 0 && (
<span className="app-badge inline-flex items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium">
<Package size={11} /> {itemsCount} поз.
@ -872,6 +881,10 @@ export default function ProcurementPage() {
currentUserId={user?.id}
request={resolvedDetail}
displayUserName={displayUserName}
canProcessItems={["waiting", "in_progress"].includes(st)}
busyKey={busyKey}
onUpdateItem={handleUpdateItem}
onMarkAllReceived={handleMarkAllReceived}
footer={(
<ProcurementRequestActionButtons
request={req}
@ -943,11 +956,19 @@ export default function ProcurementPage() {
</span>
</div>
<div className="app-text-muted mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span className="font-medium text-[var(--foreground)]">{getDeptName(selectedRequest)}</span>
<span>Отдел: <span className="font-medium text-[var(--foreground)]">{getDeptName(selectedRequest)}</span></span>
{selectedRequest.processing_department_name ? (
<span>Отдел-исполнитель: <span className="font-medium text-[var(--foreground)]">{selectedRequest.processing_department_name}</span></span>
) : null}
<span>{fmt(selectedRequest.created_at)}</span>
{getRequestAmount(selectedRequest) ? (
<span className="font-medium text-[var(--foreground)]">{money(getRequestAmount(selectedRequest))}</span>
) : null}
{selectedRequest.fulfillment_status_display ? (
<span className="app-badge rounded-full px-2 py-0.5 text-[11px] font-medium">
{selectedRequest.fulfillment_status_display}
</span>
) : null}
</div>
<div className="app-text-muted mt-2 grid grid-cols-1 gap-x-3 gap-y-1 text-xs sm:grid-cols-2">
<div className="min-w-0">
@ -978,6 +999,10 @@ export default function ProcurementPage() {
currentUserId={user?.id}
request={selectedRequest}
displayUserName={displayUserName}
canProcessItems={["waiting", "in_progress"].includes(String(selectedRequest.status || "").toLowerCase())}
busyKey={busyKey}
onUpdateItem={handleUpdateItem}
onMarkAllReceived={handleMarkAllReceived}
footer={(
<ProcurementRequestActionButtons
request={selectedRequest}
@ -1136,7 +1161,9 @@ export default function ProcurementPage() {
<div className="flex flex-wrap items-center justify-end gap-2">
<button type="button" onClick={closeModal} className="app-action-secondary rounded-lg px-3 py-2 text-sm font-medium">Отмена</button>
<button type="button" onClick={handleSave} disabled={busyKey === "save"} className="app-action-primary rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-60">
{modalMode === "create" ? "Создать черновик" : "Сохранить"}
{modalMode === "create"
? form.requireApproval ? "Создать черновик" : "Создать и направить в отдел"
: "Сохранить"}
</button>
</div>
}>
@ -1167,6 +1194,25 @@ export default function ProcurementPage() {
/>
</div>
<label className="app-surface-muted flex items-start gap-3 rounded-xl px-3 py-3 text-sm">
<input
type="checkbox"
checked={form.requireApproval}
onChange={(e) => setForm((f) => ({
...f,
requireApproval: e.target.checked,
processing_department: e.target.checked ? null : f.processing_department,
}))}
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-sky-600"
/>
<span>
<span className="block font-medium text-[var(--foreground)]">Требовать согласование</span>
<span className="app-text-muted mt-0.5 block text-xs">
Если выключено, заявка сразу попадет в очередь выбранного отдела.
</span>
</span>
</label>
{/* Отдел + Срочность */}
<div className="grid grid-cols-2 gap-3">
<SearchableSelectSingle
@ -1191,6 +1237,16 @@ export default function ProcurementPage() {
</div>
</div>
{!form.requireApproval ? (
<SearchableSelectSingle
label="Отдел-исполнитель *"
placeholder="Выберите отдел, который обработает заявку..."
items={departments.map((d) => ({ id: d.id, name: d.name }))}
selectedId={form.processing_department}
onSelect={(id) => setForm((f) => ({ ...f, processing_department: id }))}
/>
) : null}
{/* ── Items ── */}
<div>
<div className="mb-2 flex items-center justify-between">
@ -1254,6 +1310,30 @@ export default function ProcurementPage() {
placeholder="Информация о поставщике (необязательно)"
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
<textarea
value={it.linksText}
onChange={(e) => updateItemRow(idx, { linksText: e.target.value })}
placeholder="Ссылки, каждая с новой строки"
rows={2}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
<div className="grid grid-cols-2 gap-2">
<input
type="date"
value={it.expected_delivery_date}
onChange={(e) => updateItemRow(idx, { expected_delivery_date: e.target.value })}
className="app-input rounded-lg px-3 py-2 text-sm"
aria-label="Ожидаемая дата поступления"
/>
<input
type="number"
step="0.01"
value={it.actual_unit_price}
onChange={(e) => updateItemRow(idx, { actual_unit_price: e.target.value })}
placeholder="Фактическая цена/ед."
className="app-input rounded-lg px-3 py-2 text-sm"
/>
</div>
</div>
</div>
))}

View File

@ -1,11 +1,12 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Check, CircleDot, X } from "lucide-react";
import { Check, CheckCircle2, CircleDot, ExternalLink, Save, X } from "lucide-react";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
import { formatDate, formatMoney, userProfileLink } from "@/lib/shared";
import type { ProcurementRequest, User } from "@/types/api";
import type { ProcurementItem, ProcurementItemExecutionStatus, ProcurementRequest, User } from "@/types/api";
interface ProcurementRequestDetailContentProps {
currentUserId?: number | null;
@ -15,9 +16,30 @@ interface ProcurementRequestDetailContentProps {
fallbackName?: string | null,
fallbackEmail?: string | null,
) => string;
canProcessItems?: boolean;
busyKey?: string | null;
onUpdateItem?: (requestId: number, itemId: number, patch: Record<string, unknown>) => void | Promise<unknown>;
onMarkAllReceived?: (requestId: number) => void | Promise<unknown>;
footer?: React.ReactNode;
}
type ItemProcessingDraft = {
execution_status: ProcurementItemExecutionStatus;
expected_delivery_date: string;
actual_unit_price: string;
executor_comment: string;
linksText: string;
};
const executionStatusOptions: { value: ProcurementItemExecutionStatus; label: string }[] = [
{ value: "pending", label: "Не выполнено" },
{ value: "ordered", label: "Заказано" },
{ value: "rejected", label: "Отказано" },
{ value: "received", label: "Получено" },
{ value: "completed_with_issue", label: "Выполнено с замечанием" },
{ value: "edited", label: "Отредактировано" },
];
const approvalIconByStatus = (status?: string) => {
const normalized = String(status || "").toLowerCase();
if (normalized === "approved") {
@ -37,12 +59,183 @@ const initialsFromName = (name: string) =>
.map((part) => part[0]?.toUpperCase() || "")
.join("") || "?";
const normalizeItemDraft = (item: ProcurementItem): ItemProcessingDraft => ({
execution_status: item.execution_status || "pending",
expected_delivery_date: item.expected_delivery_date || "",
actual_unit_price: item.actual_unit_price ? String(item.actual_unit_price) : "",
executor_comment: item.executor_comment || "",
linksText: Array.isArray(item.links) ? item.links.join("\n") : "",
});
const parseLinks = (value: string): string[] => (
value
.split(/[\n,]+/)
.map((link) => link.trim())
.filter(Boolean)
);
const linkHref = (link: string) => (/^https?:\/\//i.test(link) ? link : `https://${link}`);
interface ProcurementItemCardProps {
item: ProcurementItem;
requestId: number;
canEditItemProcessing: boolean;
busyKey?: string | null;
onUpdateItem?: (requestId: number, itemId: number, patch: Record<string, unknown>) => void | Promise<unknown>;
}
function ProcurementItemCard({
item,
requestId,
canEditItemProcessing,
busyKey,
onUpdateItem,
}: ProcurementItemCardProps) {
const [draft, setDraft] = useState<ItemProcessingDraft>(() => normalizeItemDraft(item));
const links = Array.isArray(item.links) ? item.links.filter(Boolean) : [];
const updateDraft = (patch: Partial<ItemProcessingDraft>) => {
setDraft((previous) => ({ ...previous, ...patch }));
};
const saveItemProcessing = () => {
return onUpdateItem?.(requestId, item.id, {
execution_status: draft.execution_status,
expected_delivery_date: draft.expected_delivery_date || null,
actual_unit_price: draft.actual_unit_price || null,
executor_comment: draft.executor_comment,
links: parseLinks(draft.linksText),
});
};
return (
<div className="app-surface-muted rounded-lg px-3 py-3 text-xs">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="app-text-wrap font-medium text-[var(--foreground)]">{item.name}</p>
{item.description ? (
<p className="app-text-wrap mt-1 text-[var(--muted-foreground)]">{item.description}</p>
) : null}
{item.supplier_info ? (
<p className="app-text-wrap mt-1 text-[var(--muted-foreground)]">
Поставщик: {item.supplier_info}
</p>
) : null}
{links.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{links.map((link, linkIndex) => (
<a
key={`${item.id}-${linkIndex}`}
href={linkHref(link)}
target="_blank"
rel="noreferrer"
className="app-badge inline-flex max-w-full items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium hover:bg-[var(--surface-tertiary)]"
>
<ExternalLink size={11} />
<span className="truncate">{link}</span>
</a>
))}
</div>
) : null}
</div>
<div className="text-right">
<p className="font-medium text-[var(--foreground)]">{formatMoney(item.total_price)}</p>
<p className="app-text-muted mt-1">
{item.quantity} {item.unit}
</p>
</div>
</div>
<div className="app-text-muted mt-2 flex flex-wrap items-center gap-x-3 gap-y-1">
<span>Цена/ед.: {formatMoney(item.estimated_unit_price)}</span>
{item.actual_unit_price ? <span>Факт/ед.: {formatMoney(item.actual_unit_price)}</span> : null}
<span>Статус: {item.execution_status_display || "Не выполнено"}</span>
{item.expected_delivery_date ? <span>Ожидается: {formatDate(item.expected_delivery_date)}</span> : null}
</div>
{item.executor_comment ? (
<p className="app-text-wrap app-text-muted mt-2">
Комментарий исполнителя: {item.executor_comment}
</p>
) : null}
{canEditItemProcessing ? (
<div className="mt-3 grid gap-2 rounded-lg border border-[var(--border)] bg-[var(--surface-primary)] p-3 sm:grid-cols-2">
<div>
<label className="app-text-muted mb-1 block text-[11px] font-medium">Статус позиции</label>
<select
value={draft.execution_status}
onChange={(event) => updateDraft({ execution_status: event.target.value as ProcurementItemExecutionStatus })}
className="app-select w-full rounded-lg px-3 py-2 text-xs"
>
{executionStatusOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div>
<label className="app-text-muted mb-1 block text-[11px] font-medium">Ожидаемая дата</label>
<input
type="date"
value={draft.expected_delivery_date}
onChange={(event) => updateDraft({ expected_delivery_date: event.target.value })}
className="app-input w-full rounded-lg px-3 py-2 text-xs"
/>
</div>
<div>
<label className="app-text-muted mb-1 block text-[11px] font-medium">Фактическая цена/ед.</label>
<input
type="number"
step="0.01"
value={draft.actual_unit_price}
onChange={(event) => updateDraft({ actual_unit_price: event.target.value })}
className="app-input w-full rounded-lg px-3 py-2 text-xs"
/>
</div>
<div>
<label className="app-text-muted mb-1 block text-[11px] font-medium">Ссылки</label>
<textarea
value={draft.linksText}
onChange={(event) => updateDraft({ linksText: event.target.value })}
rows={2}
className="app-input app-text-wrap min-h-16 w-full rounded-lg px-3 py-2 text-xs"
/>
</div>
<div className="sm:col-span-2">
<label className="app-text-muted mb-1 block text-[11px] font-medium">Комментарий исполнителя</label>
<textarea
value={draft.executor_comment}
onChange={(event) => updateDraft({ executor_comment: event.target.value })}
rows={2}
className="app-input app-text-wrap min-h-16 w-full rounded-lg px-3 py-2 text-xs"
/>
</div>
<div className="sm:col-span-2">
<button
type="button"
onClick={() => void saveItemProcessing()}
disabled={busyKey === `item-${item.id}`}
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-60"
>
<Save size={13} /> Сохранить позицию
</button>
</div>
</div>
) : null}
</div>
);
}
export function ProcurementRequestDetailContent({
currentUserId,
request,
displayUserName,
canProcessItems = false,
busyKey,
onUpdateItem,
onMarkAllReceived,
footer,
}: ProcurementRequestDetailContentProps) {
const canEditItemProcessing = Boolean(canProcessItems && onUpdateItem);
return (
<div className="space-y-3">
<div className="app-surface rounded-xl p-4">
@ -73,33 +266,36 @@ export function ProcurementRequestDetailContent({
{request.items && request.items.length > 0 ? (
<div className="app-surface rounded-xl p-4">
<p className="mb-3 text-sm font-semibold text-[var(--foreground)]">Позиции</p>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-[var(--foreground)]">Позиции</p>
{canEditItemProcessing && onMarkAllReceived ? (
<button
type="button"
onClick={() => onMarkAllReceived(request.id)}
disabled={busyKey === `mark-all-${request.id}`}
className="app-action-primary inline-flex items-center gap-1 rounded-lg px-3 py-2 text-xs font-medium disabled:opacity-60"
>
<CheckCircle2 size={14} /> Отметить все полученными
</button>
) : null}
</div>
<div className="space-y-2">
{request.items.map((item, index) => (
<div key={index} className="app-surface-muted rounded-lg px-3 py-3 text-xs">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="app-text-wrap font-medium text-[var(--foreground)]">{item.name}</p>
{item.description ? (
<p className="app-text-wrap mt-1 text-[var(--muted-foreground)]">{item.description}</p>
) : null}
{item.supplier_info ? (
<p className="app-text-wrap mt-1 text-[var(--muted-foreground)]">
Поставщик: {item.supplier_info}
</p>
) : null}
</div>
<div className="text-right">
<p className="font-medium text-[var(--foreground)]">{formatMoney(item.total_price)}</p>
<p className="app-text-muted mt-1">
{item.quantity} {item.unit}
</p>
</div>
</div>
<div className="app-text-muted mt-2 flex flex-wrap items-center gap-x-3 gap-y-1">
<span>Цена/ед.: {formatMoney(item.estimated_unit_price)}</span>
</div>
</div>
{request.items.map((item) => (
<ProcurementItemCard
key={[
item.id,
item.execution_status || "",
item.expected_delivery_date || "",
item.actual_unit_price || "",
item.executor_comment || "",
Array.isArray(item.links) ? item.links.join("|") : "",
].join(":")}
item={item}
requestId={request.id}
canEditItemProcessing={canEditItemProcessing}
busyKey={busyKey}
onUpdateItem={onUpdateItem}
/>
))}
</div>
</div>

View File

@ -19,6 +19,9 @@ type ItemDraft = {
unit: string;
estimated_unit_price: string;
supplier_info: string;
linksText: string;
expected_delivery_date: string;
actual_unit_price: string;
};
const emptyItem: ItemDraft = {
@ -28,12 +31,17 @@ const emptyItem: ItemDraft = {
unit: "шт",
estimated_unit_price: "",
supplier_info: "",
linksText: "",
expected_delivery_date: "",
actual_unit_price: "",
};
type FormState = {
title: string;
description: string;
department: number | null;
requireApproval: boolean;
processing_department: number | null;
urgency: UrgencyLevel;
items: ItemDraft[];
};
@ -42,6 +50,8 @@ const emptyForm: FormState = {
title: "",
description: "",
department: null,
requireApproval: true,
processing_department: null,
urgency: "medium",
items: [{ ...emptyItem }],
};
@ -119,6 +129,13 @@ const getReadableError = (error: unknown, fallback: string): string => {
return raw;
};
const parseLinks = (value: string): string[] => (
value
.split(/[\n,]+/)
.map((link) => link.trim())
.filter(Boolean)
);
export function useProcurementPage(user: User | null) {
const canManage = canManageRequests(user);
const canSupplierManage = canManageSupplier(user);
@ -361,6 +378,8 @@ export function useProcurementPage(user: User | null) {
title: detail.title || "",
description: detail.description || "",
department: detail.department ?? null,
requireApproval: !detail.processing_department,
processing_department: detail.processing_department ?? null,
urgency: detail.urgency || "medium",
items: detail.items && detail.items.length > 0
? detail.items.map((item) => ({
@ -370,6 +389,9 @@ export function useProcurementPage(user: User | null) {
unit: item.unit || "шт",
estimated_unit_price: String(item.estimated_unit_price || ""),
supplier_info: item.supplier_info || "",
linksText: Array.isArray(item.links) ? item.links.join("\n") : "",
expected_delivery_date: item.expected_delivery_date || "",
actual_unit_price: item.actual_unit_price ? String(item.actual_unit_price) : "",
}))
: [{ ...emptyItem }],
});
@ -427,6 +449,10 @@ export function useProcurementPage(user: User | null) {
setActionError("Выберите отдел.");
return;
}
if (!form.requireApproval && !form.processing_department) {
setActionError("Выберите отдел-исполнитель.");
return;
}
const validItems = form.items.filter((item) => item.name.trim());
if (validItems.length === 0) {
@ -449,6 +475,7 @@ export function useProcurementPage(user: User | null) {
title: form.title,
description: form.description,
department: form.department,
processing_department: form.requireApproval ? null : form.processing_department,
urgency: form.urgency,
items: validItems.map((item) => ({
name: item.name,
@ -457,18 +484,22 @@ export function useProcurementPage(user: User | null) {
unit: item.unit || "шт",
estimated_unit_price: item.estimated_unit_price,
supplier_info: item.supplier_info || undefined,
links: parseLinks(item.linksText),
expected_delivery_date: item.expected_delivery_date || undefined,
actual_unit_price: item.actual_unit_price || undefined,
})),
};
if (modalMode === "create") {
await apiClient.createProcurementRequest(payload);
setActionSuccess("Заявка создана (черновик).");
setActionSuccess(form.requireApproval ? "Заявка создана (черновик)." : "Заявка направлена в отдел.");
setCreateOpen(false);
} else if (editingId) {
await apiClient.updateProcurementRequest(editingId, {
title: payload.title,
description: payload.description,
urgency: payload.urgency,
processing_department: payload.processing_department,
});
setActionSuccess("Заявка обновлена.");
setEditingId(null);
@ -654,6 +685,29 @@ export function useProcurementPage(user: User | null) {
"Заявка завершена.",
), [doAction]);
const handleMarkAllReceived = useCallback((id: number) => doAction(
`mark-all-${id}`,
() => apiClient.markAllReceivedProcurementRequest(id),
id,
"Все позиции отмечены полученными.",
), [doAction]);
const handleUpdateItem = useCallback(async (requestId: number, itemId: number, patch: Record<string, unknown>) => {
try {
setBusyKey(`item-${itemId}`);
setActionError(null);
await apiClient.updateProcurementItem(itemId, patch);
setActionSuccess("Позиция обновлена.");
await refreshOne(requestId);
return true;
} catch (updateError) {
setActionError(getReadableError(updateError, "Не удалось обновить позицию"));
return false;
} finally {
setBusyKey(null);
}
}, [refreshOne]);
const handleCancel = useCallback((id: number, reason = "") => {
return doAction(`cancel-${id}`, () => apiClient.cancelProcurementRequest(id, reason), id, "Заявка отменена.");
}, [doAction]);
@ -755,6 +809,8 @@ export function useProcurementPage(user: User | null) {
handleReject,
handleStart,
handleComplete,
handleMarkAllReceived,
handleUpdateItem,
handleCancel,
handleDelete,
handleAddComment,

View File

@ -15,6 +15,7 @@ export function createProcurementApi(request: RequestFn) {
request(`/api/v1/procurement/requests/${id}/reject/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ comment: comment || '' }) }),
startWorkProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/start_work/`, { method: 'POST' }),
completeProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/complete/`, { method: 'POST' }),
markAllReceivedProcurementRequest: (id: number) => request(`/api/v1/procurement/requests/${id}/mark_all_received/`, { method: 'POST' }),
cancelProcurementRequest: (id: number, reason?: string) =>
request(`/api/v1/procurement/requests/${id}/cancel/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: reason || '' }) }),
getProcurementComments: (requestId: number) => request(`/api/v1/procurement/requests/${requestId}/comments/`),

View File

@ -481,7 +481,9 @@ export interface MaintenanceRecord {
}
// Procurement Request types
export type ProcurementStatus = 'draft' | 'pending' | 'approved' | 'in_progress' | 'completed' | 'rejected' | 'cancelled';
export type ProcurementStatus = 'draft' | 'waiting' | 'pending' | 'approved' | 'in_progress' | 'completed' | 'rejected' | 'cancelled';
export type ProcurementFulfillmentStatus = 'pending' | 'partially_ordered' | 'ordered' | 'partially_received' | 'completed' | 'issues';
export type ProcurementItemExecutionStatus = 'pending' | 'ordered' | 'rejected' | 'received' | 'completed_with_issue' | 'edited';
export type UrgencyLevel = 'low' | 'medium' | 'high' | 'critical';
export type ApprovalStatus = 'pending' | 'approved' | 'rejected';
@ -495,6 +497,12 @@ export interface ProcurementItem {
estimated_unit_price: string | number;
total_price?: string | number;
supplier_info?: string;
links?: string[];
expected_delivery_date?: string | null;
actual_unit_price?: string | number | null;
execution_status?: ProcurementItemExecutionStatus;
execution_status_display?: string;
executor_comment?: string;
equipment?: number | null;
}
@ -519,6 +527,8 @@ export interface ProcurementRequest {
department: number;
department_name?: string;
department_details?: Department;
processing_department?: number | null;
processing_department_name?: string | null;
requestor: User | number;
requestor_name?: string;
requestor_email?: string;
@ -528,6 +538,8 @@ export interface ProcurementRequest {
status_display?: string;
urgency: UrgencyLevel;
urgency_display?: string;
fulfillment_status?: ProcurementFulfillmentStatus;
fulfillment_status_display?: string;
items?: ProcurementItem[];
approvals?: ProcurementApproval[];
required_approval_priorities?: number[];