From d2e82c9a8190354074da6369a686def807209b88 Mon Sep 17 00:00:00 2001 From: greebo Date: Fri, 20 Mar 2026 21:40:28 +0300 Subject: [PATCH] feat(frontend): add editor workspace MVP with selection and draft mutations --- src/api/queries.ts | 337 ++++++++++ src/api/queryKeys.ts | 12 + src/api/reread.ts | 9 + src/app/styles.css | 146 +++++ src/features/editor/EditorInspectorPane.tsx | 646 ++++++++++++++++++++ src/features/editor/EditorStructurePane.tsx | 134 ++++ src/features/editor/EditorViewerPane.tsx | 274 +++++++++ src/features/editor/model.ts | 167 +++++ src/features/editor/useEditorSelection.ts | 80 +++ src/features/schemes/SchemeEditorTab.tsx | 228 +++---- src/shared/types/api.ts | 153 ++++- 11 files changed, 2056 insertions(+), 130 deletions(-) create mode 100644 src/features/editor/EditorInspectorPane.tsx create mode 100644 src/features/editor/EditorStructurePane.tsx create mode 100644 src/features/editor/EditorViewerPane.tsx create mode 100644 src/features/editor/model.ts create mode 100644 src/features/editor/useEditorSelection.ts diff --git a/src/api/queries.ts b/src/api/queries.ts index 96a60bc..2b539da 100644 --- a/src/api/queries.ts +++ b/src/api/queries.ts @@ -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( + `/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( + `/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( + `/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(`/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({ + 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( + `/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(`/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({ + 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( + `/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({ + 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(`/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( + `/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({ diff --git a/src/api/queryKeys.ts b/src/api/queryKeys.ts index 7b494de..1ae7b6b 100644 --- a/src/api/queryKeys.ts +++ b/src/api/queryKeys.ts @@ -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) => diff --git a/src/api/reread.ts b/src/api/reread.ts index 9b88f9a..28af47f 100644 --- a/src/api/reread.ts +++ b/src/api/reread.ts @@ -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) }), diff --git a/src/app/styles.css b/src/app/styles.css index 411173a..0119eb3 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -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; } diff --git a/src/features/editor/EditorInspectorPane.tsx b/src/features/editor/EditorInspectorPane.tsx new file mode 100644 index 0000000..e5a9e80 --- /dev/null +++ b/src/features/editor/EditorInspectorPane.tsx @@ -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(buildSeatFormState(undefined)); + const [sectorForm, setSectorForm] = useState(buildSectorFormState(undefined)); + const [groupForm, setGroupForm] = useState(buildGroupFormState(undefined)); + const [createSectorForm, setCreateSectorForm] = useState(emptyCreateSectorForm()); + const [createGroupForm, setCreateGroupForm] = useState(emptyCreateGroupForm()); + const [categoryForm, setCategoryForm] = useState(emptyCategoryForm()); + const [ruleForm, setRuleForm] = useState(emptyRuleForm()); + const [editingRuleId, setEditingRuleId] = useState(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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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 ( +
+
+
+

Inspector

+

+ {selection.selectedEntityType + ? `Selected ${selection.selectedEntityType}: ${selection.selectedBusinessId ?? "—"}` + : "No entity selected yet. Selection can come from structure pane or SVG click."} +

+
+
+ + +
+
+ + {selection404 ? ( +
+ Selected entity is stale. +
{selection404}
+
+ ) : null} + + {selectedEntity?.entityType === "seat" ? ( +
+

Seat editor

+
+ + setSeatForm((current) => ({ ...current, seat_id: event.target.value }))} /> + {seatFieldErrors.seat_id ?
{seatFieldErrors.seat_id}
: null} +
+
+ + setSeatForm((current) => ({ ...current, sector_id: event.target.value }))} /> + {seatFieldErrors.sector_id ?
{seatFieldErrors.sector_id}
: null} +
+
+ + setSeatForm((current) => ({ ...current, group_id: event.target.value }))} /> + {seatFieldErrors.group_id ?
{seatFieldErrors.group_id}
: null} +
+
+ + setSeatForm((current) => ({ ...current, row_label: event.target.value }))} /> + {seatFieldErrors.row_label ?
{seatFieldErrors.row_label}
: null} +
+
+ + setSeatForm((current) => ({ ...current, seat_number: event.target.value }))} /> + {seatFieldErrors.seat_number ?
{seatFieldErrors.seat_number}
: null} +
+
+ + +
+ {patchSeatMutation.isError ? : null} + {bulkPatchSeatsMutation.isError ? : null} + + ) : null} + + {selectedEntity?.entityType === "sector" ? ( +
+

Sector editor

+
+ + setSectorForm((current) => ({ ...current, sector_id: event.target.value }))} /> + {sectorFieldErrors.sector_id ?
{sectorFieldErrors.sector_id}
: null} +
+
+ + setSectorForm((current) => ({ ...current, name: event.target.value }))} /> + {sectorFieldErrors.name ?
{sectorFieldErrors.name}
: null} +
+
+ + +
+ {patchSectorMutation.isError ? : null} + {deleteSectorMutation.isError ? : null} + + ) : null} + + {selectedEntity?.entityType === "group" ? ( +
+

Group editor

+
+ + setGroupForm((current) => ({ ...current, group_id: event.target.value }))} /> + {groupFieldErrors.group_id ?
{groupFieldErrors.group_id}
: null} +
+
+ + setGroupForm((current) => ({ ...current, name: event.target.value }))} /> + {groupFieldErrors.name ?
{groupFieldErrors.name}
: null} +
+
+ + +
+ {patchGroupMutation.isError ? : null} + {deleteGroupMutation.isError ? : null} + + ) : null} + + {!selectedEntity ? ( +
+ No selection. +
Choose a seat, sector, or group from the structure pane or SVG.
+
+ ) : null} + +
+

Create sector

+ setCreateSectorForm((current) => ({ ...current, element_id: event.target.value }))} /> + setCreateSectorForm((current) => ({ ...current, sector_id: event.target.value }))} /> + setCreateSectorForm((current) => ({ ...current, name: event.target.value }))} /> + + {createSectorMutation.isError ? : null} + + +
+

Create group

+ setCreateGroupForm((current) => ({ ...current, element_id: event.target.value }))} /> + setCreateGroupForm((current) => ({ ...current, group_id: event.target.value }))} /> + setCreateGroupForm((current) => ({ ...current, name: event.target.value }))} /> + + {createGroupMutation.isError ? : null} + + +
+

Pricing

+ {pricingQuery.isLoading ?
Loading pricing...
: null} + {pricingQuery.isError ? : null} + + {!pricingQuery.isLoading ? ( + <> +
+ Categories +
{categories.length === 0 ? "No pricing categories yet." : `${categories.length} categories available.`}
+
+ +
+ setCategoryForm((current) => ({ ...current, name: event.target.value }))} /> + setCategoryForm((current) => ({ ...current, code: event.target.value }))} /> + + {createCategoryMutation.isError ? : null} + + +
+ {categories.map((category) => ( +
+ {valueText(category.name)} / {valueText(category.code)} +
+ ))} +
+ + {!canEditPricing(selectedEntity) ? ( +
+ Pricing is available for seat or group selection only. +
Select a seat or group to create/update/delete rules for that target.
+
+ ) : ( + <> +
+ {targetInfo?.title} +
Rules target `{targetInfo?.targetType}` / `{targetInfo?.targetRef}`.
+
+ +
+ + setRuleForm((current) => ({ ...current, amount: event.target.value }))} /> + + + +
+ + +
+ {(createRuleMutation.isError || updateRuleMutation.isError) ? ( + + ) : null} + + + {selectedRules.length === 0 ? ( +
+ No rules for selected target. +
This is a valid empty pricing state.
+
+ ) : ( +
+ {selectedRules.map((rule) => ( +
+
Category: {valueText(categories.find((item) => item.pricing_category_id === rule.pricing_category_id)?.name ?? rule.pricing_category_id)}
+
Amount: {valueText(rule.amount)} {valueText(rule.currency)}
+
+ + +
+
+ ))} +
+ )} + + {deleteRuleMutation.isError ? : null} + + )} + + ) : null} +
+ + {repairReferencesMutation.isError ? : null} +
+ ); +} diff --git a/src/features/editor/EditorStructurePane.tsx b/src/features/editor/EditorStructurePane.tsx new file mode 100644 index 0000000..a629098 --- /dev/null +++ b/src/features/editor/EditorStructurePane.tsx @@ -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("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 ( +
+
+
+

Structure

+

Selection model uses business identity, not record ids.

+
+ +
+ +
+ + + + +
+ + setSearchValue(event.target.value)} + /> + + {entityIndex.all.length === 0 ? ( +
+ No extracted entities. +
Empty structure is a controlled product state, not an integration failure.
+
+ ) : filteredItems.length === 0 ? ( +
+ No matches for current filter. +
Adjust the search or switch entity tab.
+
+ ) : ( +
+ {filteredItems.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/features/editor/EditorViewerPane.tsx b/src/features/editor/EditorViewerPane.tsx new file mode 100644 index 0000000..30989cb --- /dev/null +++ b/src/features/editor/EditorViewerPane.tsx @@ -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(null); + const hostRef = useRef(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) { + 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) { + 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) { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + + dragRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + } + + function handleWheel(event: React.WheelEvent) { + 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) { + if (!(event.target instanceof Element)) { + return; + } + + const nextSelection = getEntitySelectionFromElement(event.target, entityIndex); + if (nextSelection) { + onSelectFromViewer(nextSelection); + } + } + + return ( +
+
+
+

Viewer

+

Primary source: display SVG passthrough. Direct SVG click selection is enabled when `element_id` / `data-*` matches draft structure.

+
+
+ + + +
+
+ +
+
+
+
+ ); +} diff --git a/src/features/editor/model.ts b/src/features/editor/model.ts new file mode 100644 index 0000000..bf820fc --- /dev/null +++ b/src/features/editor/model.ts @@ -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; + seatByElementId: Map; + sectorByBusinessId: Map; + sectorByElementId: Map; + groupByBusinessId: Map; + groupByElementId: Map; +}; + +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(map: Map, 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((item) => ({ + ...item, + entityType: "seat", + businessId: getSeatBusinessId(item) + })) + .filter((item) => Boolean(item.businessId)); + + const sectors = args.sectors + .map((item) => ({ + ...item, + entityType: "sector", + businessId: getSectorBusinessId(item) + })) + .filter((item) => Boolean(item.businessId)); + + const groups = args.groups + .map((item) => ({ + ...item, + entityType: "group", + businessId: getGroupBusinessId(item) + })) + .filter((item) => Boolean(item.businessId)); + + const all: EditorEntity[] = [...seats, ...sectors, ...groups]; + + const seatByBusinessId = new Map(); + const seatByElementId = new Map(); + const sectorByBusinessId = new Map(); + const sectorByElementId = new Map(); + const groupByBusinessId = new Map(); + const groupByElementId = new Map(); + + 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 + ); +} diff --git a/src/features/editor/useEditorSelection.ts b/src/features/editor/useEditorSelection.ts new file mode 100644 index 0000000..0ae5de8 --- /dev/null +++ b/src/features/editor/useEditorSelection.ts @@ -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(emptySelection); + const [staleSelectionMessage, setStaleSelectionMessage] = useState(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 + }; +} diff --git a/src/features/schemes/SchemeEditorTab.tsx b/src/features/schemes/SchemeEditorTab.tsx index 90cde3f..05d3d8a 100644 --- a/src/features/schemes/SchemeEditorTab.tsx +++ b/src/features/schemes/SchemeEditorTab.tsx @@ -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(null); const [statusNote, setStatusNote] = useState(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 (
@@ -195,11 +189,11 @@ export function SchemeEditorTab({ schemeId }: Props) {

Редактор

- 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.

-
{editorReady ? "Draft shell active" : "Entry in progress"}
+
{editorReady ? "Draft workspace active" : "Entry in progress"}
@@ -222,9 +216,9 @@ export function SchemeEditorTab({ schemeId }: Props) { {!editorReady && shouldEnsureDraft ? (
- Требуется подготовка draft. + Draft preparation required.
- Пока `draft/ensure` не завершится и `current`/`editor/context` не будут перечитаны, editor shell не читает draft models. + Workspace waits for `draft/ensure` and a controlled reread before loading editable draft state.
@@ -233,9 +227,9 @@ export function SchemeEditorTab({ schemeId }: Props) { {!editorReady && !shouldEnsureDraft ? (
- Editor shell пока недоступен. + Editor workspace is not ready.
- Проверь `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)}.
@@ -247,7 +241,8 @@ export function SchemeEditorTab({ schemeId }: Props) { ) : null} - {editorReady && (summaryQuery.isLoading || structureQuery.isLoading || validationQuery.isLoading || compareQuery.isLoading) ? ( + {editorReady && + (summaryQuery.isLoading || structureQuery.isLoading || validationQuery.isLoading || compareQuery.isLoading) ? (
Загрузка draft read models...
) : null} @@ -267,102 +262,82 @@ export function SchemeEditorTab({ schemeId }: Props) { ) : null} - {editorReady && summaryQuery.data ? ( + {selectionState.staleSelectionMessage ? (
-

Summary

-
- - - - -
- -
- - - - -
- -
- {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 - })} +
+ Selection was cleared after reread. +
{selectionState.staleSelectionMessage}
+
+ +
) : null} - {editorReady && structureQuery.data ? ( + {editorReady && summaryQuery.data ? (
-

Structure

- - - - + + + +
- -
- - - - - - - - - - - - {structure.sectors.map((item, index) => ( - - - - - - - - ))} - - {structure.groups.map((item, index) => ( - - - - - - - - ))} - - {structure.seats.map((item, index) => ( - - - - - - - - ))} - - {structure.sectors.length === 0 && structure.groups.length === 0 && structure.seats.length === 0 ? ( - - - - ) : null} - -
EntityBusiness IDelement_idRelationsDetails
sector{sectorBusinessId(item)}{valueText(item.element_id)}{valueText(item.name)}
group{groupBusinessId(item)}{valueText(item.element_id)}sector_id={valueText(item.sector_id)}{valueText(item.name)}
seat{seatBusinessId(item)}{valueText(item.element_id)} - sector_id={valueText(item.sector_id)} / group_id={valueText(item.group_id)} - - row={valueText(item.row_label)} / seat={valueText(item.seat_number)} -
Draft structure returned empty lists.
+
+ + + +
) : null} + {editorReady ? ( +
+ + + {displayMetaQuery.isError || displaySvgQuery.isError ? ( +
+ +
+ ) : displayMetaQuery.isLoading || displaySvgQuery.isLoading ? ( +
Загрузка viewer...
+ ) : displaySvgQuery.data ? ( + + ) : ( +
+
+ Display SVG is empty. +
Viewer pane remains controlled even when passthrough SVG is unavailable.
+
+
+ )} + + +
+ ) : null} + {editorReady && validationQuery.data ? (

Validation

@@ -375,8 +350,8 @@ export function SchemeEditorTab({ schemeId }: Props) { {validationState.issues.length === 0 && validationState.warnings.length === 0 ? (
- Validation issues не найдены. -
Пустой набор warnings/issues не считается ошибкой интеграции.
+ Validation issues not found. +
Empty validation issues/warnings is a controlled state.
) : null} @@ -391,12 +366,6 @@ export function SchemeEditorTab({ schemeId }: Props) { {prettyJson(validationState.warnings)}
) : null} - - {validationQuery.data.indicators ? ( -
- {prettyJson(validationQuery.data.indicators)} -
- ) : null}
) : null} @@ -412,12 +381,12 @@ export function SchemeEditorTab({ schemeId }: Props) { {compareWithoutBaseline ? (
- Baseline отсутствует. -
Для fresh scheme compare-preview может вернуться без baseline, это controlled empty state, а не ошибка.
+ Baseline is absent. +
For fresh schemes compare-preview without baseline stays a controlled empty state.
) : (
- {prettyJson(compareQuery.data)} + {prettyJson(compareQuery.data.summary ?? compareQuery.data.diff_summary ?? compareQuery.data)}
)}
@@ -434,6 +403,9 @@ export function SchemeEditorTab({ schemeId }: Props) {
current_is_draft: {boolText(contextQuery.data?.current_is_draft)}
recommended_action: {localizeRecommendedAction(contextQuery.data?.recommended_action)}
last conflict code: {valueText(draftConflictError ? normalizeApiError(draftConflictError).code : null)}
+
selectedEntityType: {valueText(selectionState.selection.selectedEntityType)}
+
selectedBusinessId: {valueText(selectionState.selection.selectedBusinessId)}
+
selectedElementId: {valueText(selectionState.selection.selectedElementId)}
diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 3de0fd7..2cdfe9a 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -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 | null; + sectors?: Array>; + groups?: Array>; + seats?: Array>; diff_summary?: Record | 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; +};