diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx
index fd30c9e2..a1a7e6c2 100644
--- a/frontend/src/app/procurement/page.tsx
+++ b/frontend/src/app/procurement/page.tsx
@@ -1275,7 +1275,7 @@ export default function ProcurementPage() {
{form.items.map((it, idx) => {
const hasOptionalFields = Boolean(
- it.description.trim() || it.supplier_info.trim() || it.linksText.trim(),
+ it.description.trim() || it.supplier_info.trim() || it.links.some((link) => link.trim()),
);
return (
@@ -1344,13 +1344,44 @@ export default function ProcurementPage() {
placeholder="Информация о поставщике (необязательно)"
className="app-input w-full rounded-lg px-3 py-2 text-sm"
/>
-
diff --git a/frontend/src/components/procurement/ProcurementRequestDetailContent.tsx b/frontend/src/components/procurement/ProcurementRequestDetailContent.tsx
index aab99047..6b4844a3 100644
--- a/frontend/src/components/procurement/ProcurementRequestDetailContent.tsx
+++ b/frontend/src/components/procurement/ProcurementRequestDetailContent.tsx
@@ -2,9 +2,10 @@
import { useState } from "react";
import Link from "next/link";
-import { Check, CheckCircle2, CircleDot, ExternalLink, Save, X } from "lucide-react";
+import { Check, CheckCircle2, CircleDot, ExternalLink, Plus, Save, X } from "lucide-react";
import { RequestAvatar } from "@/components/requests/RequestAvatar";
+import { cleanLinkRows, linkHref, toLinkRows } from "@/lib/procurementLinks";
import { formatDate, formatMoney, userProfileLink } from "@/lib/shared";
import type { ProcurementItem, ProcurementItemExecutionStatus, ProcurementRequest, User } from "@/types/api";
@@ -28,7 +29,7 @@ type ItemProcessingDraft = {
expected_delivery_date: string;
actual_unit_price: string;
executor_comment: string;
- linksText: string;
+ links: string[];
};
const executionStatusOptions: { value: ProcurementItemExecutionStatus; label: string }[] = [
@@ -64,18 +65,9 @@ const normalizeItemDraft = (item: ProcurementItem): ItemProcessingDraft => ({
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") : "",
+ links: toLinkRows(item.links),
});
-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;
@@ -104,7 +96,7 @@ function ProcurementItemCard({
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),
+ links: cleanLinkRows(draft.links),
});
};
@@ -192,12 +184,41 @@ function ProcurementItemCard({
diff --git a/frontend/src/hooks/useProcurementPage.ts b/frontend/src/hooks/useProcurementPage.ts
index 36dd9c87..789a4242 100644
--- a/frontend/src/hooks/useProcurementPage.ts
+++ b/frontend/src/hooks/useProcurementPage.ts
@@ -3,6 +3,7 @@
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import { apiClient } from "@/lib/api";
import { canManageRequests, canManageSupplier } from "@/lib/permissions";
+import { cleanLinkRows, toLinkRows, validateLinkRows } from "@/lib/procurementLinks";
import { displayUserName, extractNextPage, loadAllPages } from "@/lib/shared";
import type {
Department,
@@ -19,7 +20,7 @@ type ItemDraft = {
unit: string;
estimated_unit_price: string;
supplier_info: string;
- linksText: string;
+ links: string[];
};
const emptyItem: ItemDraft = {
@@ -29,7 +30,7 @@ const emptyItem: ItemDraft = {
unit: "шт",
estimated_unit_price: "",
supplier_info: "",
- linksText: "",
+ links: [""],
};
type FormState = {
@@ -125,13 +126,6 @@ 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);
@@ -385,7 +379,7 @@ 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") : "",
+ links: toLinkRows(item.links),
}))
: [{ ...emptyItem }],
});
@@ -463,6 +457,11 @@ export function useProcurementPage(user: User | null) {
setActionError(`Позиция «${item.name}»: укажите цену за единицу.`);
return;
}
+ const linksError = validateLinkRows(item.links, `Позиция «${item.name}», ссылка`);
+ if (linksError) {
+ setActionError(linksError);
+ return;
+ }
}
const payload: Record = {
@@ -478,7 +477,7 @@ 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),
+ links: cleanLinkRows(item.links),
})),
};
@@ -697,6 +696,13 @@ export function useProcurementPage(user: User | null) {
try {
setBusyKey(`item-${itemId}`);
setActionError(null);
+ if (Array.isArray(patch.links)) {
+ const linksError = validateLinkRows(patch.links.map((link) => String(link || "")));
+ if (linksError) {
+ setActionError(linksError);
+ return false;
+ }
+ }
await apiClient.updateProcurementItem(itemId, patch);
setActionSuccess("Позиция обновлена.");
await refreshOne(requestId);
diff --git a/frontend/src/lib/procurementLinks.ts b/frontend/src/lib/procurementLinks.ts
new file mode 100644
index 00000000..09e7adc6
--- /dev/null
+++ b/frontend/src/lib/procurementLinks.ts
@@ -0,0 +1,38 @@
+export const toLinkRows = (links?: string[] | null): string[] => {
+ const rows = Array.isArray(links)
+ ? links.map((link) => String(link || "").trim()).filter(Boolean)
+ : [];
+ return rows.length > 0 ? rows : [""];
+};
+
+export const cleanLinkRows = (links: string[]): string[] => (
+ links.map((link) => link.trim()).filter(Boolean)
+);
+
+export const linkHref = (link: string): string => (
+ /^https?:\/\//i.test(link) ? link : `https://${link}`
+);
+
+export const validateLinkRows = (links: string[], label = "Ссылка"): string | null => {
+ const cleaned = cleanLinkRows(links);
+
+ for (let index = 0; index < cleaned.length; index += 1) {
+ const value = cleaned[index];
+ const displayIndex = cleaned.length > 1 ? ` ${index + 1}` : "";
+
+ if ((value.match(/https?:\/\//gi) || []).length > 1) {
+ return `${label}${displayIndex}: вставьте каждую ссылку в отдельное поле.`;
+ }
+
+ try {
+ const url = new URL(value);
+ if (!["http:", "https:"].includes(url.protocol) || !url.hostname.includes(".")) {
+ return `${label}${displayIndex}: укажите корректный URL с http:// или https://.`;
+ }
+ } catch {
+ return `${label}${displayIndex}: укажите корректный URL с http:// или https://.`;
+ }
+ }
+
+ return null;
+};