feat(oid): импорт работ из заявки на услуги на шаге 1
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+112
@@ -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;
|
||||||
|
}
|
||||||
+130
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+28
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-1
@@ -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
|
||||||
|
|||||||
+47
-2
@@ -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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user