procurement: validate item links individually
This commit is contained in:
parent
327130da9a
commit
ece1c0a4bd
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
38
frontend/src/lib/procurementLinks.ts
Normal file
38
frontend/src/lib/procurementLinks.ts
Normal 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;
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user