feat(frontend): add editor workspace MVP with selection and draft mutations
This commit is contained in:
@@ -6,17 +6,32 @@ import { queryKeys } from "./queryKeys";
|
||||
import { rereadSchemeState } from "./reread";
|
||||
import type {
|
||||
AuthMeResponse,
|
||||
BulkSeatPatchRequest,
|
||||
BulkSeatPatchResponse,
|
||||
CreateGroupRequest,
|
||||
CreateGroupResponse,
|
||||
CreatePriceRuleRequest,
|
||||
CreatePricingCategoryRequest,
|
||||
CreateSectorRequest,
|
||||
CreateSectorResponse,
|
||||
DeleteEntityResponse,
|
||||
DisplaySvgMetaResponse,
|
||||
DraftComparePreviewResponse,
|
||||
DraftGroupRecordResponse,
|
||||
DraftSeatRecordResponse,
|
||||
DraftSectorRecordResponse,
|
||||
DraftStructureResponse,
|
||||
DraftSummaryResponse,
|
||||
DraftValidationResponse,
|
||||
GroupPatchRequest,
|
||||
GroupPatchResponse,
|
||||
LifecycleActionResponse,
|
||||
ManifestResponse,
|
||||
PriceRuleItem,
|
||||
PricingCategoryItem,
|
||||
RepairReferencesResponse,
|
||||
SeatPatchRequest,
|
||||
SeatPatchResponse,
|
||||
SchemeAuditResponse,
|
||||
SchemeCurrentVersionResponse,
|
||||
SchemeDetailResponse,
|
||||
@@ -26,6 +41,8 @@ import type {
|
||||
SchemeSeatsResponse,
|
||||
SchemeSectorsResponse,
|
||||
SchemeVersionsResponse,
|
||||
SectorPatchRequest,
|
||||
SectorPatchResponse,
|
||||
SchemesListResponse,
|
||||
TestSeatPreviewResponse,
|
||||
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() {
|
||||
return useQuery({
|
||||
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(
|
||||
schemeId: 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) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -10,6 +10,18 @@ export const queryKeys = {
|
||||
["scheme-draft-summary", schemeId, expectedVersionId] as const,
|
||||
schemeDraftStructure: (schemeId: string | undefined, expectedVersionId: string | undefined) =>
|
||||
["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) =>
|
||||
["scheme-draft-validation", schemeId, expectedVersionId] as const,
|
||||
schemeDraftComparePreview: (schemeId: string | undefined, expectedVersionId: string | undefined) =>
|
||||
|
||||
@@ -34,6 +34,15 @@ export async function rereadSchemeState(queryClient: QueryClient, options: Rerea
|
||||
queryClient.invalidateQueries({
|
||||
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({
|
||||
queryKey: queryKeys.schemeDraftValidation(schemeId, expectedSchemeVersionId ?? undefined)
|
||||
}),
|
||||
|
||||
@@ -458,6 +458,148 @@ select {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(340px, 1fr);
|
||||
@@ -498,6 +640,10 @@ select {
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.editor-workspace-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.viewer-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
646
src/features/editor/EditorInspectorPane.tsx
Normal file
646
src/features/editor/EditorInspectorPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
src/features/editor/EditorStructurePane.tsx
Normal file
134
src/features/editor/EditorStructurePane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
274
src/features/editor/EditorViewerPane.tsx
Normal file
274
src/features/editor/EditorViewerPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
src/features/editor/model.ts
Normal file
167
src/features/editor/model.ts
Normal 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
|
||||
);
|
||||
}
|
||||
80
src/features/editor/useEditorSelection.ts
Normal file
80
src/features/editor/useEditorSelection.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getApiErrorMessage,
|
||||
useDisplaySvgMetaQuery,
|
||||
useDisplaySvgQuery,
|
||||
useDraftComparePreviewQuery,
|
||||
useDraftStructureQuery,
|
||||
useDraftSummaryQuery,
|
||||
@@ -10,20 +12,17 @@ import {
|
||||
useSchemeCurrentQuery,
|
||||
useSchemeEditorContextQuery
|
||||
} from "../../api/queries";
|
||||
import { normalizeApiError, isRereadRequiredConflict } from "../../api/errors";
|
||||
import { isRereadRequiredConflict, normalizeApiError } from "../../api/errors";
|
||||
import { rereadSchemeState } from "../../api/reread";
|
||||
import { useAuthState } from "../../shared/auth/AuthProvider";
|
||||
import { localizeRecommendedAction, localizeRole, localizeStatus } from "../../shared/lib/formatters";
|
||||
import { ApiErrorView } from "../../shared/ui/ApiErrorView";
|
||||
import { StatCard } from "../../shared/ui/StatCard";
|
||||
import type {
|
||||
DraftComparePreviewResponse,
|
||||
DraftStructureGroupItem,
|
||||
DraftStructureResponse,
|
||||
DraftStructureSeatItem,
|
||||
DraftStructureSectorItem,
|
||||
DraftValidationResponse
|
||||
} from "../../shared/types/api";
|
||||
import type { DraftComparePreviewResponse, DraftStructureResponse, DraftValidationResponse } from "../../shared/types/api";
|
||||
import { EditorInspectorPane } from "../editor/EditorInspectorPane";
|
||||
import { EditorStructurePane } from "../editor/EditorStructurePane";
|
||||
import { EditorViewerPane } from "../editor/EditorViewerPane";
|
||||
import { useEditorSelection } from "../editor/useEditorSelection";
|
||||
|
||||
type Props = {
|
||||
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) {
|
||||
return {
|
||||
issues: Array.isArray(payload?.issues) ? payload.issues : [],
|
||||
@@ -109,6 +96,8 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
||||
const structureQuery = useDraftStructureQuery(schemeId, expectedSchemeVersionId, editorReady);
|
||||
const validationQuery = useDraftValidationQuery(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 [statusNote, setStatusNote] = useState<string | null>(null);
|
||||
@@ -120,15 +109,15 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
||||
}
|
||||
|
||||
setAutoEnsureAttemptedFor(currentVersionId);
|
||||
setStatusNote("Текущая версия опубликована. Выполняется `draft/ensure` перед чтением editor shell.");
|
||||
setStatusNote("Current version is published. Running `draft/ensure` before opening the editable workspace.");
|
||||
ensureDraftMutation.mutate({ expectedCurrentSchemeVersionId: currentVersionId });
|
||||
}, [autoEnsureAttemptedFor, currentVersionId, ensureDraftMutation, ensureDraftMutation.isPending, shouldEnsureDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
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) {
|
||||
setStatusNote("Current draft уже был активен. Editor shell работает в нём.");
|
||||
setStatusNote("Current draft is already active. Workspace uses the existing draft version.");
|
||||
}
|
||||
}, [ensureDraftMutation.data]);
|
||||
|
||||
@@ -163,7 +152,7 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
||||
}
|
||||
|
||||
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, {
|
||||
schemeId,
|
||||
@@ -187,6 +176,11 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
||||
const structure = structureLists(structureQuery.data);
|
||||
const validationState = issuesList(validationQuery.data);
|
||||
const compareWithoutBaseline = compareBaselineMissing(compareQuery.data);
|
||||
const selectionState = useEditorSelection({
|
||||
seats: structure.seats,
|
||||
sectors: structure.sectors,
|
||||
groups: structure.groups
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="detail-grid">
|
||||
@@ -195,11 +189,11 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
||||
<div>
|
||||
<h2>Редактор</h2>
|
||||
<p className="muted">
|
||||
Editor shell читает только backend read models: `draft/summary`, `draft/structure`, `draft/validation`,
|
||||
`draft/compare-preview`.
|
||||
Real MVP workspace: structure pane, viewer pane, inspector pane, seat/sector/group editing, and selected
|
||||
seat/group pricing actions.
|
||||
</p>
|
||||
</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 className="stats-grid">
|
||||
@@ -222,9 +216,9 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
||||
{!editorReady && shouldEnsureDraft ? (
|
||||
<div className="panel">
|
||||
<div className="state-block state-block-warning">
|
||||
<strong>Требуется подготовка draft.</strong>
|
||||
<strong>Draft preparation required.</strong>
|
||||
<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>
|
||||
@@ -233,9 +227,9 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
||||
{!editorReady && !shouldEnsureDraft ? (
|
||||
<div className="panel">
|
||||
<div className="state-block state-block-warning">
|
||||
<strong>Editor shell пока недоступен.</strong>
|
||||
<strong>Editor workspace is not ready.</strong>
|
||||
<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=
|
||||
{localizeRecommendedAction(contextQuery.data?.recommended_action)}.
|
||||
</div>
|
||||
@@ -247,7 +241,8 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
||||
<ApiErrorView title="Ошибка подготовки draft" message={getApiErrorMessage(ensureDraftMutation.error)} />
|
||||
) : 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>
|
||||
) : null}
|
||||
|
||||
@@ -267,102 +262,82 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
||||
<ApiErrorView title="Ошибка чтения draft compare-preview" message={getApiErrorMessage(compareQuery.error)} />
|
||||
) : null}
|
||||
|
||||
{editorReady && summaryQuery.data ? (
|
||||
{selectionState.staleSelectionMessage ? (
|
||||
<div className="panel">
|
||||
<h3>Summary</h3>
|
||||
<div className="stats-grid">
|
||||
<StatCard label="draft version id" value={summaryQuery.data.scheme_version_id ?? "—"} />
|
||||
<StatCard label="version number" value={summaryQuery.data.version_number ?? "—"} />
|
||||
<StatCard label="draft status" value={localizeStatus(summaryQuery.data.status)} />
|
||||
<StatCard label="publish readiness" value={summaryQuery.data.publish_readiness ? "есть" : "—"} />
|
||||
</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 className="state-block state-block-warning">
|
||||
<strong>Selection was cleared after reread.</strong>
|
||||
<div className="muted">{selectionState.staleSelectionMessage}</div>
|
||||
<div className="toolbar">
|
||||
<button type="button" className="btn btn-secondary" onClick={selectionState.clearStaleSelectionMessage}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{editorReady && structureQuery.data ? (
|
||||
{editorReady && summaryQuery.data ? (
|
||||
<div className="panel">
|
||||
<h3>Structure</h3>
|
||||
<div className="stats-grid">
|
||||
<StatCard label="sectors" value={structure.sectors.length} />
|
||||
<StatCard label="groups" value={structure.groups.length} />
|
||||
<StatCard label="seats" value={structure.seats.length} />
|
||||
<StatCard label="scheme_version_id" value={structureQuery.data.scheme_version_id ?? "—"} />
|
||||
<StatCard label="draft version id" value={summaryQuery.data.scheme_version_id ?? "—"} />
|
||||
<StatCard label="draft status" value={localizeStatus(summaryQuery.data.status)} />
|
||||
<StatCard label="seats" value={summaryQuery.data.total_seats ?? 0} />
|
||||
<StatCard label="groups" value={summaryQuery.data.total_groups ?? 0} />
|
||||
</div>
|
||||
|
||||
<div className="table-wrap" style={{ marginTop: 12 }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<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 className="stats-grid" style={{ marginTop: 10 }}>
|
||||
<StatCard label="sectors" value={summaryQuery.data.total_sectors ?? 0} />
|
||||
<StatCard label="validation issues" value={validationState.issues.length} />
|
||||
<StatCard label="validation warnings" value={validationState.warnings.length} />
|
||||
<StatCard label="compare baseline" value={compareWithoutBaseline ? "none" : "available"} />
|
||||
</div>
|
||||
</div>
|
||||
) : 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 ? (
|
||||
<div className="panel">
|
||||
<h3>Validation</h3>
|
||||
@@ -375,8 +350,8 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
||||
|
||||
{validationState.issues.length === 0 && validationState.warnings.length === 0 ? (
|
||||
<div className="state-block state-block-success" style={{ marginTop: 12 }}>
|
||||
<strong>Validation issues не найдены.</strong>
|
||||
<div className="muted">Пустой набор warnings/issues не считается ошибкой интеграции.</div>
|
||||
<strong>Validation issues not found.</strong>
|
||||
<div className="muted">Empty validation issues/warnings is a controlled state.</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -391,12 +366,6 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
||||
{prettyJson(validationState.warnings)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{validationQuery.data.indicators ? (
|
||||
<div className="code-box" style={{ marginTop: 12 }}>
|
||||
{prettyJson(validationQuery.data.indicators)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -412,12 +381,12 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
||||
|
||||
{compareWithoutBaseline ? (
|
||||
<div className="state-block state-block-info" style={{ marginTop: 12 }}>
|
||||
<strong>Baseline отсутствует.</strong>
|
||||
<div className="muted">Для fresh scheme compare-preview может вернуться без baseline, это controlled empty state, а не ошибка.</div>
|
||||
<strong>Baseline is absent.</strong>
|
||||
<div className="muted">For fresh schemes compare-preview without baseline stays a controlled empty state.</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="code-box" style={{ marginTop: 12 }}>
|
||||
{prettyJson(compareQuery.data)}
|
||||
{prettyJson(compareQuery.data.summary ?? compareQuery.data.diff_summary ?? compareQuery.data)}
|
||||
</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>recommended_action:</strong> {localizeRecommendedAction(contextQuery.data?.recommended_action)}</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>
|
||||
|
||||
@@ -297,34 +297,58 @@ export type DraftSummaryResponse = {
|
||||
};
|
||||
|
||||
export type DraftStructureSectorItem = {
|
||||
sector_record_id?: string;
|
||||
scheme_id?: string;
|
||||
scheme_version_id?: string;
|
||||
sector_id?: string | null;
|
||||
element_id?: string | null;
|
||||
name?: string | null;
|
||||
classes_raw?: string | null;
|
||||
created_at?: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type DraftStructureGroupItem = {
|
||||
group_record_id?: string;
|
||||
scheme_id?: string;
|
||||
scheme_version_id?: string;
|
||||
group_id?: string | null;
|
||||
element_id?: string | null;
|
||||
name?: string | null;
|
||||
sector_id?: string | null;
|
||||
classes_raw?: string | null;
|
||||
created_at?: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type DraftStructureSeatItem = {
|
||||
seat_record_id?: string;
|
||||
scheme_id?: string;
|
||||
scheme_version_id?: string;
|
||||
seat_id?: string | null;
|
||||
element_id?: string | null;
|
||||
sector_id?: string | null;
|
||||
group_id?: string | null;
|
||||
row_label?: 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;
|
||||
};
|
||||
|
||||
export type DraftStructureResponse = {
|
||||
scheme_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[];
|
||||
groups?: DraftStructureGroupItem[];
|
||||
seats?: DraftStructureSeatItem[];
|
||||
@@ -352,9 +376,134 @@ export type DraftValidationResponse = {
|
||||
export type DraftComparePreviewResponse = {
|
||||
scheme_id?: string;
|
||||
scheme_version_id?: string;
|
||||
draft_scheme_version_id?: string;
|
||||
baseline_scheme_version_id?: string | null;
|
||||
baseline_version_number?: number | 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;
|
||||
[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>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user