procurement: wire processing workflow UI
This commit is contained in:
parent
fce272d352
commit
21e5cd2685
@ -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>
|
||||
))}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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/`),
|
||||
|
||||
@ -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[];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user