feat(frontend): add editor workspace MVP with selection and draft mutations

This commit is contained in:
greebo
2026-03-20 21:40:28 +03:00
parent 796f0f4af1
commit d2e82c9a81
11 changed files with 2056 additions and 130 deletions

View File

@@ -6,17 +6,32 @@ import { queryKeys } from "./queryKeys";
import { rereadSchemeState } from "./reread"; import { rereadSchemeState } from "./reread";
import type { import type {
AuthMeResponse, AuthMeResponse,
BulkSeatPatchRequest,
BulkSeatPatchResponse,
CreateGroupRequest,
CreateGroupResponse,
CreatePriceRuleRequest, CreatePriceRuleRequest,
CreatePricingCategoryRequest, CreatePricingCategoryRequest,
CreateSectorRequest,
CreateSectorResponse,
DeleteEntityResponse,
DisplaySvgMetaResponse, DisplaySvgMetaResponse,
DraftComparePreviewResponse, DraftComparePreviewResponse,
DraftGroupRecordResponse,
DraftSeatRecordResponse,
DraftSectorRecordResponse,
DraftStructureResponse, DraftStructureResponse,
DraftSummaryResponse, DraftSummaryResponse,
DraftValidationResponse, DraftValidationResponse,
GroupPatchRequest,
GroupPatchResponse,
LifecycleActionResponse, LifecycleActionResponse,
ManifestResponse, ManifestResponse,
PriceRuleItem, PriceRuleItem,
PricingCategoryItem, PricingCategoryItem,
RepairReferencesResponse,
SeatPatchRequest,
SeatPatchResponse,
SchemeAuditResponse, SchemeAuditResponse,
SchemeCurrentVersionResponse, SchemeCurrentVersionResponse,
SchemeDetailResponse, SchemeDetailResponse,
@@ -26,6 +41,8 @@ import type {
SchemeSeatsResponse, SchemeSeatsResponse,
SchemeSectorsResponse, SchemeSectorsResponse,
SchemeVersionsResponse, SchemeVersionsResponse,
SectorPatchRequest,
SectorPatchResponse,
SchemesListResponse, SchemesListResponse,
TestSeatPreviewResponse, TestSeatPreviewResponse,
UpdatePriceRuleRequest, UpdatePriceRuleRequest,
@@ -68,6 +85,22 @@ async function handleLifecycleReread(
}); });
} }
async function handleEditorMutationReread(
queryClient: QueryClient,
schemeId: string | undefined,
expectedSchemeVersionId: string | null | undefined
) {
if (!schemeId) {
return;
}
await rereadSchemeState(queryClient, {
schemeId,
includeEditorDraft: true,
expectedSchemeVersionId
});
}
export function useAuthMeQuery() { export function useAuthMeQuery() {
return useQuery({ return useQuery({
queryKey: queryKeys.authMe, queryKey: queryKeys.authMe,
@@ -151,6 +184,57 @@ export function useDraftStructureQuery(
}); });
} }
export function useDraftSeatRecordQuery(
schemeId: string | undefined,
expectedSchemeVersionId: string | undefined,
seatRecordId: string | undefined,
enabled = true
) {
return useQuery({
queryKey: queryKeys.draftSeatRecord(schemeId, expectedSchemeVersionId, seatRecordId),
enabled: Boolean(schemeId && expectedSchemeVersionId && seatRecordId && enabled),
queryFn: async () =>
apiGet<DraftSeatRecordResponse>(
`/api/v1/schemes/${schemeId}/draft/seats/records/${seatRecordId}`,
draftReadParams(expectedSchemeVersionId)
)
});
}
export function useDraftSectorRecordQuery(
schemeId: string | undefined,
expectedSchemeVersionId: string | undefined,
sectorRecordId: string | undefined,
enabled = true
) {
return useQuery({
queryKey: queryKeys.draftSectorRecord(schemeId, expectedSchemeVersionId, sectorRecordId),
enabled: Boolean(schemeId && expectedSchemeVersionId && sectorRecordId && enabled),
queryFn: async () =>
apiGet<DraftSectorRecordResponse>(
`/api/v1/schemes/${schemeId}/draft/sectors/records/${sectorRecordId}`,
draftReadParams(expectedSchemeVersionId)
)
});
}
export function useDraftGroupRecordQuery(
schemeId: string | undefined,
expectedSchemeVersionId: string | undefined,
groupRecordId: string | undefined,
enabled = true
) {
return useQuery({
queryKey: queryKeys.draftGroupRecord(schemeId, expectedSchemeVersionId, groupRecordId),
enabled: Boolean(schemeId && expectedSchemeVersionId && groupRecordId && enabled),
queryFn: async () =>
apiGet<DraftGroupRecordResponse>(
`/api/v1/schemes/${schemeId}/draft/groups/records/${groupRecordId}`,
draftReadParams(expectedSchemeVersionId)
)
});
}
export function useDraftValidationQuery( export function useDraftValidationQuery(
schemeId: string | undefined, schemeId: string | undefined,
expectedSchemeVersionId: string | undefined, expectedSchemeVersionId: string | undefined,
@@ -399,6 +483,259 @@ export function useRollbackSchemeMutation(schemeId: string | undefined) {
}); });
} }
export function useCreateDraftSectorMutation(schemeId: string | undefined, expectedSchemeVersionId: string | undefined) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: CreateSectorRequest) => {
if (!schemeId || !expectedSchemeVersionId) {
throw new Error("schemeId and expectedSchemeVersionId are required");
}
return apiPost<CreateSectorResponse>(`/api/v1/schemes/${schemeId}/draft/sectors`, payload, {
params: { expected_scheme_version_id: expectedSchemeVersionId }
});
},
onSuccess: async () => {
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
},
onError: async (error) => {
if (isRereadRequiredConflict(error)) {
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
}
}
});
}
export function usePatchDraftSectorMutation(
schemeId: string | undefined,
expectedSchemeVersionId: string | undefined
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (params: { sectorRecordId: string; payload: SectorPatchRequest }) => {
if (!schemeId || !expectedSchemeVersionId) {
throw new Error("schemeId and expectedSchemeVersionId are required");
}
return apiRequest<SectorPatchResponse>({
url: `/api/v1/schemes/${schemeId}/draft/sectors/records/${params.sectorRecordId}`,
method: "patch",
data: params.payload,
params: { expected_scheme_version_id: expectedSchemeVersionId }
});
},
onSuccess: async () => {
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
},
onError: async (error) => {
if (isRereadRequiredConflict(error)) {
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
}
}
});
}
export function useDeleteDraftSectorMutation(
schemeId: string | undefined,
expectedSchemeVersionId: string | undefined
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (sectorRecordId: string) => {
if (!schemeId || !expectedSchemeVersionId) {
throw new Error("schemeId and expectedSchemeVersionId are required");
}
return apiDelete<DeleteEntityResponse>(
`/api/v1/schemes/${schemeId}/draft/sectors/records/${sectorRecordId}`,
{
params: { expected_scheme_version_id: expectedSchemeVersionId }
}
);
},
onSuccess: async () => {
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
},
onError: async (error) => {
if (isRereadRequiredConflict(error)) {
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
}
}
});
}
export function useCreateDraftGroupMutation(schemeId: string | undefined, expectedSchemeVersionId: string | undefined) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: CreateGroupRequest) => {
if (!schemeId || !expectedSchemeVersionId) {
throw new Error("schemeId and expectedSchemeVersionId are required");
}
return apiPost<CreateGroupResponse>(`/api/v1/schemes/${schemeId}/draft/groups`, payload, {
params: { expected_scheme_version_id: expectedSchemeVersionId }
});
},
onSuccess: async () => {
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
},
onError: async (error) => {
if (isRereadRequiredConflict(error)) {
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
}
}
});
}
export function usePatchDraftGroupMutation(
schemeId: string | undefined,
expectedSchemeVersionId: string | undefined
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (params: { groupRecordId: string; payload: GroupPatchRequest }) => {
if (!schemeId || !expectedSchemeVersionId) {
throw new Error("schemeId and expectedSchemeVersionId are required");
}
return apiRequest<GroupPatchResponse>({
url: `/api/v1/schemes/${schemeId}/draft/groups/records/${params.groupRecordId}`,
method: "patch",
data: params.payload,
params: { expected_scheme_version_id: expectedSchemeVersionId }
});
},
onSuccess: async () => {
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
},
onError: async (error) => {
if (isRereadRequiredConflict(error)) {
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
}
}
});
}
export function useDeleteDraftGroupMutation(
schemeId: string | undefined,
expectedSchemeVersionId: string | undefined
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (groupRecordId: string) => {
if (!schemeId || !expectedSchemeVersionId) {
throw new Error("schemeId and expectedSchemeVersionId are required");
}
return apiDelete<DeleteEntityResponse>(
`/api/v1/schemes/${schemeId}/draft/groups/records/${groupRecordId}`,
{
params: { expected_scheme_version_id: expectedSchemeVersionId }
}
);
},
onSuccess: async () => {
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
},
onError: async (error) => {
if (isRereadRequiredConflict(error)) {
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
}
}
});
}
export function usePatchDraftSeatMutation(schemeId: string | undefined, expectedSchemeVersionId: string | undefined) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (params: { seatRecordId: string; payload: SeatPatchRequest }) => {
if (!schemeId || !expectedSchemeVersionId) {
throw new Error("schemeId and expectedSchemeVersionId are required");
}
return apiRequest<SeatPatchResponse>({
url: `/api/v1/schemes/${schemeId}/draft/seats/records/${params.seatRecordId}`,
method: "patch",
data: params.payload,
params: { expected_scheme_version_id: expectedSchemeVersionId }
});
},
onSuccess: async () => {
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
},
onError: async (error) => {
if (isRereadRequiredConflict(error)) {
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
}
}
});
}
export function useBulkPatchDraftSeatsMutation(
schemeId: string | undefined,
expectedSchemeVersionId: string | undefined
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: BulkSeatPatchRequest) => {
if (!schemeId || !expectedSchemeVersionId) {
throw new Error("schemeId and expectedSchemeVersionId are required");
}
return apiPost<BulkSeatPatchResponse>(`/api/v1/schemes/${schemeId}/draft/seats/bulk`, payload, {
params: { expected_scheme_version_id: expectedSchemeVersionId }
});
},
onSuccess: async () => {
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
},
onError: async (error) => {
if (isRereadRequiredConflict(error)) {
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
}
}
});
}
export function useRepairDraftReferencesMutation(
schemeId: string | undefined,
expectedSchemeVersionId: string | undefined
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
if (!schemeId || !expectedSchemeVersionId) {
throw new Error("schemeId and expectedSchemeVersionId are required");
}
return apiPost<RepairReferencesResponse>(
`/api/v1/schemes/${schemeId}/draft/repair-references`,
{},
{
params: { expected_scheme_version_id: expectedSchemeVersionId }
}
);
},
onSuccess: async () => {
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
},
onError: async (error) => {
if (isRereadRequiredConflict(error)) {
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
}
}
});
}
export function useCreatePricingCategoryMutation(schemeId: string | undefined) { export function useCreatePricingCategoryMutation(schemeId: string | undefined) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({

View File

@@ -10,6 +10,18 @@ export const queryKeys = {
["scheme-draft-summary", schemeId, expectedVersionId] as const, ["scheme-draft-summary", schemeId, expectedVersionId] as const,
schemeDraftStructure: (schemeId: string | undefined, expectedVersionId: string | undefined) => schemeDraftStructure: (schemeId: string | undefined, expectedVersionId: string | undefined) =>
["scheme-draft-structure", schemeId, expectedVersionId] as const, ["scheme-draft-structure", schemeId, expectedVersionId] as const,
draftSeatRecord: (schemeId: string | undefined, expectedVersionId: string | undefined, seatRecordId: string | undefined) =>
["draft-seat-record", schemeId, expectedVersionId, seatRecordId] as const,
draftSectorRecord: (
schemeId: string | undefined,
expectedVersionId: string | undefined,
sectorRecordId: string | undefined
) => ["draft-sector-record", schemeId, expectedVersionId, sectorRecordId] as const,
draftGroupRecord: (
schemeId: string | undefined,
expectedVersionId: string | undefined,
groupRecordId: string | undefined
) => ["draft-group-record", schemeId, expectedVersionId, groupRecordId] as const,
schemeDraftValidation: (schemeId: string | undefined, expectedVersionId: string | undefined) => schemeDraftValidation: (schemeId: string | undefined, expectedVersionId: string | undefined) =>
["scheme-draft-validation", schemeId, expectedVersionId] as const, ["scheme-draft-validation", schemeId, expectedVersionId] as const,
schemeDraftComparePreview: (schemeId: string | undefined, expectedVersionId: string | undefined) => schemeDraftComparePreview: (schemeId: string | undefined, expectedVersionId: string | undefined) =>

View File

@@ -34,6 +34,15 @@ export async function rereadSchemeState(queryClient: QueryClient, options: Rerea
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.schemeDraftStructure(schemeId, expectedSchemeVersionId ?? undefined) queryKey: queryKeys.schemeDraftStructure(schemeId, expectedSchemeVersionId ?? undefined)
}), }),
queryClient.invalidateQueries({
queryKey: ["draft-seat-record", schemeId]
}),
queryClient.invalidateQueries({
queryKey: ["draft-sector-record", schemeId]
}),
queryClient.invalidateQueries({
queryKey: ["draft-group-record", schemeId]
}),
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.schemeDraftValidation(schemeId, expectedSchemeVersionId ?? undefined) queryKey: queryKeys.schemeDraftValidation(schemeId, expectedSchemeVersionId ?? undefined)
}), }),

