From 899b14b869c2ae5a255eda371c6c153a888aabac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Wed, 2 Sep 2026 15:27:30 +0500 Subject: [PATCH] =?UTF-8?q?feat(oid):=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=20=D0=B8=D0=B7=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=8F=D0=B2=D0=BA=D0=B8=20=D0=BD=D0=B0=20=D1=83=D1=81?= =?UTF-8?q?=D0=BB=D1=83=D0=B3=D0=B8=20=D0=BD=D0=B0=20=D1=88=D0=B0=D0=B3?= =?UTF-8?q?=D0=B5=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../RequestPickerDialog.module.scss | 112 +++++++++++++++ .../Step1WorksInput/RequestPickerDialog.tsx | 130 ++++++++++++++++++ .../Step1WorksInput.module.scss | 28 ++++ .../Step1WorksInput/Step1WorksInput.tsx | 44 +++++- .../Step1WorksInput/useStep1WorksInput.ts | 49 ++++++- .../forms/oid/components/types/domain.ts | 16 +++ .../forms/oid/services/OidService.ts | 115 +++++++++++++++- 7 files changed, 490 insertions(+), 4 deletions(-) create mode 100644 src/webparts/ctFormsPortal/forms/oid/components/Step1WorksInput/RequestPickerDialog.module.scss create mode 100644 src/webparts/ctFormsPortal/forms/oid/components/Step1WorksInput/RequestPickerDialog.tsx diff --git a/src/webparts/ctFormsPortal/forms/oid/components/Step1WorksInput/RequestPickerDialog.module.scss b/src/webparts/ctFormsPortal/forms/oid/components/Step1WorksInput/RequestPickerDialog.module.scss new file mode 100644 index 0000000..75fd02d --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/oid/components/Step1WorksInput/RequestPickerDialog.module.scss @@ -0,0 +1,112 @@ +@import '../common/theme'; + +.state { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + color: $muted; + font-size: 14px; +} + +.searchBox { + margin-bottom: 8px; +} + +.hint { + margin-bottom: 8px; + font-size: 12px; + color: $muted; +} + +.list { + list-style: none; + margin: 0; + padding: 4px; + width: 100%; + max-height: 50vh; + overflow-y: auto; + box-sizing: border-box; + border: 1px solid $border-strong; + border-radius: 4px; + background: $surface; + display: flex; + flex-direction: column; + gap: 4px; +} + +.item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 10px; + border-radius: 4px; + cursor: pointer; + + &:hover { + background: $hover; + } +} + +.itemMain { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} + +.itemCode { + font-weight: 600; + color: $text; + flex-shrink: 0; +} + +.itemActivity { + color: $text; + font-size: 13px; + overflow-wrap: anywhere; +} + +.itemMeta { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + font-size: 12px; + color: $muted; +} + +// ─── Статус-пилюля ─────────────────────────────────────────────────────────── +.pill { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.pillOk { + color: $ok; + background: $ok-bg; +} + +.pillAccent { + color: $accent-dark; + background: $accent-bg; +} + +.pillWarn { + color: $warn-fg; + background: $warn-bg; +} + +.pillDanger { + color: $danger; + background: $danger-bg; +} + +.pillMuted { + color: $muted; + background: $head-bg; +} diff --git a/src/webparts/ctFormsPortal/forms/oid/components/Step1WorksInput/RequestPickerDialog.tsx b/src/webparts/ctFormsPortal/forms/oid/components/Step1WorksInput/RequestPickerDialog.tsx new file mode 100644 index 0000000..3465493 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/oid/components/Step1WorksInput/RequestPickerDialog.tsx @@ -0,0 +1,130 @@ +import * as React from "react"; +import { useState, useEffect, useMemo } from "react"; +import { Dialog, TextInput, Spinner, Alert } from "@tnsu/ui-kit"; +import type { ServiceRequest } from "../types/domain"; +import { OidService } from "../../services/OidService"; +import styles from "./RequestPickerDialog.module.scss"; + +interface Props { + service: OidService; + onPick: (request: ServiceRequest) => void; + onClose: () => void; +} + +// Сколько последних заявок показывать без поиска (список может быть очень большим). +const LATEST_LIMIT = 5; + +function positionsCount(request: ServiceRequest): number { + return request.groups.reduce((sum, g) => sum + g.positions.length, 0); +} + +function statusPillClass(status: string): string { + switch (status) { + case "Согласовано": + return styles.pillOk; + case "Выполнение": + return styles.pillAccent; + case "На согласовании": + case "Возвращена на доработку": + return styles.pillWarn; + case "Отклонено": + return styles.pillDanger; + default: + return styles.pillMuted; // Черновик, Закрыта, прочее + } +} + +// Диалог выбора заявки на услуги (Шаг 1 ОИД): поиск + список (по умолчанию 5 последних). +// Выбор заявки заполняет форму работами из неё. +export const RequestPickerDialog: React.FC = ({ service, onPick, onClose }) => { + const [requests, setRequests] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [query, setQuery] = useState(""); + + useEffect(() => { + let cancelled = false; + setIsLoading(true); + service + .fetchServiceRequests() + .then((list) => { if (!cancelled) setRequests(list); }) + .catch((e) => { if (!cancelled) setError((e as Error).message || "Ошибка загрузки заявок"); }) + .finally(() => { if (!cancelled) setIsLoading(false); }); + return () => { cancelled = true; }; + }, [service]); + + const isSearching = query.trim().length > 0; + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return requests.slice(0, LATEST_LIMIT); + return requests.filter( + (r) => + r.code.toLowerCase().includes(q) || + r.activityName.toLowerCase().includes(q) || + r.projectName.toLowerCase().includes(q), + ); + }, [requests, query]); + + return ( + + {isLoading && ( +
+ Загрузка заявок… +
+ )} + + {!isLoading && error && {error}} + + {!isLoading && !error && requests.length === 0 && ( +
Заявки не найдены
+ )} + + {!isLoading && !error && requests.length > 0 && ( + <> +
+ +
+ + {!isSearching && requests.length > LATEST_LIMIT && ( +
+ Показаны {LATEST_LIMIT} последних — используйте поиск, чтобы найти остальные +
+ )} + + {filtered.length === 0 ? ( +
Ничего не найдено
+ ) : ( +
    + {filtered.map((r) => ( +
  • onPick(r)}> +
    + {r.code || "б/н"} + {r.activityName || "—"} +
    +
    + + {r.status || "—"} + + {r.projectName || "—"} + позиций {positionsCount(r)} +
    +
  • + ))} +
+ )} + + )} +
+ ); +}; diff --git a/src/webparts/ctFormsPortal/forms/oid/components/Step1WorksInput/Step1WorksInput.module.scss b/src/webparts/ctFormsPortal/forms/oid/components/Step1WorksInput/Step1WorksInput.module.scss index 2b6f170..21f4319 100644 --- a/src/webparts/ctFormsPortal/forms/oid/components/Step1WorksInput/Step1WorksInput.module.scss +++ b/src/webparts/ctFormsPortal/forms/oid/components/Step1WorksInput/Step1WorksInput.module.scss @@ -6,6 +6,34 @@ gap: 24px; } +.importRow { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.importHint { + font-size: 13px; + color: $muted; +} + +.sourceRow { + display: flex; + align-items: center; + gap: 8px; +} + +.sourceLink { + font-size: 13px; + color: $text; + text-decoration: underline; + + &:hover { + opacity: 0.8; + } +} + .workNameRow { max-width: 600px; } diff --git a/src/webparts/ctFormsPortal/forms/oid/components/Step1WorksInput/Step1WorksInput.tsx b/src/webparts/ctFormsPortal/forms/oid/components/Step1WorksInput/Step1WorksInput.tsx index fa15d81..daf83ad 100644 --- a/src/webparts/ctFormsPortal/forms/oid/components/Step1WorksInput/Step1WorksInput.tsx +++ b/src/webparts/ctFormsPortal/forms/oid/components/Step1WorksInput/Step1WorksInput.tsx @@ -4,6 +4,7 @@ import type { OIDFormData, FormStep } from "../types/domain"; import { OidService } from "../../services/OidService"; import { useStep1WorksInput } from "./useStep1WorksInput"; import { ComboField } from "./ComboField"; +import { RequestPickerDialog } from "./RequestPickerDialog"; import styles from "./Step1WorksInput.module.scss"; interface Props { @@ -17,11 +18,52 @@ interface Props { // Шаг 1 «Ввод работ» (ручной ввод групп работ, не список потребностей — своя доменка ОИД). // Логика — в useStep1WorksInput; здесь только разметка. export const Step1WorksInput: React.FC = (props) => { - const { isReadOnly = false } = props; + const { isReadOnly = false, service } = props; const a = useStep1WorksInput(props); + const [showPicker, setShowPicker] = React.useState(false); return (
+ {!isReadOnly && ( +
+ + + Заполнить работы из заявки на услуги (или введите вручную ниже) + +
+ )} + + {a.sourceRequest?.url && ( + + )} + + {showPicker && ( + setShowPicker(false)} + onPick={(req) => { + a.applyRequest(req); + setShowPicker(false); + }} + /> + )} +
{ + dirtyRef.current = true; + setProject(req.projectName); + setProjectOptions((prev) => + prev.some((o) => o.key === req.projectName) + ? prev + : [{ key: req.projectName, text: req.projectName }, ...prev], + ); + setWorkName(req.activityName); + + const groupsFromReq: PositionGroup[] = req.groups.map((g) => ({ + name: g.name, + positions: g.positions.map((p) => ({ ...p, id: genId(), spId: undefined })), + })); + setGroups(groupsFromReq.length > 0 ? groupsFromReq : [makeEmptyGroup()]); + + const oid = formData.oid; + if (oid) { + onSave({ oid: { ...oid, project: req.projectName } }); + service.updateOidProject(oid.id, req.projectName).catch(() => undefined); + } + markDirtyAndSync(); + }, + [formData.oid, onSave, service, markDirtyAndSync], + ); + const updateGroupName = useCallback((gIdx: number, name: string): void => { setGroups((prev) => prev.map((g, i) => @@ -328,6 +358,19 @@ export function useStep1WorksInput(args: UseStep1WorksInputArgs) { markDirtyAndSync(); }, []); + // Заявка-источник (для ссылки на форме): берём из первой позиции, где проставлены + // requestCode/requestUrl — работает и после импорта, и после перезагрузки из SP. + const sourceRequest = useMemo(() => { + for (const g of groups) { + for (const p of g.positions) { + if (p.requestCode || p.requestUrl) { + return { code: p.requestCode ?? "", url: p.requestUrl ?? "" }; + } + } + } + return null; + }, [groups]); + const validate = (): boolean => { if (!project.trim()) { setValidationError("Выберите проект"); @@ -434,10 +477,12 @@ export function useStep1WorksInput(args: UseStep1WorksInputArgs) { saveError, validationError, unitOptions, + sourceRequest, dirtyRef, loadProjects, handleWorkNameBlur, handleProjectChange, + applyRequest, updateGroupName, updatePosition, addGroup, diff --git a/src/webparts/ctFormsPortal/forms/oid/components/types/domain.ts b/src/webparts/ctFormsPortal/forms/oid/components/types/domain.ts index 39eebc7..00e0280 100644 --- a/src/webparts/ctFormsPortal/forms/oid/components/types/domain.ts +++ b/src/webparts/ctFormsPortal/forms/oid/components/types/domain.ts @@ -30,6 +30,22 @@ export interface OIDPosition { quantity: number; priceSmeta?: number; summaSmeta?: number; + requestCode?: string; // номер заявки-источника (SP-поле RequestCode) + requestUrl?: string; // ссылка на заявку в SP (SP-поле RequestUrl) +} + +// Заявка на услуги из SP-списка «Заявка на услуги 1 — данные» (/sites/requests). +// Источник для импорта работ на Шаге 1 ОИД. +export interface ServiceRequest { + id: number; + code: string; // RequestUniqueCode + status: string; // RequestStatus + projectName: string; // ProjectName + activityName: string; // ActivityName + requestDate: string; // RequestDate + requesterName: string; // RequesterName + url: string; // ссылка на форму просмотра элемента заявки + groups: PositionGroup[]; // распарсено из WorkVolumesJson (позиции уже с requestCode/requestUrl) } export interface Supplier { diff --git a/src/webparts/ctFormsPortal/forms/oid/services/OidService.ts b/src/webparts/ctFormsPortal/forms/oid/services/OidService.ts index e5b23d3..b3e868a 100644 --- a/src/webparts/ctFormsPortal/forms/oid/services/OidService.ts +++ b/src/webparts/ctFormsPortal/forms/oid/services/OidService.ts @@ -7,6 +7,7 @@ import type { OIDStatus, OIDPosition, PositionGroup, + ServiceRequest, Supplier, SupplierOffer, SupplierCriteria, @@ -18,6 +19,13 @@ import type { const SITE_URL = "https://tnsukz.sharepoint.com/sites/sed"; +// Список заявок на услуги (источник импорта работ на Шаге 1). Отдельный сайт, тот же тенант. +const REQUESTS_SITE_URL = "https://tnsukz.sharepoint.com/sites/requests"; +const REQUESTS_LIST_TITLE = "Заявка на услуги 1 — данные"; +// Ссылка на заявку: страница портала заявок, rpid = Id элемента списка. +const REQUEST_LINK_BASE = + "https://tnsukz.sharepoint.com/sites/requests/SitePages/Портал-Заявок.aspx?rpmode=approve&rptype=wh-service-request&rpid="; + // "Единицы измерения" lives on the construction site collection (same tenant). const UNITS_SITE_URL = "https://tnsukz.sharepoint.com/sites/stroy"; const UNITS_LIST_TITLE = "Единицы измерения"; @@ -73,6 +81,63 @@ function listUrl(title: string): string { return `${SITE_URL}/_api/web/lists/getbytitle('${encodeURIComponent(title)}')`; } +function reqGenId(): string { + return Math.random().toString(36).slice(2) + Date.now().toString(36); +} + +// qty/rate в JSON заявки — строки, бывают с пробелами и запятой ("2000 ", "5 000,5"). +function reqToNum(v: unknown): number { + const n = parseFloat(String(v ?? "").trim().replace(/\s/g, "").replace(",", ".")); + return isNaN(n) ? 0 : n; +} + +interface WorkVolumeItem { + description?: string; + unit?: string; + qty?: string | number; + rate?: string | number; +} +interface WorkVolumeGroup { + name?: string; + items?: WorkVolumeItem[]; +} + +// Парсит WorkVolumesJson заявки в группы/позиции формы ОИД. +// Формат: {"groups":[{"name":"...","items":[{"description","unit","qty","rate"}]}],"total":N} +function parseWorkVolumes(raw: string, requestCode: string, requestUrl: string): PositionGroup[] { + if (!raw || !raw.trim()) return []; + let data: { groups?: WorkVolumeGroup[] }; + try { + data = JSON.parse(raw); + } catch { + return []; + } + const groups = Array.isArray(data.groups) ? data.groups : []; + return groups.map((g) => { + const name = String(g.name ?? "").trim(); + const items = Array.isArray(g.items) ? g.items : []; + return { + name, + positions: items.map((it): OIDPosition => { + const quantity = reqToNum(it.qty); + const price = reqToNum(it.rate); + return { + id: reqGenId(), + parentGroup: name, + position: String(it.description ?? "").trim(), + positionType: "Услуга", + unit: String(it.unit ?? "").trim(), + quantity, + priceSmeta: price || undefined, + summaSmeta: price && quantity ? price * quantity : undefined, + requestCode, + requestUrl, + }; + }), + }; + }); +} + export class OidService { private readonly context: WebPartContext; private readonly baseUrl: string; @@ -245,6 +310,48 @@ export class OidService { } } + // ─── Заявки на услуги (импорт работ на Шаге 1) ────────────────────────────── + + // Читает заявки из списка «Заявка на услуги 1 — данные» (/sites/requests). + // Грузим все заявки (последние сверху); поиск по коду/наименованию — на клиенте. + async fetchServiceRequests(search?: string): Promise { + const url = + `${REQUESTS_SITE_URL}/_api/web/lists/getbytitle('${encodeURIComponent(REQUESTS_LIST_TITLE)}')/items` + + `?$select=Id,RequestUniqueCode,RequestStatus,ProjectName,ProjectId,ActivityName,RequestDate,RequesterName,WorkVolumesJson` + + `&$orderby=RequestDate%20desc` + + `&$top=200`; + + const res = await this.sp.get(url, SPHttpClient.configurations.v1); + if (!res.ok) throw new Error(`Ошибка загрузки заявок: ${res.status}`); + const data = await res.json(); + const items = (data.value as Array>) || []; + + const mapped: ServiceRequest[] = items.map((it) => { + const id = it.Id as number; + const code = (it.RequestUniqueCode as string) || ""; + const requestUrl = `${REQUEST_LINK_BASE}${id}`; + return { + id, + code, + status: (it.RequestStatus as string) || "", + projectName: (it.ProjectName as string) || "", + activityName: (it.ActivityName as string) || "", + requestDate: (it.RequestDate as string) || "", + requesterName: (it.RequesterName as string) || "", + url: requestUrl, + groups: parseWorkVolumes((it.WorkVolumesJson as string) || "", code, requestUrl), + }; + }); + + const q = (search ?? "").trim().toLowerCase(); + if (!q) return mapped; + return mapped.filter( + (r) => + r.code.toLowerCase().includes(q) || + r.activityName.toLowerCase().includes(q), + ); + } + // ─── Positions ────────────────────────────────────────────────────────────── async createOIDPositions( @@ -265,6 +372,8 @@ export class OidService { }; if (pos.priceSmeta !== undefined) body.priceSmeta = pos.priceSmeta; if (pos.summaSmeta !== undefined) body.summaSmeta = pos.summaSmeta; + if (pos.requestCode) body.RequestCode = pos.requestCode; + if (pos.requestUrl) body.RequestUrl = pos.requestUrl; const res = await this.sp.post( `${listUrl(LISTS.OID_POSITIONS)}/items`, @@ -308,6 +417,8 @@ export class OidService { quantity: pos.quantity, priceSmeta: pos.priceSmeta ?? null, summaSmeta: pos.summaSmeta ?? null, + RequestCode: pos.requestCode ?? null, + RequestUrl: pos.requestUrl ?? null, }; if (pos.spId && existingIds.has(pos.spId)) { @@ -376,7 +487,7 @@ export class OidService { const url = `${listUrl(LISTS.OID_POSITIONS)}/items` + `?$filter=CTId eq ${oidId}` + - `&$select=Id,parentGroup,position,positionType,unit,quantity,priceSmeta,summaSmeta` + + `&$select=Id,parentGroup,position,positionType,unit,quantity,priceSmeta,summaSmeta,RequestCode,RequestUrl` + `&$top=500`; const res = await this.sp.get(url, SPHttpClient.configurations.v1); const data = await res.json(); @@ -392,6 +503,8 @@ export class OidService { quantity: (item.quantity as number) || 0, priceSmeta: item.priceSmeta as number | undefined, summaSmeta: item.summaSmeta as number | undefined, + requestCode: (item.RequestCode as string) || undefined, + requestUrl: (item.RequestUrl as string) || undefined, }), ); }