procurement: validate item links individually

This commit is contained in:
lizerk 2026-05-22 14:17:29 +03:00
parent 327130da9a
commit ece1c0a4bd
4 changed files with 134 additions and 38 deletions

View File

@ -1275,7 +1275,7 @@ export default function ProcurementPage() {
<div className="space-y-3">
{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"
/>
<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="space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="app-text-muted text-xs font-medium">Ссылки</span>
<button
type="button"
onClick={() => updateItemRow(idx, { links: [...it.links, ""] })}
className="app-link-accent inline-flex items-center gap-1 text-xs font-medium"
>
<Plus size={12} /> Добавить ссылку
</button>
</div>
{it.links.map((link, linkIndex) => (
<div key={linkIndex} className="flex items-center gap-2">
<input
value={link}
onChange={(e) => updateItemRow(idx, {
links: it.links.map((currentLink, currentIndex) => (
currentIndex === linkIndex ? e.target.value : currentLink
)),
})}
placeholder="https://example.ru/item"
className="app-input min-w-0 flex-1 rounded-lg px-3 py-2 text-sm"
/>
<button
type="button"
onClick={() => updateItemRow(idx, {
links: it.links.length > 1
? it.links.filter((_, currentIndex) => currentIndex !== linkIndex)
: [""],
})}
className="app-action-secondary inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
title="Удалить ссылку"
>
<X size={13} />
</button>
</div>
))}
</div>
</div>
</details>
</div>

View File

@ -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({
</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 className="space-y-2">
{draft.links.map((link, linkIndex) => (
<div key={linkIndex} className="flex items-center gap-2">
<input
value={link}
onChange={(event) => updateDraft({
links: draft.links.map((currentLink, currentIndex) => (
currentIndex === linkIndex ? event.target.value : currentLink
)),
})}
placeholder="https://example.ru/item"
className="app-input min-w-0 flex-1 rounded-lg px-3 py-2 text-xs"
/>
<button
type="button"
onClick={() => updateDraft({
links: draft.links.length > 1
? draft.links.filter((_, currentIndex) => currentIndex !== linkIndex)
: [""],
})}
className="app-action-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
title="Удалить ссылку"
>
<X size={12} />
</button>
</div>
))}
<button
type="button"
onClick={() => updateDraft({ links: [...draft.links, ""] })}
className="app-link-accent inline-flex items-center gap-1 text-xs font-medium"
>
<Plus size={12} /> Добавить ссылку
</button>
</div>
</div>
<div className="sm:col-span-2">
<label className="app-text-muted mb-1 block text-[11px] font-medium">Комментарий исполнителя</label>

View File

@ -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<string, unknown> = {
@ -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);

View File

@ -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;
};