View File

@@ -458,6 +458,148 @@ select {
gap: 8px; gap: 8px;
} }
.editor-workspace-grid {
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr) minmax(320px, 380px);
gap: 16px;
align-items: start;
}
.editor-pane {
min-height: 720px;
display: grid;
gap: 12px;
align-content: start;
}
.editor-pane-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
.editor-search-input {
min-width: 0;
width: 100%;
}
.editor-structure-list {
display: grid;
gap: 8px;
max-height: 560px;
overflow: auto;
}
.editor-structure-row {
display: grid;
gap: 4px;
text-align: left;
border: 1px solid #dbe3f0;
border-radius: 12px;
background: #f8fafc;
padding: 10px 12px;
cursor: pointer;
}
.editor-structure-row.selected {
border-color: #3b82f6;
background: #eff6ff;
}
.editor-structure-row-title {
font-weight: 700;
}
.editor-structure-row-meta {
color: #6b7280;
font-size: 13px;
word-break: break-word;
}
.editor-empty-state {
border: 1px dashed #d1d5db;
border-radius: 12px;
padding: 12px;
background: #fafafa;
display: grid;
gap: 6px;
}
.editor-viewer-surface {
min-height: 620px;
border: 1px solid #dbe3f0;
border-radius: 12px;
overflow: hidden;
background:
linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px),
linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px),
#f8fafc;
background-size: 24px 24px;
position: relative;
cursor: grab;
}
.editor-viewer-surface:active {
cursor: grabbing;
}
.editor-viewer-host {
display: inline-block;
min-width: 100%;
min-height: 100%;
user-select: none;
}
.editor-viewer-host svg {
width: auto;
height: auto;
max-width: none;
}
.editor-viewer-host .editor-svg-selected {
filter: drop-shadow(0 0 8px rgba(59, 130, 246, 0.65));
}
.editor-viewer-host .editor-svg-selected,
.editor-viewer-host .editor-svg-selected * {
stroke: #2563eb !important;
stroke-width: 2px !important;
}
.editor-inspector-section {
display: grid;
gap: 12px;
}
.editor-category-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.editor-chip {
border: 1px solid #dbe3f0;
border-radius: 999px;
padding: 6px 10px;
background: #f8fafc;
font-size: 13px;
}
.editor-rule-list {
display: grid;
gap: 10px;
}
.editor-rule-card {
border: 1px solid #dbe3f0;
border-radius: 12px;
background: #f8fafc;
padding: 12px;
display: grid;
gap: 8px;
}
.viewer-grid { .viewer-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 2fr) minmax(340px, 1fr); grid-template-columns: minmax(0, 2fr) minmax(340px, 1fr);
@@ -498,6 +640,10 @@ select {
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
.editor-workspace-grid {
grid-template-columns: 1fr;
}
.viewer-grid { .viewer-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -0,0 +1,646 @@
import { useEffect, useMemo, useState } from "react";
import {
getApiErrorMessage,
getApiFieldErrors,
useBulkPatchDraftSeatsMutation,
useCreateDraftGroupMutation,
useCreateDraftSectorMutation,
useCreatePriceRuleMutation,
useCreatePricingCategoryMutation,
useDeleteDraftGroupMutation,
useDeleteDraftSectorMutation,
useDeletePriceRuleMutation,
useDraftGroupRecordQuery,
useDraftSeatRecordQuery,
useDraftSectorRecordQuery,
usePatchDraftGroupMutation,
usePatchDraftSeatMutation,
usePatchDraftSectorMutation,
useRepairDraftReferencesMutation,
useSchemePricingQuery,
useUpdatePriceRuleMutation
} from "../../api/queries";
import { normalizeApiError } from "../../api/errors";
import { ApiErrorView } from "../../shared/ui/ApiErrorView";
import type {
BulkSeatPatchItemRequest,
CreatePriceRuleRequest,
DraftStructureGroupItem,
DraftStructureSeatItem,
DraftStructureSectorItem,
PriceRuleItem
} from "../../shared/types/api";
import type { EditorEntity, EditorSelection } from "./model";
type Props = {
schemeId: string;
expectedSchemeVersionId: string | undefined;
selection: EditorSelection;
selectedEntity: EditorEntity | null;
onClearSelection: () => void;
};
type CategoryFormState = {
name: string;
code: string;
};
type RuleFormState = {
pricing_category_id: string;
amount: string;
};
type SeatFormState = {
seat_id: string;
sector_id: string;
group_id: string;
row_label: string;
seat_number: string;
};
type SectorFormState = {
sector_id: string;
name: string;
};
type GroupFormState = {
group_id: string;
name: string;
};
type CreateSectorFormState = {
element_id: string;
sector_id: string;
name: string;
};
type CreateGroupFormState = {
element_id: string;
group_id: string;
name: string;
};
function valueText(value: unknown): string {
if (value === null || value === undefined || value === "") {
return "—";
}
return String(value);
}
function emptyCategoryForm(): CategoryFormState {
return { name: "", code: "" };
}
function emptyRuleForm(): RuleFormState {
return { pricing_category_id: "", amount: "" };
}
function buildSeatFormState(source: DraftStructureSeatItem | undefined): SeatFormState {
return {
seat_id: source?.seat_id ?? "",
sector_id: source?.sector_id ?? "",
group_id: source?.group_id ?? "",
row_label: source?.row_label ?? "",
seat_number: source?.seat_number ?? ""
};
}
function buildSectorFormState(source: DraftStructureSectorItem | undefined): SectorFormState {
return {
sector_id: source?.sector_id ?? "",
name: source?.name ?? ""
};
}
function buildGroupFormState(source: DraftStructureGroupItem | undefined): GroupFormState {
return {
group_id: source?.group_id ?? "",
name: source?.name ?? ""
};
}
function emptyCreateSectorForm(): CreateSectorFormState {
return {
element_id: "",
sector_id: "",
name: ""
};
}
function emptyCreateGroupForm(): CreateGroupFormState {
return {
element_id: "",
group_id: "",
name: ""
};
}
function normalizeStringField(value: string): string | undefined {
const normalized = value.trim();
return normalized ? normalized : undefined;
}
function normalizeNullableField(value: string): string | undefined {
const normalized = value.trim();
return normalized === "" ? undefined : normalized;
}
function getSelectedTargetInfo(selectedEntity: EditorEntity | null) {
if (!selectedEntity) {
return null;
}
if (selectedEntity.entityType === "seat") {
return {
targetType: "seat" as const,
targetRef: selectedEntity.businessId,
title: `Pricing for seat ${selectedEntity.businessId}`
};
}
if (selectedEntity.entityType === "group") {
return {
targetType: "group" as const,
targetRef: selectedEntity.businessId,
title: `Pricing for group ${selectedEntity.businessId}`
};
}
return null;
}
function canEditPricing(selectedEntity: EditorEntity | null) {
return selectedEntity?.entityType === "seat" || selectedEntity?.entityType === "group";
}
export function EditorInspectorPane({ schemeId, expectedSchemeVersionId, selection, selectedEntity, onClearSelection }: Props) {
const selectedSeat = selectedEntity?.entityType === "seat" ? selectedEntity : null;
const selectedSector = selectedEntity?.entityType === "sector" ? selectedEntity : null;
const selectedGroup = selectedEntity?.entityType === "group" ? selectedEntity : null;
const seatQuery = useDraftSeatRecordQuery(
schemeId,
expectedSchemeVersionId,
selectedSeat?.seat_record_id,
Boolean(selectedSeat)
);
const sectorQuery = useDraftSectorRecordQuery(
schemeId,
expectedSchemeVersionId,
selectedSector?.sector_record_id,
Boolean(selectedSector)
);
const groupQuery = useDraftGroupRecordQuery(
schemeId,
expectedSchemeVersionId,
selectedGroup?.group_record_id,
Boolean(selectedGroup)
);
const patchSeatMutation = usePatchDraftSeatMutation(schemeId, expectedSchemeVersionId);
const bulkPatchSeatsMutation = useBulkPatchDraftSeatsMutation(schemeId, expectedSchemeVersionId);
const createSectorMutation = useCreateDraftSectorMutation(schemeId, expectedSchemeVersionId);
const patchSectorMutation = usePatchDraftSectorMutation(schemeId, expectedSchemeVersionId);
const deleteSectorMutation = useDeleteDraftSectorMutation(schemeId, expectedSchemeVersionId);
const createGroupMutation = useCreateDraftGroupMutation(schemeId, expectedSchemeVersionId);
const patchGroupMutation = usePatchDraftGroupMutation(schemeId, expectedSchemeVersionId);
const deleteGroupMutation = useDeleteDraftGroupMutation(schemeId, expectedSchemeVersionId);
const repairReferencesMutation = useRepairDraftReferencesMutation(schemeId, expectedSchemeVersionId);
const pricingQuery = useSchemePricingQuery(schemeId);
const createCategoryMutation = useCreatePricingCategoryMutation(schemeId);
const createRuleMutation = useCreatePriceRuleMutation(schemeId);
const updateRuleMutation = useUpdatePriceRuleMutation(schemeId);
const deleteRuleMutation = useDeletePriceRuleMutation(schemeId);
const [seatForm, setSeatForm] = useState<SeatFormState>(buildSeatFormState(undefined));
const [sectorForm, setSectorForm] = useState<SectorFormState>(buildSectorFormState(undefined));
const [groupForm, setGroupForm] = useState<GroupFormState>(buildGroupFormState(undefined));
const [createSectorForm, setCreateSectorForm] = useState<CreateSectorFormState>(emptyCreateSectorForm());
const [createGroupForm, setCreateGroupForm] = useState<CreateGroupFormState>(emptyCreateGroupForm());
const [categoryForm, setCategoryForm] = useState<CategoryFormState>(emptyCategoryForm());
const [ruleForm, setRuleForm] = useState<RuleFormState>(emptyRuleForm());
const [editingRuleId, setEditingRuleId] = useState<string | null>(null);
useEffect(() => {
setSeatForm(buildSeatFormState(seatQuery.data ?? selectedSeat ?? undefined));
}, [seatQuery.data, selectedSeat?.businessId]);
useEffect(() => {
setSectorForm(buildSectorFormState(sectorQuery.data ?? selectedSector ?? undefined));
}, [sectorQuery.data, selectedSector?.businessId]);
useEffect(() => {
setGroupForm(buildGroupFormState(groupQuery.data ?? selectedGroup ?? undefined));
}, [groupQuery.data, selectedGroup?.businessId]);
const targetInfo = getSelectedTargetInfo(selectedEntity);
const categories = Array.isArray(pricingQuery.data?.categories) ? pricingQuery.data.categories : [];
const rules = Array.isArray(pricingQuery.data?.rules) ? pricingQuery.data.rules : [];
const selectedRules = useMemo(() => {
if (!targetInfo) {
return [];
}
return rules.filter((rule) => rule.target_type === targetInfo.targetType && rule.target_ref === targetInfo.targetRef);
}, [rules, targetInfo]);
const seatFieldErrors = getApiFieldErrors(patchSeatMutation.error);
const sectorFieldErrors = getApiFieldErrors(patchSectorMutation.error ?? createSectorMutation.error);
const groupFieldErrors = getApiFieldErrors(patchGroupMutation.error ?? createGroupMutation.error);
const ruleFieldErrors = getApiFieldErrors(createRuleMutation.error ?? updateRuleMutation.error);
const categoryFieldErrors = getApiFieldErrors(createCategoryMutation.error);
async function handleSeatSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedSeat?.seat_record_id) {
return;
}
await patchSeatMutation.mutateAsync({
seatRecordId: selectedSeat.seat_record_id,
payload: {
seat_id: normalizeStringField(seatForm.seat_id),
sector_id: normalizeStringField(seatForm.sector_id),
group_id: normalizeStringField(seatForm.group_id),
row_label: normalizeNullableField(seatForm.row_label),
seat_number: normalizeNullableField(seatForm.seat_number)
}
});
}
async function handleSectorCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!createSectorForm.sector_id.trim()) {
return;
}
await createSectorMutation.mutateAsync({
element_id: normalizeNullableField(createSectorForm.element_id),
sector_id: createSectorForm.sector_id.trim(),
name: normalizeNullableField(createSectorForm.name)
});
setCreateSectorForm(emptyCreateSectorForm());
}
async function handleSectorUpdate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedSector?.sector_record_id) {
return;
}
await patchSectorMutation.mutateAsync({
sectorRecordId: selectedSector.sector_record_id,
payload: {
sector_id: normalizeStringField(sectorForm.sector_id),
name: normalizeNullableField(sectorForm.name)
}
});
}
async function handleGroupCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!createGroupForm.group_id.trim()) {
return;
}
await createGroupMutation.mutateAsync({
element_id: normalizeNullableField(createGroupForm.element_id),
group_id: createGroupForm.group_id.trim(),
name: normalizeNullableField(createGroupForm.name)
});
setCreateGroupForm(emptyCreateGroupForm());
}
async function handleGroupUpdate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedGroup?.group_record_id) {
return;
}
await patchGroupMutation.mutateAsync({
groupRecordId: selectedGroup.group_record_id,
payload: {
group_id: normalizeStringField(groupForm.group_id),
name: normalizeNullableField(groupForm.name)
}
});
}
async function handleCategorySubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!categoryForm.name.trim() || !categoryForm.code.trim()) {
return;
}
await createCategoryMutation.mutateAsync({
name: categoryForm.name.trim(),
code: categoryForm.code.trim()
});
setCategoryForm(emptyCategoryForm());
}
async function handleRuleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!targetInfo || !ruleForm.pricing_category_id.trim() || !ruleForm.amount.trim()) {
return;
}
const payload: CreatePriceRuleRequest = {
pricing_category_id: ruleForm.pricing_category_id.trim(),
target_type: targetInfo.targetType,
target_ref: targetInfo.targetRef,
amount: ruleForm.amount.trim(),
currency: "RUB"
};
if (editingRuleId) {
await updateRuleMutation.mutateAsync({ priceRuleId: editingRuleId, payload });
} else {
await createRuleMutation.mutateAsync(payload);
}
setEditingRuleId(null);
setRuleForm(emptyRuleForm());
}
function startRuleEdit(rule: PriceRuleItem) {
setEditingRuleId(rule.price_rule_id ?? null);
setRuleForm({
pricing_category_id: rule.pricing_category_id ?? "",
amount: rule.amount ?? ""
});
}
const selectedQueryError = seatQuery.error ?? sectorQuery.error ?? groupQuery.error;
const selection404 =
selectedQueryError && normalizeApiError(selectedQueryError).status === 404
? "Selected entity is stale or already removed from the current draft."
: null;
return (
<div className="panel editor-pane">
<div className="editor-pane-header">
<div>
<h3>Inspector</h3>
<p className="muted">
{selection.selectedEntityType
? `Selected ${selection.selectedEntityType}: ${selection.selectedBusinessId ?? "—"}`
: "No entity selected yet. Selection can come from structure pane or SVG click."}
</p>
</div>
<div className="toolbar">
<button type="button" className="btn btn-secondary" onClick={onClearSelection}>
Clear selection
</button>
<button
type="button"
className="btn btn-secondary"
disabled={repairReferencesMutation.isPending || !expectedSchemeVersionId}
onClick={() => repairReferencesMutation.mutate()}
>
{repairReferencesMutation.isPending ? "Repairing..." : "Repair references"}
</button>
</div>
</div>
{selection404 ? (
<div className="editor-empty-state">
<strong>Selected entity is stale.</strong>
<div className="muted">{selection404}</div>
</div>
) : null}
{selectedEntity?.entityType === "seat" ? (
<form className="form-grid" onSubmit={handleSeatSubmit}>
<h4>Seat editor</h4>
<div className="form-field">
<label>seat_id</label>
<input className={`text-input ${seatFieldErrors.seat_id ? "input-error" : ""}`} value={seatForm.seat_id} onChange={(event) => setSeatForm((current) => ({ ...current, seat_id: event.target.value }))} />
{seatFieldErrors.seat_id ? <div className="field-error">{seatFieldErrors.seat_id}</div> : null}
</div>
<div className="form-field">
<label>sector_id</label>
<input className={`text-input ${seatFieldErrors.sector_id ? "input-error" : ""}`} value={seatForm.sector_id} onChange={(event) => setSeatForm((current) => ({ ...current, sector_id: event.target.value }))} />
{seatFieldErrors.sector_id ? <div className="field-error">{seatFieldErrors.sector_id}</div> : null}
</div>
<div className="form-field">
<label>group_id</label>
<input className={`text-input ${seatFieldErrors.group_id ? "input-error" : ""}`} value={seatForm.group_id} onChange={(event) => setSeatForm((current) => ({ ...current, group_id: event.target.value }))} />
{seatFieldErrors.group_id ? <div className="field-error">{seatFieldErrors.group_id}</div> : null}
</div>
<div className="form-field">
<label>row_label</label>
<input className={`text-input ${seatFieldErrors.row_label ? "input-error" : ""}`} value={seatForm.row_label} onChange={(event) => setSeatForm((current) => ({ ...current, row_label: event.target.value }))} />
{seatFieldErrors.row_label ? <div className="field-error">{seatFieldErrors.row_label}</div> : null}
</div>
<div className="form-field">
<label>seat_number</label>
<input className={`text-input ${seatFieldErrors.seat_number ? "input-error" : ""}`} value={seatForm.seat_number} onChange={(event) => setSeatForm((current) => ({ ...current, seat_number: event.target.value }))} />
{seatFieldErrors.seat_number ? <div className="field-error">{seatFieldErrors.seat_number}</div> : null}
</div>
<div className="toolbar">
<button type="submit" className="btn btn-primary" disabled={patchSeatMutation.isPending}>
{patchSeatMutation.isPending ? "Saving..." : "Save seat"}
</button>
<button type="button" className="btn btn-secondary" disabled={bulkPatchSeatsMutation.isPending} onClick={() => void bulkPatchSeatsMutation.mutateAsync({
items: selectedSeat?.seat_record_id
? [{
seat_record_id: selectedSeat.seat_record_id,
row_label: normalizeNullableField(seatForm.row_label),
seat_number: normalizeNullableField(seatForm.seat_number)
}]
: []
})}>
{bulkPatchSeatsMutation.isPending ? "Applying..." : "Bulk apply row/seat"}
</button>
</div>
{patchSeatMutation.isError ? <ApiErrorView title="Seat update failed" message={getApiErrorMessage(patchSeatMutation.error)} /> : null}
{bulkPatchSeatsMutation.isError ? <ApiErrorView title="Bulk seat update failed" message={getApiErrorMessage(bulkPatchSeatsMutation.error)} /> : null}
</form>
) : null}
{selectedEntity?.entityType === "sector" ? (
<form className="form-grid" onSubmit={handleSectorUpdate}>
<h4>Sector editor</h4>
<div className="form-field">
<label>sector_id</label>
<input className={`text-input ${sectorFieldErrors.sector_id ? "input-error" : ""}`} value={sectorForm.sector_id} onChange={(event) => setSectorForm((current) => ({ ...current, sector_id: event.target.value }))} />
{sectorFieldErrors.sector_id ? <div className="field-error">{sectorFieldErrors.sector_id}</div> : null}
</div>
<div className="form-field">
<label>name</label>
<input className={`text-input ${sectorFieldErrors.name ? "input-error" : ""}`} value={sectorForm.name} onChange={(event) => setSectorForm((current) => ({ ...current, name: event.target.value }))} />
{sectorFieldErrors.name ? <div className="field-error">{sectorFieldErrors.name}</div> : null}
</div>
<div className="toolbar">
<button type="submit" className="btn btn-primary" disabled={patchSectorMutation.isPending}>
{patchSectorMutation.isPending ? "Saving..." : "Save sector"}
</button>
<button type="button" className="btn btn-danger" disabled={deleteSectorMutation.isPending || !selectedSector?.sector_record_id} onClick={() => selectedSector?.sector_record_id && deleteSectorMutation.mutate(selectedSector.sector_record_id)}>
{deleteSectorMutation.isPending ? "Deleting..." : "Delete sector"}
</button>
</div>
{patchSectorMutation.isError ? <ApiErrorView title="Sector update failed" message={getApiErrorMessage(patchSectorMutation.error)} /> : null}
{deleteSectorMutation.isError ? <ApiErrorView title="Sector delete failed" message={getApiErrorMessage(deleteSectorMutation.error)} /> : null}
</form>
) : null}
{selectedEntity?.entityType === "group" ? (
<form className="form-grid" onSubmit={handleGroupUpdate}>
<h4>Group editor</h4>
<div className="form-field">
<label>group_id</label>
<input className={`text-input ${groupFieldErrors.group_id ? "input-error" : ""}`} value={groupForm.group_id} onChange={(event) => setGroupForm((current) => ({ ...current, group_id: event.target.value }))} />
{groupFieldErrors.group_id ? <div className="field-error">{groupFieldErrors.group_id}</div> : null}
</div>
<div className="form-field">
<label>name</label>
<input className={`text-input ${groupFieldErrors.name ? "input-error" : ""}`} value={groupForm.name} onChange={(event) => setGroupForm((current) => ({ ...current, name: event.target.value }))} />
{groupFieldErrors.name ? <div className="field-error">{groupFieldErrors.name}</div> : null}
</div>
<div className="toolbar">
<button type="submit" className="btn btn-primary" disabled={patchGroupMutation.isPending}>
{patchGroupMutation.isPending ? "Saving..." : "Save group"}
</button>
<button type="button" className="btn btn-danger" disabled={deleteGroupMutation.isPending || !selectedGroup?.group_record_id} onClick={() => selectedGroup?.group_record_id && deleteGroupMutation.mutate(selectedGroup.group_record_id)}>
{deleteGroupMutation.isPending ? "Deleting..." : "Delete group"}
</button>
</div>
{patchGroupMutation.isError ? <ApiErrorView title="Group update failed" message={getApiErrorMessage(patchGroupMutation.error)} /> : null}
{deleteGroupMutation.isError ? <ApiErrorView title="Group delete failed" message={getApiErrorMessage(deleteGroupMutation.error)} /> : null}
</form>
) : null}
{!selectedEntity ? (
<div className="editor-empty-state">
<strong>No selection.</strong>
<div className="muted">Choose a seat, sector, or group from the structure pane or SVG.</div>
</div>
) : null}
<form className="form-grid" onSubmit={handleSectorCreate}>
<h4>Create sector</h4>
<input className={`text-input ${sectorFieldErrors.element_id ? "input-error" : ""}`} placeholder="element_id" value={createSectorForm.element_id} onChange={(event) => setCreateSectorForm((current) => ({ ...current, element_id: event.target.value }))} />
<input className={`text-input ${sectorFieldErrors.sector_id ? "input-error" : ""}`} placeholder="sector_id" value={createSectorForm.sector_id} onChange={(event) => setCreateSectorForm((current) => ({ ...current, sector_id: event.target.value }))} />
<input className={`text-input ${sectorFieldErrors.name ? "input-error" : ""}`} placeholder="name" value={createSectorForm.name} onChange={(event) => setCreateSectorForm((current) => ({ ...current, name: event.target.value }))} />
<button type="submit" className="btn btn-secondary" disabled={createSectorMutation.isPending}>
{createSectorMutation.isPending ? "Creating..." : "Create sector"}
</button>
{createSectorMutation.isError ? <ApiErrorView title="Sector create failed" message={getApiErrorMessage(createSectorMutation.error)} /> : null}
</form>
<form className="form-grid" onSubmit={handleGroupCreate}>
<h4>Create group</h4>
<input className={`text-input ${groupFieldErrors.element_id ? "input-error" : ""}`} placeholder="element_id" value={createGroupForm.element_id} onChange={(event) => setCreateGroupForm((current) => ({ ...current, element_id: event.target.value }))} />
<input className={`text-input ${groupFieldErrors.group_id ? "input-error" : ""}`} placeholder="group_id" value={createGroupForm.group_id} onChange={(event) => setCreateGroupForm((current) => ({ ...current, group_id: event.target.value }))} />
<input className={`text-input ${groupFieldErrors.name ? "input-error" : ""}`} placeholder="name" value={createGroupForm.name} onChange={(event) => setCreateGroupForm((current) => ({ ...current, name: event.target.value }))} />
<button type="submit" className="btn btn-secondary" disabled={createGroupMutation.isPending}>
{createGroupMutation.isPending ? "Creating..." : "Create group"}
</button>
{createGroupMutation.isError ? <ApiErrorView title="Group create failed" message={getApiErrorMessage(createGroupMutation.error)} /> : null}
</form>
<div className="editor-inspector-section">
<h4>Pricing</h4>
{pricingQuery.isLoading ? <div className="muted">Loading pricing...</div> : null}
{pricingQuery.isError ? <ApiErrorView title="Pricing load failed" message={getApiErrorMessage(pricingQuery.error)} /> : null}
{!pricingQuery.isLoading ? (
<>
<div className="editor-empty-state">
<strong>Categories</strong>
<div className="muted">{categories.length === 0 ? "No pricing categories yet." : `${categories.length} categories available.`}</div>
</div>
<form className="form-grid" onSubmit={handleCategorySubmit}>
<input className={`text-input ${categoryFieldErrors.name ? "input-error" : ""}`} placeholder="category name" value={categoryForm.name} onChange={(event) => setCategoryForm((current) => ({ ...current, name: event.target.value }))} />
<input className={`text-input ${categoryFieldErrors.code ? "input-error" : ""}`} placeholder="category code" value={categoryForm.code} onChange={(event) => setCategoryForm((current) => ({ ...current, code: event.target.value }))} />
<button type="submit" className="btn btn-secondary" disabled={createCategoryMutation.isPending}>
{createCategoryMutation.isPending ? "Creating..." : "Create category"}
</button>
{createCategoryMutation.isError ? <ApiErrorView title="Category create failed" message={getApiErrorMessage(createCategoryMutation.error)} /> : null}
</form>
<div className="editor-category-list">
{categories.map((category) => (
<div key={category.pricing_category_id ?? category.code} className="editor-chip">
{valueText(category.name)} / {valueText(category.code)}
</div>
))}
</div>
{!canEditPricing(selectedEntity) ? (
<div className="editor-empty-state">
<strong>Pricing is available for seat or group selection only.</strong>
<div className="muted">Select a seat or group to create/update/delete rules for that target.</div>
</div>
) : (
<>
<div className="editor-empty-state">
<strong>{targetInfo?.title}</strong>
<div className="muted">Rules target `{targetInfo?.targetType}` / `{targetInfo?.targetRef}`.</div>
</div>
<form className="form-grid" onSubmit={handleRuleSubmit}>
<select className="select" value={ruleForm.pricing_category_id} onChange={(event) => setRuleForm((current) => ({ ...current, pricing_category_id: event.target.value }))}>
<option value="">Select category</option>
{categories.map((category) => (
<option key={category.pricing_category_id ?? category.code} value={category.pricing_category_id ?? ""}>
{valueText(category.name)} / {valueText(category.code)}
</option>
))}
</select>
<input className={`text-input ${ruleFieldErrors.amount ? "input-error" : ""}`} placeholder="amount string, e.g. 2500.00" value={ruleForm.amount} onChange={(event) => setRuleForm((current) => ({ ...current, amount: event.target.value }))} />
<input className="text-input" value={targetInfo?.targetType ?? ""} disabled />
<input className="text-input" value={targetInfo?.targetRef ?? ""} disabled />
<input className="text-input" value="RUB" disabled />
<div className="toolbar">
<button type="submit" className="btn btn-primary" disabled={createRuleMutation.isPending || updateRuleMutation.isPending}>
{editingRuleId ? (updateRuleMutation.isPending ? "Saving..." : "Save rule") : (createRuleMutation.isPending ? "Creating..." : "Create rule")}
</button>
<button type="button" className="btn btn-secondary" onClick={() => { setEditingRuleId(null); setRuleForm(emptyRuleForm()); }}>
Reset rule form
</button>
</div>
{(createRuleMutation.isError || updateRuleMutation.isError) ? (
<ApiErrorView title="Rule mutation failed" message={getApiErrorMessage(createRuleMutation.error ?? updateRuleMutation.error)} />
) : null}
</form>
{selectedRules.length === 0 ? (
<div className="editor-empty-state">
<strong>No rules for selected target.</strong>
<div className="muted">This is a valid empty pricing state.</div>
</div>
) : (
<div className="editor-rule-list">
{selectedRules.map((rule) => (
<div key={rule.price_rule_id ?? `${rule.target_type}-${rule.target_ref}`} className="editor-rule-card">
<div><strong>Category:</strong> {valueText(categories.find((item) => item.pricing_category_id === rule.pricing_category_id)?.name ?? rule.pricing_category_id)}</div>
<div><strong>Amount:</strong> {valueText(rule.amount)} {valueText(rule.currency)}</div>
<div className="toolbar">
<button type="button" className="btn btn-secondary" onClick={() => startRuleEdit(rule)}>Edit</button>
<button type="button" className="btn btn-danger" disabled={deleteRuleMutation.isPending || !rule.price_rule_id} onClick={() => rule.price_rule_id && deleteRuleMutation.mutate(rule.price_rule_id)}>
{deleteRuleMutation.isPending ? "Deleting..." : "Delete"}
</button>
</div>
</div>
))}
</div>
)}
{deleteRuleMutation.isError ? <ApiErrorView title="Rule delete failed" message={getApiErrorMessage(deleteRuleMutation.error)} /> : null}
</>
)}
</>
) : null}
</div>
{repairReferencesMutation.isError ? <ApiErrorView title="Repair references failed" message={getApiErrorMessage(repairReferencesMutation.error)} /> : null}
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { useMemo, useState } from "react";
import { selectionMatchesEntity, type EditorEntity, type EditorEntityIndex, type EditorSelection } from "./model";
type Props = {
entityIndex: EditorEntityIndex;
selection: EditorSelection;
onSelectEntity: (entity: EditorEntity) => void;
onClearSelection: () => void;
};
type FilterTab = "all" | "seat" | "sector" | "group";
function entityLabel(entity: EditorEntity): string {
if (entity.entityType === "seat") {
return `Seat ${entity.businessId}`;
}
if (entity.entityType === "sector") {
return `Sector ${entity.businessId}`;
}
return `Group ${entity.businessId}`;
}
function entityMeta(entity: EditorEntity): string {
if (entity.entityType === "seat") {
return [
entity.element_id ? `element_id=${entity.element_id}` : null,
entity.sector_id ? `sector_id=${entity.sector_id}` : null,
entity.group_id ? `group_id=${entity.group_id}` : null,
entity.row_label ? `row=${entity.row_label}` : null,
entity.seat_number ? `seat=${entity.seat_number}` : null
]
.filter(Boolean)
.join(" · ");
}
if (entity.entityType === "sector") {
return [entity.element_id ? `element_id=${entity.element_id}` : null, entity.name ? `name=${entity.name}` : null]
.filter(Boolean)
.join(" · ");
}
return [entity.element_id ? `element_id=${entity.element_id}` : null, entity.name ? `name=${entity.name}` : null]
.filter(Boolean)
.join(" · ");
}
export function EditorStructurePane({ entityIndex, selection, onSelectEntity, onClearSelection }: Props) {
const [filterTab, setFilterTab] = useState<FilterTab>("all");
const [searchValue, setSearchValue] = useState("");
const filteredItems = useMemo(() => {
const normalizedQuery = searchValue.trim().toLowerCase();
let baseItems = entityIndex.all;
if (filterTab !== "all") {
baseItems = entityIndex.all.filter((item) => item.entityType === filterTab);
}
if (!normalizedQuery) {
return baseItems;
}
return baseItems.filter((item) =>
[item.businessId, item.element_id, item.name, item.entityType]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(normalizedQuery)
);
}, [entityIndex.all, filterTab, searchValue]);
return (
<div className="panel editor-pane">
<div className="editor-pane-header">
<div>
<h3>Structure</h3>
<p className="muted">Selection model uses business identity, not record ids.</p>
</div>
<button type="button" className="btn btn-secondary" onClick={onClearSelection}>
Clear selection
</button>
</div>
<div className="toolbar" style={{ marginBottom: 12 }}>
<button type="button" className={`btn ${filterTab === "all" ? "btn-primary" : "btn-secondary"}`} onClick={() => setFilterTab("all")}>
All ({entityIndex.all.length})
</button>
<button type="button" className={`btn ${filterTab === "seat" ? "btn-primary" : "btn-secondary"}`} onClick={() => setFilterTab("seat")}>
Seats ({entityIndex.seats.length})
</button>
<button type="button" className={`btn ${filterTab === "sector" ? "btn-primary" : "btn-secondary"}`} onClick={() => setFilterTab("sector")}>
Sectors ({entityIndex.sectors.length})
</button>
<button type="button" className={`btn ${filterTab === "group" ? "btn-primary" : "btn-secondary"}`} onClick={() => setFilterTab("group")}>
Groups ({entityIndex.groups.length})
</button>
</div>
<input
className="text-input editor-search-input"
type="text"
placeholder="Search by business id, element id, or name"
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
/>
{entityIndex.all.length === 0 ? (
<div className="editor-empty-state">
<strong>No extracted entities.</strong>
<div className="muted">Empty structure is a controlled product state, not an integration failure.</div>
</div>
) : filteredItems.length === 0 ? (
<div className="editor-empty-state">
<strong>No matches for current filter.</strong>
<div className="muted">Adjust the search or switch entity tab.</div>
</div>
) : (
<div className="editor-structure-list">
{filteredItems.map((item) => (
<button
key={`${item.entityType}-${item.businessId}-${item.element_id ?? "none"}`}
type="button"
className={`editor-structure-row ${selectionMatchesEntity(selection, item) ? "selected" : ""}`}
onClick={() => onSelectEntity(item)}
>
<span className="editor-structure-row-title">{entityLabel(item)}</span>
<span className="editor-structure-row-meta">{entityMeta(item) || "—"}</span>
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,274 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { DisplaySvgMetaResponse } from "../../shared/types/api";
import type { EditorEntityIndex, EditorSelection } from "./model";
type Props = {
svgMarkup: string;
meta: DisplaySvgMetaResponse | undefined;
entityIndex: EditorEntityIndex;
selection: EditorSelection;
onSelectFromViewer: (selection: EditorSelection) => void;
};
type ViewBox = {
minX: number;
minY: number;
width: number;
height: number;
};
function parseViewBox(viewBox: string | null | undefined): ViewBox | null {
if (!viewBox) {
return null;
}
const parts = viewBox.trim().split(/\s+/).map(Number);
if (parts.length !== 4 || parts.some((value) => Number.isNaN(value))) {
return null;
}
return {
minX: parts[0],
minY: parts[1],
width: parts[2],
height: parts[3]
};
}
function getEntitySelectionFromElement(
element: Element,
entityIndex: EditorEntityIndex
): EditorSelection | null {
const nearest: Element[] = [];
let current: Element | null = element;
while (current) {
nearest.push(current);
current = current.parentElement;
}
for (const node of nearest) {
const seatBusinessId = node.getAttribute("data-seat-id");
const groupBusinessId = node.getAttribute("data-group-id");
const sectorBusinessId = node.getAttribute("data-sector-id");
const elementId = node.getAttribute("id");
if (seatBusinessId && entityIndex.seatByBusinessId.has(seatBusinessId)) {
return {
selectedEntityType: "seat",
selectedBusinessId: seatBusinessId,
selectedElementId: elementId ?? entityIndex.seatByBusinessId.get(seatBusinessId)?.element_id ?? null
};
}
if (elementId && entityIndex.seatByElementId.has(elementId)) {
const seat = entityIndex.seatByElementId.get(elementId)!;
return {
selectedEntityType: "seat",
selectedBusinessId: seat.businessId,
selectedElementId: seat.element_id ?? null
};
}
if (groupBusinessId && entityIndex.groupByBusinessId.has(groupBusinessId)) {
return {
selectedEntityType: "group",
selectedBusinessId: groupBusinessId,
selectedElementId: elementId ?? entityIndex.groupByBusinessId.get(groupBusinessId)?.element_id ?? null
};
}
if (elementId && entityIndex.groupByElementId.has(elementId)) {
const group = entityIndex.groupByElementId.get(elementId)!;
return {
selectedEntityType: "group",
selectedBusinessId: group.businessId,
selectedElementId: group.element_id ?? null
};
}
if (sectorBusinessId && entityIndex.sectorByBusinessId.has(sectorBusinessId)) {
return {
selectedEntityType: "sector",
selectedBusinessId: sectorBusinessId,
selectedElementId: elementId ?? entityIndex.sectorByBusinessId.get(sectorBusinessId)?.element_id ?? null
};
}
if (elementId && entityIndex.sectorByElementId.has(elementId)) {
const sector = entityIndex.sectorByElementId.get(elementId)!;
return {
selectedEntityType: "sector",
selectedBusinessId: sector.businessId,
selectedElementId: sector.element_id ?? null
};
}
}
return null;
}
export function EditorViewerPane({ svgMarkup, meta, entityIndex, selection, onSelectFromViewer }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
const hostRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(null);
const [scale, setScale] = useState(1);
const [translate, setTranslate] = useState({ x: 0, y: 0 });
const viewBox = useMemo(() => parseViewBox(meta?.view_box), [meta?.view_box]);
useEffect(() => {
const host = hostRef.current;
if (!host) {
return;
}
const selectedNodes = host.querySelectorAll(".editor-svg-selected");
selectedNodes.forEach((node) => node.classList.remove("editor-svg-selected"));
if (!selection.selectedElementId) {
return;
}
const target = host.querySelector(`[id="${CSS.escape(selection.selectedElementId)}"]`);
if (!target) {
return;
}
target.classList.add("editor-svg-selected");
}, [selection.selectedElementId, svgMarkup]);
useEffect(() => {
fitToScreen();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meta?.view_box, svgMarkup]);
function fitToScreen() {
const container = containerRef.current;
if (!container || !viewBox) {
setScale(1);
setTranslate({ x: 0, y: 0 });
return;
}
const availableWidth = Math.max(container.clientWidth - 32, 1);
const availableHeight = Math.max(container.clientHeight - 32, 1);
const nextScale = Math.min(availableWidth / viewBox.width, availableHeight / viewBox.height);
setScale(Number(nextScale.toFixed(4)));
setTranslate({
x: -viewBox.minX * nextScale,
y: -viewBox.minY * nextScale
});
}
function fitToWidth() {
const container = containerRef.current;
if (!container || !viewBox) {
return;
}
const nextScale = Math.max((container.clientWidth - 32) / viewBox.width, 0.1);
setScale(Number(nextScale.toFixed(4)));
setTranslate({
x: -viewBox.minX * nextScale,
y: -viewBox.minY * nextScale
});
}
function resetView() {
setScale(1);
setTranslate({ x: 0, y: 0 });
}
function handlePointerDown(event: React.PointerEvent<HTMLDivElement>) {
if (event.button !== 0) {
return;
}
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: translate.x,
originY: translate.y
};
event.currentTarget.setPointerCapture(event.pointerId);
}
function handlePointerMove(event: React.PointerEvent<HTMLDivElement>) {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
setTranslate({
x: drag.originX + (event.clientX - drag.startX),
y: drag.originY + (event.clientY - drag.startY)
});
}
function handlePointerUp(event: React.PointerEvent<HTMLDivElement>) {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
dragRef.current = null;
event.currentTarget.releasePointerCapture(event.pointerId);
}
function handleWheel(event: React.WheelEvent<HTMLDivElement>) {
event.preventDefault();
const delta = event.deltaY < 0 ? 1.1 : 0.9;
setScale((current) => Number(Math.min(8, Math.max(0.2, current * delta)).toFixed(4)));
}
function handleClick(event: React.MouseEvent<HTMLDivElement>) {
if (!(event.target instanceof Element)) {
return;
}
const nextSelection = getEntitySelectionFromElement(event.target, entityIndex);
if (nextSelection) {
onSelectFromViewer(nextSelection);
}
}
return (
<div className="panel editor-pane">
<div className="editor-pane-header">
<div>
<h3>Viewer</h3>
<p className="muted">Primary source: display SVG passthrough. Direct SVG click selection is enabled when `element_id` / `data-*` matches draft structure.</p>
</div>
<div className="toolbar">
<button type="button" className="btn btn-secondary" onClick={fitToScreen}>Fit to screen</button>
<button type="button" className="btn btn-secondary" onClick={fitToWidth}>Fit to width</button>
<button type="button" className="btn btn-secondary" onClick={resetView}>Reset view</button>
</div>
</div>
<div
ref={containerRef}
className="editor-viewer-surface"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onWheel={handleWheel}
onClick={handleClick}
>
<div
ref={hostRef}
className="editor-viewer-host"
style={{
transform: `translate(${translate.x}px, ${translate.y}px) scale(${scale})`,
transformOrigin: "top left"
}}
dangerouslySetInnerHTML={{ __html: svgMarkup }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,167 @@
import type {
DraftStructureGroupItem,
DraftStructureSeatItem,
DraftStructureSectorItem
} from "../../shared/types/api";
export type EditorEntityType = "seat" | "sector" | "group";
export type EditorSelection = {
selectedEntityType: EditorEntityType | null;
selectedBusinessId: string | null;
selectedElementId: string | null;
};
export type SeatEntity = DraftStructureSeatItem & {
entityType: "seat";
businessId: string;
};
export type SectorEntity = DraftStructureSectorItem & {
entityType: "sector";
businessId: string;
};
export type GroupEntity = DraftStructureGroupItem & {
entityType: "group";
businessId: string;
};
export type EditorEntity = SeatEntity | SectorEntity | GroupEntity;
export type EditorEntityIndex = {
seats: SeatEntity[];
sectors: SectorEntity[];
groups: GroupEntity[];
all: EditorEntity[];
seatByBusinessId: Map<string, SeatEntity>;
seatByElementId: Map<string, SeatEntity>;
sectorByBusinessId: Map<string, SectorEntity>;
sectorByElementId: Map<string, SectorEntity>;
groupByBusinessId: Map<string, GroupEntity>;
groupByElementId: Map<string, GroupEntity>;
};
export function getSeatBusinessId(item: DraftStructureSeatItem): string {
return item.seat_id || item.element_id || "";
}
export function getSectorBusinessId(item: DraftStructureSectorItem): string {
return item.sector_id || item.element_id || "";
}
export function getGroupBusinessId(item: DraftStructureGroupItem): string {
return item.group_id || item.element_id || "";
}
function putIfPresent<T>(map: Map<string, T>, key: string | null | undefined, value: T) {
if (key) {
map.set(key, value);
}
}
export function buildEditorEntityIndex(args: {
seats: DraftStructureSeatItem[];
sectors: DraftStructureSectorItem[];
groups: DraftStructureGroupItem[];
}): EditorEntityIndex {
const seats = args.seats
.map<SeatEntity>((item) => ({
...item,
entityType: "seat",
businessId: getSeatBusinessId(item)
}))
.filter((item) => Boolean(item.businessId));
const sectors = args.sectors
.map<SectorEntity>((item) => ({
...item,
entityType: "sector",
businessId: getSectorBusinessId(item)
}))
.filter((item) => Boolean(item.businessId));
const groups = args.groups
.map<GroupEntity>((item) => ({
...item,
entityType: "group",
businessId: getGroupBusinessId(item)
}))
.filter((item) => Boolean(item.businessId));
const all: EditorEntity[] = [...seats, ...sectors, ...groups];
const seatByBusinessId = new Map<string, SeatEntity>();
const seatByElementId = new Map<string, SeatEntity>();
const sectorByBusinessId = new Map<string, SectorEntity>();
const sectorByElementId = new Map<string, SectorEntity>();
const groupByBusinessId = new Map<string, GroupEntity>();
const groupByElementId = new Map<string, GroupEntity>();
seats.forEach((item) => {
putIfPresent(seatByBusinessId, item.businessId, item);
putIfPresent(seatByElementId, item.element_id, item);
});
sectors.forEach((item) => {
putIfPresent(sectorByBusinessId, item.businessId, item);
putIfPresent(sectorByElementId, item.element_id, item);
});
groups.forEach((item) => {
putIfPresent(groupByBusinessId, item.businessId, item);
putIfPresent(groupByElementId, item.element_id, item);
});
return {
seats,
sectors,
groups,
all,
seatByBusinessId,
seatByElementId,
sectorByBusinessId,
sectorByElementId,
groupByBusinessId,
groupByElementId
};
}
export function resolveSelectedEntity(selection: EditorSelection, index: EditorEntityIndex): EditorEntity | null {
if (!selection.selectedEntityType || !selection.selectedBusinessId) {
return null;
}
if (selection.selectedEntityType === "seat") {
return index.seatByBusinessId.get(selection.selectedBusinessId) ?? null;
}
if (selection.selectedEntityType === "sector") {
return index.sectorByBusinessId.get(selection.selectedBusinessId) ?? null;
}
return index.groupByBusinessId.get(selection.selectedBusinessId) ?? null;
}
export function buildSelectionFromEntity(entity: EditorEntity): EditorSelection {
return {
selectedEntityType: entity.entityType,
selectedBusinessId: entity.businessId,
selectedElementId: entity.element_id ?? null
};
}
export function emptySelection(): EditorSelection {
return {
selectedEntityType: null,
selectedBusinessId: null,
selectedElementId: null
};
}
export function selectionMatchesEntity(selection: EditorSelection, entity: EditorEntity): boolean {
return (
selection.selectedEntityType === entity.entityType &&
selection.selectedBusinessId === entity.businessId
);
}

View File

@@ -0,0 +1,80 @@
import { useEffect, useMemo, useState } from "react";
import {
buildEditorEntityIndex,
buildSelectionFromEntity,
emptySelection,
resolveSelectedEntity,
type EditorEntity,
type EditorSelection
} from "./model";
import type {
DraftStructureGroupItem,
DraftStructureSeatItem,
DraftStructureSectorItem
} from "../../shared/types/api";
type Args = {
seats: DraftStructureSeatItem[];
sectors: DraftStructureSectorItem[];
groups: DraftStructureGroupItem[];
};
export function useEditorSelection(args: Args) {
const entityIndex = useMemo(
() =>
buildEditorEntityIndex({
seats: args.seats,
sectors: args.sectors,
groups: args.groups
}),
[args.groups, args.seats, args.sectors]
);
const [selection, setSelection] = useState<EditorSelection>(emptySelection);
const [staleSelectionMessage, setStaleSelectionMessage] = useState<string | null>(null);
const selectedEntity = useMemo(() => resolveSelectedEntity(selection, entityIndex), [entityIndex, selection]);
useEffect(() => {
if (!selection.selectedEntityType || !selection.selectedBusinessId) {
return;
}
if (selectedEntity) {
return;
}
setSelection(emptySelection());
setStaleSelectionMessage("Выбранная сущность больше не существует в актуальном draft. Выделение сброшено после reread.");
}, [selectedEntity, selection.selectedBusinessId, selection.selectedEntityType]);
function selectEntity(entity: EditorEntity) {
setSelection(buildSelectionFromEntity(entity));
setStaleSelectionMessage(null);
}
function selectSelection(nextSelection: EditorSelection) {
setSelection(nextSelection);
setStaleSelectionMessage(null);
}
function clearSelection() {
setSelection(emptySelection());
}
function clearStaleSelectionMessage() {
setStaleSelectionMessage(null);
}
return {
entityIndex,
selection,
selectedEntity,
selectEntity,
selectSelection,
clearSelection,
staleSelectionMessage,
clearStaleSelectionMessage,
setSelection
};
}

View File

@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { import {
getApiErrorMessage, getApiErrorMessage,
useDisplaySvgMetaQuery,
useDisplaySvgQuery,
useDraftComparePreviewQuery, useDraftComparePreviewQuery,
useDraftStructureQuery, useDraftStructureQuery,
useDraftSummaryQuery, useDraftSummaryQuery,
@@ -10,20 +12,17 @@ import {
useSchemeCurrentQuery, useSchemeCurrentQuery,
useSchemeEditorContextQuery useSchemeEditorContextQuery
} from "../../api/queries"; } from "../../api/queries";
import { normalizeApiError, isRereadRequiredConflict } from "../../api/errors"; import { isRereadRequiredConflict, normalizeApiError } from "../../api/errors";
import { rereadSchemeState } from "../../api/reread"; import { rereadSchemeState } from "../../api/reread";
import { useAuthState } from "../../shared/auth/AuthProvider"; import { useAuthState } from "../../shared/auth/AuthProvider";
import { localizeRecommendedAction, localizeRole, localizeStatus } from "../../shared/lib/formatters"; import { localizeRecommendedAction, localizeRole, localizeStatus } from "../../shared/lib/formatters";
import { ApiErrorView } from "../../shared/ui/ApiErrorView"; import { ApiErrorView } from "../../shared/ui/ApiErrorView";
import { StatCard } from "../../shared/ui/StatCard"; import { StatCard } from "../../shared/ui/StatCard";
import type { import type { DraftComparePreviewResponse, DraftStructureResponse, DraftValidationResponse } from "../../shared/types/api";
DraftComparePreviewResponse, import { EditorInspectorPane } from "../editor/EditorInspectorPane";
DraftStructureGroupItem, import { EditorStructurePane } from "../editor/EditorStructurePane";
DraftStructureResponse, import { EditorViewerPane } from "../editor/EditorViewerPane";
DraftStructureSeatItem, import { useEditorSelection } from "../editor/useEditorSelection";
DraftStructureSectorItem,
DraftValidationResponse
} from "../../shared/types/api";
type Props = { type Props = {
schemeId: string; schemeId: string;
@@ -50,18 +49,6 @@ function prettyJson(value: unknown): string {
} }
} }
function seatBusinessId(item: DraftStructureSeatItem): string {
return item.seat_id || item.element_id || "—";
}
function sectorBusinessId(item: DraftStructureSectorItem): string {
return item.sector_id || item.element_id || "—";
}
function groupBusinessId(item: DraftStructureGroupItem): string {
return item.group_id || item.element_id || "—";
}
function issuesList(payload: DraftValidationResponse | undefined) { function issuesList(payload: DraftValidationResponse | undefined) {
return { return {
issues: Array.isArray(payload?.issues) ? payload.issues : [], issues: Array.isArray(payload?.issues) ? payload.issues : [],
@@ -109,6 +96,8 @@ export function SchemeEditorTab({ schemeId }: Props) {
const structureQuery = useDraftStructureQuery(schemeId, expectedSchemeVersionId, editorReady); const structureQuery = useDraftStructureQuery(schemeId, expectedSchemeVersionId, editorReady);
const validationQuery = useDraftValidationQuery(schemeId, expectedSchemeVersionId, editorReady); const validationQuery = useDraftValidationQuery(schemeId, expectedSchemeVersionId, editorReady);
const compareQuery = useDraftComparePreviewQuery(schemeId, expectedSchemeVersionId, editorReady); const compareQuery = useDraftComparePreviewQuery(schemeId, expectedSchemeVersionId, editorReady);
const displayMetaQuery = useDisplaySvgMetaQuery(schemeId);
const displaySvgQuery = useDisplaySvgQuery(schemeId);
const [autoEnsureAttemptedFor, setAutoEnsureAttemptedFor] = useState<string | null>(null); const [autoEnsureAttemptedFor, setAutoEnsureAttemptedFor] = useState<string | null>(null);
const [statusNote, setStatusNote] = useState<string | null>(null); const [statusNote, setStatusNote] = useState<string | null>(null);
@@ -120,15 +109,15 @@ export function SchemeEditorTab({ schemeId }: Props) {
} }
setAutoEnsureAttemptedFor(currentVersionId); setAutoEnsureAttemptedFor(currentVersionId);
setStatusNote("Текущая версия опубликована. Выполняется `draft/ensure` перед чтением editor shell."); setStatusNote("Current version is published. Running `draft/ensure` before opening the editable workspace.");
ensureDraftMutation.mutate({ expectedCurrentSchemeVersionId: currentVersionId }); ensureDraftMutation.mutate({ expectedCurrentSchemeVersionId: currentVersionId });
}, [autoEnsureAttemptedFor, currentVersionId, ensureDraftMutation, ensureDraftMutation.isPending, shouldEnsureDraft]); }, [autoEnsureAttemptedFor, currentVersionId, ensureDraftMutation, ensureDraftMutation.isPending, shouldEnsureDraft]);
useEffect(() => { useEffect(() => {
if (ensureDraftMutation.data?.created) { if (ensureDraftMutation.data?.created) {
setStatusNote("Backend создал новый draft. Выполняется controlled reread current/context и draft read models."); setStatusNote("Backend created a new draft. Current/context and draft read models were reread in a controlled way.");
} else if (ensureDraftMutation.data && ensureDraftMutation.data.created === false) { } else if (ensureDraftMutation.data && ensureDraftMutation.data.created === false) {
setStatusNote("Current draft уже был активен. Editor shell работает в нём."); setStatusNote("Current draft is already active. Workspace uses the existing draft version.");
} }
}, [ensureDraftMutation.data]); }, [ensureDraftMutation.data]);
@@ -163,7 +152,7 @@ export function SchemeEditorTab({ schemeId }: Props) {
} }
setLastConflictSignature(signature); setLastConflictSignature(signature);
setStatusNote("Backend вернул stale conflict. Editor shell перечитывает current/context и draft read models."); setStatusNote("Backend returned a stale conflict. Workspace is rereading current/context and draft read models.");
void rereadSchemeState(queryClient, { void rereadSchemeState(queryClient, {
schemeId, schemeId,
@@ -187,6 +176,11 @@ export function SchemeEditorTab({ schemeId }: Props) {
const structure = structureLists(structureQuery.data); const structure = structureLists(structureQuery.data);
const validationState = issuesList(validationQuery.data); const validationState = issuesList(validationQuery.data);
const compareWithoutBaseline = compareBaselineMissing(compareQuery.data); const compareWithoutBaseline = compareBaselineMissing(compareQuery.data);
const selectionState = useEditorSelection({
seats: structure.seats,
sectors: structure.sectors,
groups: structure.groups
});
return ( return (
<div className="detail-grid"> <div className="detail-grid">
@@ -195,11 +189,11 @@ export function SchemeEditorTab({ schemeId }: Props) {
<div> <div>
<h2>Редактор</h2> <h2>Редактор</h2>
<p className="muted"> <p className="muted">
Editor shell читает только backend read models: `draft/summary`, `draft/structure`, `draft/validation`, Real MVP workspace: structure pane, viewer pane, inspector pane, seat/sector/group editing, and selected
`draft/compare-preview`. seat/group pricing actions.
</p> </p>
</div> </div>
<div className="badge">{editorReady ? "Draft shell active" : "Entry in progress"}</div> <div className="badge">{editorReady ? "Draft workspace active" : "Entry in progress"}</div>
</div> </div>
<div className="stats-grid"> <div className="stats-grid">
@@ -222,9 +216,9 @@ export function SchemeEditorTab({ schemeId }: Props) {
{!editorReady && shouldEnsureDraft ? ( {!editorReady && shouldEnsureDraft ? (
<div className="panel"> <div className="panel">
<div className="state-block state-block-warning"> <div className="state-block state-block-warning">
<strong>Требуется подготовка draft.</strong> <strong>Draft preparation required.</strong>
<div className="muted"> <div className="muted">
Пока `draft/ensure` не завершится и `current`/`editor/context` не будут перечитаны, editor shell не читает draft models. Workspace waits for `draft/ensure` and a controlled reread before loading editable draft state.
</div> </div>
</div> </div>
</div> </div>
@@ -233,9 +227,9 @@ export function SchemeEditorTab({ schemeId }: Props) {
{!editorReady && !shouldEnsureDraft ? ( {!editorReady && !shouldEnsureDraft ? (
<div className="panel"> <div className="panel">
<div className="state-block state-block-warning"> <div className="state-block state-block-warning">
<strong>Editor shell пока недоступен.</strong> <strong>Editor workspace is not ready.</strong>
<div className="muted"> <div className="muted">
Проверь `editor/context`: current_is_draft={boolText(contextQuery.data?.current_is_draft)}, create_draft_available= Check `editor/context`: current_is_draft={boolText(contextQuery.data?.current_is_draft)}, create_draft_available=
{boolText(contextQuery.data?.create_draft_available)}, recommended_action= {boolText(contextQuery.data?.create_draft_available)}, recommended_action=
{localizeRecommendedAction(contextQuery.data?.recommended_action)}. {localizeRecommendedAction(contextQuery.data?.recommended_action)}.
</div> </div>
@@ -247,7 +241,8 @@ export function SchemeEditorTab({ schemeId }: Props) {
<ApiErrorView title="Ошибка подготовки draft" message={getApiErrorMessage(ensureDraftMutation.error)} /> <ApiErrorView title="Ошибка подготовки draft" message={getApiErrorMessage(ensureDraftMutation.error)} />
) : null} ) : null}
{editorReady && (summaryQuery.isLoading || structureQuery.isLoading || validationQuery.isLoading || compareQuery.isLoading) ? ( {editorReady &&
(summaryQuery.isLoading || structureQuery.isLoading || validationQuery.isLoading || compareQuery.isLoading) ? (
<div className="panel">Загрузка draft read models...</div> <div className="panel">Загрузка draft read models...</div>
) : null} ) : null}
@@ -267,102 +262,82 @@ export function SchemeEditorTab({ schemeId }: Props) {
<ApiErrorView title="Ошибка чтения draft compare-preview" message={getApiErrorMessage(compareQuery.error)} /> <ApiErrorView title="Ошибка чтения draft compare-preview" message={getApiErrorMessage(compareQuery.error)} />
) : null} ) : null}
{editorReady && summaryQuery.data ? ( {selectionState.staleSelectionMessage ? (
<div className="panel"> <div className="panel">
<h3>Summary</h3> <div className="state-block state-block-warning">
<div className="stats-grid"> <strong>Selection was cleared after reread.</strong>
<StatCard label="draft version id" value={summaryQuery.data.scheme_version_id ?? "—"} /> <div className="muted">{selectionState.staleSelectionMessage}</div>
<StatCard label="version number" value={summaryQuery.data.version_number ?? "—"} /> <div className="toolbar">
<StatCard label="draft status" value={localizeStatus(summaryQuery.data.status)} /> <button type="button" className="btn btn-secondary" onClick={selectionState.clearStaleSelectionMessage}>
<StatCard label="publish readiness" value={summaryQuery.data.publish_readiness ? "есть" : "—"} /> Dismiss
</div> </button>
</div>
<div className="stats-grid" style={{ marginTop: 10 }}>
<StatCard label="total seats" value={summaryQuery.data.total_seats ?? "—"} />
<StatCard label="total groups" value={summaryQuery.data.total_groups ?? "—"} />
<StatCard label="total sectors" value={summaryQuery.data.total_sectors ?? "—"} />
<StatCard label="readiness summary" value={summaryQuery.data.readiness_summary ? "есть" : "—"} />
</div>
<div className="code-box" style={{ marginTop: 12 }}>
{prettyJson({
readiness_summary: summaryQuery.data.readiness_summary ?? null,
aggregate_counts: summaryQuery.data.aggregate_counts ?? null,
validation_summary: summaryQuery.data.validation_summary ?? null,
structure_diff_summary: summaryQuery.data.structure_diff_summary ?? null,
publish_readiness: summaryQuery.data.publish_readiness ?? null
})}
</div> </div>
</div> </div>
) : null} ) : null}
{editorReady && structureQuery.data ? ( {editorReady && summaryQuery.data ? (
<div className="panel"> <div className="panel">
<h3>Structure</h3>
<div className="stats-grid"> <div className="stats-grid">
<StatCard label="sectors" value={structure.sectors.length} /> <StatCard label="draft version id" value={summaryQuery.data.scheme_version_id ?? "—"} />
<StatCard label="groups" value={structure.groups.length} /> <StatCard label="draft status" value={localizeStatus(summaryQuery.data.status)} />
<StatCard label="seats" value={structure.seats.length} /> <StatCard label="seats" value={summaryQuery.data.total_seats ?? 0} />
<StatCard label="scheme_version_id" value={structureQuery.data.scheme_version_id ?? "—"} /> <StatCard label="groups" value={summaryQuery.data.total_groups ?? 0} />
</div> </div>
<div className="stats-grid" style={{ marginTop: 10 }}>
<div className="table-wrap" style={{ marginTop: 12 }}> <StatCard label="sectors" value={summaryQuery.data.total_sectors ?? 0} />
<table className="data-table"> <StatCard label="validation issues" value={validationState.issues.length} />
<thead> <StatCard label="validation warnings" value={validationState.warnings.length} />
<tr> <StatCard label="compare baseline" value={compareWithoutBaseline ? "none" : "available"} />
<th>Entity</th>
<th>Business ID</th>
<th>element_id</th>
<th>Relations</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{structure.sectors.map((item, index) => (
<tr key={`sector-${item.sector_id ?? item.element_id ?? index}`}>
<td>sector</td>
<td>{sectorBusinessId(item)}</td>
<td>{valueText(item.element_id)}</td>
<td></td>
<td>{valueText(item.name)}</td>
</tr>
))}
{structure.groups.map((item, index) => (
<tr key={`group-${item.group_id ?? item.element_id ?? index}`}>
<td>group</td>
<td>{groupBusinessId(item)}</td>
<td>{valueText(item.element_id)}</td>
<td>sector_id={valueText(item.sector_id)}</td>
<td>{valueText(item.name)}</td>
</tr>
))}
{structure.seats.map((item, index) => (
<tr key={`seat-${item.seat_id ?? item.element_id ?? index}`}>
<td>seat</td>
<td>{seatBusinessId(item)}</td>
<td>{valueText(item.element_id)}</td>
<td>
sector_id={valueText(item.sector_id)} / group_id={valueText(item.group_id)}
</td>
<td>
row={valueText(item.row_label)} / seat={valueText(item.seat_number)}
</td>
</tr>
))}
{structure.sectors.length === 0 && structure.groups.length === 0 && structure.seats.length === 0 ? (
<tr>
<td colSpan={5}>Draft structure returned empty lists.</td>
</tr>
) : null}
</tbody>
</table>
</div> </div>
</div> </div>
) : null} ) : null}
{editorReady ? (
<div className="editor-workspace-grid">
<EditorStructurePane
entityIndex={selectionState.entityIndex}
selection={selectionState.selection}
onSelectEntity={selectionState.selectEntity}
onClearSelection={selectionState.clearSelection}
/>
{displayMetaQuery.isError || displaySvgQuery.isError ? (
<div className="panel editor-pane">
<ApiErrorView
title="Viewer unavailable"
message={getApiErrorMessage(displayMetaQuery.error ?? displaySvgQuery.error)}
/>
</div>
) : displayMetaQuery.isLoading || displaySvgQuery.isLoading ? (
<div className="panel editor-pane">Загрузка viewer...</div>
) : displaySvgQuery.data ? (
<EditorViewerPane
svgMarkup={displaySvgQuery.data}
meta={displayMetaQuery.data}
entityIndex={selectionState.entityIndex}
selection={selectionState.selection}
onSelectFromViewer={selectionState.selectSelection}
/>
) : (
<div className="panel editor-pane">
<div className="editor-empty-state">
<strong>Display SVG is empty.</strong>
<div className="muted">Viewer pane remains controlled even when passthrough SVG is unavailable.</div>
</div>
</div>
)}
<EditorInspectorPane
schemeId={schemeId}
expectedSchemeVersionId={expectedSchemeVersionId}
selection={selectionState.selection}
selectedEntity={selectionState.selectedEntity}
onClearSelection={selectionState.clearSelection}
/>
</div>
) : null}
{editorReady && validationQuery.data ? ( {editorReady && validationQuery.data ? (
<div className="panel"> <div className="panel">
<h3>Validation</h3> <h3>Validation</h3>
@@ -375,8 +350,8 @@ export function SchemeEditorTab({ schemeId }: Props) {
{validationState.issues.length === 0 && validationState.warnings.length === 0 ? ( {validationState.issues.length === 0 && validationState.warnings.length === 0 ? (
<div className="state-block state-block-success" style={{ marginTop: 12 }}> <div className="state-block state-block-success" style={{ marginTop: 12 }}>
<strong>Validation issues не найдены.</strong> <strong>Validation issues not found.</strong>
<div className="muted">Пустой набор warnings/issues не считается ошибкой интеграции.</div> <div className="muted">Empty validation issues/warnings is a controlled state.</div>
</div> </div>
) : null} ) : null}
@@ -391,12 +366,6 @@ export function SchemeEditorTab({ schemeId }: Props) {
{prettyJson(validationState.warnings)} {prettyJson(validationState.warnings)}
</div> </div>
) : null} ) : null}
{validationQuery.data.indicators ? (
<div className="code-box" style={{ marginTop: 12 }}>
{prettyJson(validationQuery.data.indicators)}
</div>
) : null}
</div> </div>
) : null} ) : null}
@@ -412,12 +381,12 @@ export function SchemeEditorTab({ schemeId }: Props) {
{compareWithoutBaseline ? ( {compareWithoutBaseline ? (
<div className="state-block state-block-info" style={{ marginTop: 12 }}> <div className="state-block state-block-info" style={{ marginTop: 12 }}>
<strong>Baseline отсутствует.</strong> <strong>Baseline is absent.</strong>
<div className="muted">Для fresh scheme compare-preview может вернуться без baseline, это controlled empty state, а не ошибка.</div> <div className="muted">For fresh schemes compare-preview without baseline stays a controlled empty state.</div>
</div> </div>
) : ( ) : (
<div className="code-box" style={{ marginTop: 12 }}> <div className="code-box" style={{ marginTop: 12 }}>
{prettyJson(compareQuery.data)} {prettyJson(compareQuery.data.summary ?? compareQuery.data.diff_summary ?? compareQuery.data)}
</div> </div>
)} )}
</div> </div>
@@ -434,6 +403,9 @@ export function SchemeEditorTab({ schemeId }: Props) {
<div><strong>current_is_draft:</strong> {boolText(contextQuery.data?.current_is_draft)}</div> <div><strong>current_is_draft:</strong> {boolText(contextQuery.data?.current_is_draft)}</div>
<div><strong>recommended_action:</strong> {localizeRecommendedAction(contextQuery.data?.recommended_action)}</div> <div><strong>recommended_action:</strong> {localizeRecommendedAction(contextQuery.data?.recommended_action)}</div>
<div><strong>last conflict code:</strong> {valueText(draftConflictError ? normalizeApiError(draftConflictError).code : null)}</div> <div><strong>last conflict code:</strong> {valueText(draftConflictError ? normalizeApiError(draftConflictError).code : null)}</div>
<div><strong>selectedEntityType:</strong> {valueText(selectionState.selection.selectedEntityType)}</div>
<div><strong>selectedBusinessId:</strong> {valueText(selectionState.selection.selectedBusinessId)}</div>
<div><strong>selectedElementId:</strong> {valueText(selectionState.selection.selectedElementId)}</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -297,34 +297,58 @@ export type DraftSummaryResponse = {
}; };
export type DraftStructureSectorItem = { export type DraftStructureSectorItem = {
sector_record_id?: string;
scheme_id?: string;
scheme_version_id?: string;
sector_id?: string | null; sector_id?: string | null;
element_id?: string | null; element_id?: string | null;
name?: string | null; name?: string | null;
classes_raw?: string | null;
created_at?: string | null;
[key: string]: unknown; [key: string]: unknown;
}; };
export type DraftStructureGroupItem = { export type DraftStructureGroupItem = {
group_record_id?: string;
scheme_id?: string;
scheme_version_id?: string;
group_id?: string | null; group_id?: string | null;
element_id?: string | null; element_id?: string | null;
name?: string | null; name?: string | null;
sector_id?: string | null; classes_raw?: string | null;
created_at?: string | null;
[key: string]: unknown; [key: string]: unknown;
}; };
export type DraftStructureSeatItem = { export type DraftStructureSeatItem = {
seat_record_id?: string;
scheme_id?: string;
scheme_version_id?: string;
seat_id?: string | null; seat_id?: string | null;
element_id?: string | null; element_id?: string | null;
sector_id?: string | null; sector_id?: string | null;
group_id?: string | null; group_id?: string | null;
row_label?: string | null; row_label?: string | null;
seat_number?: string | null; seat_number?: string | null;
tag?: string | null;
classes_raw?: string | null;
x?: number | null;
y?: number | null;
cx?: number | null;
cy?: number | null;
width?: number | null;
height?: number | null;
created_at?: string | null;
[key: string]: unknown; [key: string]: unknown;
}; };
export type DraftStructureResponse = { export type DraftStructureResponse = {
scheme_id?: string; scheme_id?: string;
scheme_version_id?: string; scheme_version_id?: string;
version_number?: number | null; status?: string | null;
total_seats?: number | null;
total_sectors?: number | null;
total_groups?: number | null;
sectors?: DraftStructureSectorItem[]; sectors?: DraftStructureSectorItem[];
groups?: DraftStructureGroupItem[]; groups?: DraftStructureGroupItem[];
seats?: DraftStructureSeatItem[]; seats?: DraftStructureSeatItem[];
@@ -352,9 +376,134 @@ export type DraftValidationResponse = {
export type DraftComparePreviewResponse = { export type DraftComparePreviewResponse = {
scheme_id?: string; scheme_id?: string;
scheme_version_id?: string; scheme_version_id?: string;
draft_scheme_version_id?: string;
baseline_scheme_version_id?: string | null; baseline_scheme_version_id?: string | null;
baseline_version_number?: number | null; baseline_version_number?: number | null;
has_structure_changes?: boolean | null; has_structure_changes?: boolean | null;
summary?: Record<string, unknown> | null;
sectors?: Array<Record<string, unknown>>;
groups?: Array<Record<string, unknown>>;
seats?: Array<Record<string, unknown>>;
diff_summary?: Record<string, unknown> | null; diff_summary?: Record<string, unknown> | null;
[key: string]: unknown; [key: string]: unknown;
}; };
export type DraftSeatRecordResponse = DraftStructureSeatItem;
export type DraftSectorRecordResponse = DraftStructureSectorItem;
export type DraftGroupRecordResponse = DraftStructureGroupItem;
export type SeatPatchRequest = {
seat_id?: string | null;
sector_id?: string | null;
group_id?: string | null;
row_label?: string | null;
seat_number?: string | null;
};
export type SeatPatchResponse = {
scheme_id?: string;
scheme_version_id?: string;
element_id?: string | null;
seat_id?: string | null;
sector_id?: string | null;
group_id?: string | null;
row_label?: string | null;
seat_number?: string | null;
};
export type BulkSeatPatchItemRequest = SeatPatchRequest & {
seat_record_id: string;
};
export type BulkSeatPatchRequest = {
items: BulkSeatPatchItemRequest[];
};
export type BulkSeatPatchResultItem = {
seat_record_id?: string;
updated_seat_id?: string | null;
sector_id?: string | null;
group_id?: string | null;
row_label?: string | null;
seat_number?: string | null;
};
export type BulkSeatPatchResponse = {
scheme_id?: string;
scheme_version_id?: string;
updated_count?: number;
items?: BulkSeatPatchResultItem[];
};
export type CreateSectorRequest = {
element_id?: string | null;
sector_id: string;
name?: string | null;
classes_raw?: string | null;
};
export type CreateSectorResponse = {
scheme_id?: string;
scheme_version_id?: string;
sector_record_id?: string;
element_id?: string | null;
sector_id?: string | null;
name?: string | null;
};
export type SectorPatchRequest = {
sector_id?: string | null;
name?: string | null;
};
export type SectorPatchResponse = {
scheme_id?: string;
scheme_version_id?: string;
element_id?: string | null;
sector_id?: string | null;
name?: string | null;
};
export type CreateGroupRequest = {
element_id?: string | null;
group_id: string;
name?: string | null;
classes_raw?: string | null;
};
export type CreateGroupResponse = {
scheme_id?: string;
scheme_version_id?: string;
group_record_id?: string;
element_id?: string | null;
group_id?: string | null;
name?: string | null;
};
export type GroupPatchRequest = {
group_id?: string | null;
name?: string | null;
};
export type GroupPatchResponse = {
scheme_id?: string;
scheme_version_id?: string;
element_id?: string | null;
group_id?: string | null;
name?: string | null;
};
export type DeleteEntityResponse = {
scheme_id?: string;
scheme_version_id?: string;
deleted?: boolean;
record_id?: string;
};
export type RepairReferencesResponse = {
scheme_id?: string;
scheme_version_id?: string;
repaired_sector_refs_count?: number;
repaired_group_refs_count?: number;
details?: Record<string, unknown>;
};