feat(oid): импорт работ из заявки на услуги на шаге 1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-09-02 15:27:30 +05:00
parent 9faff36ba2
commit 899b14b869
7 changed files with 490 additions and 4 deletions
@@ -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;
}
@@ -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<Props> = ({ service, onPick, onClose }) => {
const [requests, setRequests] = useState<ServiceRequest[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<Dialog
open
title="Выбор заявки на услуги"
subText="Выберите заявку — её проект, работы и позиции заполнят форму."
onDismiss={onClose}
width="lg"
>
{isLoading && (
<div className={styles.state}>
<Spinner size="small" /> Загрузка заявок…
</div>
)}
{!isLoading && error && <Alert variant="err">{error}</Alert>}
{!isLoading && !error && requests.length === 0 && (
<div className={styles.state}>Заявки не найдены</div>
)}
{!isLoading && !error && requests.length > 0 && (
<>
<div className={styles.searchBox}>
<TextInput
value={query}
onChange={setQuery}
placeholder="Поиск по коду, наименованию работ или проекту…"
ariaLabel="Поиск заявки"
/>
</div>
{!isSearching && requests.length > LATEST_LIMIT && (
<div className={styles.hint}>
Показаны {LATEST_LIMIT} последних — используйте поиск, чтобы найти остальные
</div>
)}
{filtered.length === 0 ? (
<div className={styles.state}>Ничего не найдено</div>
) : (
<ul className={styles.list}>
{filtered.map((r) => (
<li key={r.id} className={styles.item} onClick={() => onPick(r)}>
<div className={styles.itemMain}>
<span className={styles.itemCode}>{r.code || "б/н"}</span>
<span className={styles.itemActivity}>{r.activityName || "—"}</span>
</div>
<div className={styles.itemMeta}>
<span className={`${styles.pill} ${statusPillClass(r.status)}`}>
{r.status || "—"}
</span>
<span>{r.projectName || "—"}</span>
<span>позиций {positionsCount(r)}</span>
</div>
</li>
))}
</ul>
)}
</>
)}
</Dialog>
);
};
@@ -6,6 +6,34 @@
gap: 24px; 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 { .workNameRow {
max-width: 600px; max-width: 600px;
} }
@@ -4,6 +4,7 @@ import type { OIDFormData, FormStep } from "../types/domain";
import { OidService } from "../../services/OidService"; import { OidService } from "../../services/OidService";
import { useStep1WorksInput } from "./useStep1WorksInput"; import { useStep1WorksInput } from "./useStep1WorksInput";
import { ComboField } from "./ComboField"; import { ComboField } from "./ComboField";
import { RequestPickerDialog } from "./RequestPickerDialog";
import styles from "./Step1WorksInput.module.scss"; import styles from "./Step1WorksInput.module.scss";
interface Props { interface Props {
@@ -17,11 +18,52 @@ interface Props {
// Шаг 1 «Ввод работ» (ручной ввод групп работ, не список потребностей — своя доменка ОИД). // Шаг 1 «Ввод работ» (ручной ввод групп работ, не список потребностей — своя доменка ОИД).
// Логика — в useStep1WorksInput; здесь только разметка. // Логика — в useStep1WorksInput; здесь только разметка.
export const Step1WorksInput: React.FC<Props> = (props) => { export const Step1WorksInput: React.FC<Props> = (props) => {
const { isReadOnly = false } = props; const { isReadOnly = false, service } = props;
const a = useStep1WorksInput(props); const a = useStep1WorksInput(props);
const [showPicker, setShowPicker] = React.useState(false);
return ( return (
<div className={styles.root}> <div className={styles.root}>
{!isReadOnly && (
<div className={styles.importRow}>
<Button
variant="secondary"
onClick={() => setShowPicker(true)}
disabled={a.isSaving || a.isCreatingOid}
>
Выбрать из заявки
</Button>
<span className={styles.importHint}>
Заполнить работы из заявки на услуги (или введите вручную ниже)
</span>
</div>
)}
{a.sourceRequest?.url && (
<div className={styles.sourceRow}>
<span className={styles.importHint}>Источник:</span>
<a
className={styles.sourceLink}
href={a.sourceRequest.url}
target="_blank"
rel="noreferrer"
>
Заявка {a.sourceRequest.code || ""} ↗
</a>
</div>
)}
{showPicker && (
<RequestPickerDialog
service={service}
onClose={() => setShowPicker(false)}
onPick={(req) => {
a.applyRequest(req);
setShowPicker(false);
}}
/>
)}
<div className={styles.workNameRow}> <div className={styles.workNameRow}>
<FormField label="Проект" required> <FormField label="Проект" required>
<ComboField <ComboField
@@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import { useState, useCallback, useRef, useEffect } from "react"; import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import type { OIDFormData, PositionGroup, OIDPosition, FormStep } from "../types/domain"; import type { OIDFormData, PositionGroup, OIDPosition, ServiceRequest, FormStep } from "../types/domain";
import { OidService } from "../../services/OidService"; import { OidService } from "../../services/OidService";
export interface ComboOption { export interface ComboOption {
@@ -254,6 +254,36 @@ export function useStep1WorksInput(args: UseStep1WorksInputArgs) {
[formData.oid, onSave, service], [formData.oid, onSave, service],
); );
// Заполнить форму данными выбранной заявки (проект, наименование работ, группы/позиции).
// Ручной ввод не трогаем — это отдельное действие «Выбрать из заявки». Позиции получают
// свежие id и requestCode/requestUrl из заявки (чтобы записались в «Позиции СТ ОИД»).
const applyRequest = useCallback(
(req: ServiceRequest): void => {
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 => { const updateGroupName = useCallback((gIdx: number, name: string): void => {
setGroups((prev) => setGroups((prev) =>
prev.map((g, i) => prev.map((g, i) =>
@@ -328,6 +358,19 @@ export function useStep1WorksInput(args: UseStep1WorksInputArgs) {
markDirtyAndSync(); 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 => { const validate = (): boolean => {
if (!project.trim()) { if (!project.trim()) {
setValidationError("Выберите проект"); setValidationError("Выберите проект");
@@ -434,10 +477,12 @@ export function useStep1WorksInput(args: UseStep1WorksInputArgs) {
saveError, saveError,
validationError, validationError,
unitOptions, unitOptions,
sourceRequest,
dirtyRef, dirtyRef,
loadProjects, loadProjects,
handleWorkNameBlur, handleWorkNameBlur,
handleProjectChange, handleProjectChange,
applyRequest,
updateGroupName, updateGroupName,
updatePosition, updatePosition,
addGroup, addGroup,
@@ -30,6 +30,22 @@ export interface OIDPosition {
quantity: number; quantity: number;
priceSmeta?: number; priceSmeta?: number;
summaSmeta?: 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 { export interface Supplier {
@@ -7,6 +7,7 @@ import type {
OIDStatus, OIDStatus,
OIDPosition, OIDPosition,
PositionGroup, PositionGroup,
ServiceRequest,
Supplier, Supplier,
SupplierOffer, SupplierOffer,
SupplierCriteria, SupplierCriteria,
@@ -18,6 +19,13 @@ import type {
const SITE_URL = "https://tnsukz.sharepoint.com/sites/sed"; 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). // "Единицы измерения" lives on the construction site collection (same tenant).
const UNITS_SITE_URL = "https://tnsukz.sharepoint.com/sites/stroy"; const UNITS_SITE_URL = "https://tnsukz.sharepoint.com/sites/stroy";
const UNITS_LIST_TITLE = "Единицы измерения"; const UNITS_LIST_TITLE = "Единицы измерения";
@@ -73,6 +81,63 @@ function listUrl(title: string): string {
return `${SITE_URL}/_api/web/lists/getbytitle('${encodeURIComponent(title)}')`; 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 { export class OidService {
private readonly context: WebPartContext; private readonly context: WebPartContext;
private readonly baseUrl: string; private readonly baseUrl: string;
@@ -245,6 +310,48 @@ export class OidService {
} }
} }
// ─── Заявки на услуги (импорт работ на Шаге 1) ──────────────────────────────
// Читает заявки из списка «Заявка на услуги 1 — данные» (/sites/requests).
// Грузим все заявки (последние сверху); поиск по коду/наименованию — на клиенте.
async fetchServiceRequests(search?: string): Promise<ServiceRequest[]> {
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<Record<string, unknown>>) || [];
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 ────────────────────────────────────────────────────────────── // ─── Positions ──────────────────────────────────────────────────────────────
async createOIDPositions( async createOIDPositions(
@@ -265,6 +372,8 @@ export class OidService {
}; };
if (pos.priceSmeta !== undefined) body.priceSmeta = pos.priceSmeta; if (pos.priceSmeta !== undefined) body.priceSmeta = pos.priceSmeta;
if (pos.summaSmeta !== undefined) body.summaSmeta = pos.summaSmeta; 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( const res = await this.sp.post(
`${listUrl(LISTS.OID_POSITIONS)}/items`, `${listUrl(LISTS.OID_POSITIONS)}/items`,
@@ -308,6 +417,8 @@ export class OidService {
quantity: pos.quantity, quantity: pos.quantity,
priceSmeta: pos.priceSmeta ?? null, priceSmeta: pos.priceSmeta ?? null,
summaSmeta: pos.summaSmeta ?? null, summaSmeta: pos.summaSmeta ?? null,
RequestCode: pos.requestCode ?? null,
RequestUrl: pos.requestUrl ?? null,
}; };
if (pos.spId && existingIds.has(pos.spId)) { if (pos.spId && existingIds.has(pos.spId)) {
@@ -376,7 +487,7 @@ export class OidService {
const url = const url =
`${listUrl(LISTS.OID_POSITIONS)}/items` + `${listUrl(LISTS.OID_POSITIONS)}/items` +
`?$filter=CTId eq ${oidId}` + `?$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`; `&$top=500`;
const res = await this.sp.get(url, SPHttpClient.configurations.v1); const res = await this.sp.get(url, SPHttpClient.configurations.v1);
const data = await res.json(); const data = await res.json();
@@ -392,6 +503,8 @@ export class OidService {
quantity: (item.quantity as number) || 0, quantity: (item.quantity as number) || 0,
priceSmeta: item.priceSmeta as number | undefined, priceSmeta: item.priceSmeta as number | undefined,
summaSmeta: item.summaSmeta as number | undefined, summaSmeta: item.summaSmeta as number | undefined,
requestCode: (item.RequestCode as string) || undefined,
requestUrl: (item.RequestUrl as string) || undefined,
}), }),
); );
} }