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 { rereadSchemeState } from "./reread";
|
||||||
import type {
|
import type {
|
||||||
AuthMeResponse,
|
AuthMeResponse,
|
||||||
|
BulkSeatPatchRequest,
|
||||||
|
BulkSeatPatchResponse,
|
||||||
|
CreateGroupRequest,
|
||||||
|
CreateGroupResponse,
|
||||||
CreatePriceRuleRequest,
|
CreatePriceRuleRequest,
|
||||||
CreatePricingCategoryRequest,
|
CreatePricingCategoryRequest,
|
||||||
|
CreateSectorRequest,
|
||||||
|
CreateSectorResponse,
|
||||||
|
DeleteEntityResponse,
|
||||||
DisplaySvgMetaResponse,
|
DisplaySvgMetaResponse,
|
||||||
DraftComparePreviewResponse,
|
DraftComparePreviewResponse,
|
||||||
|
DraftGroupRecordResponse,
|
||||||
|
DraftSeatRecordResponse,
|
||||||
|
DraftSectorRecordResponse,
|
||||||
DraftStructureResponse,
|
DraftStructureResponse,
|
||||||
DraftSummaryResponse,
|
DraftSummaryResponse,
|
||||||
DraftValidationResponse,
|
DraftValidationResponse,
|
||||||
|
GroupPatchRequest,
|
||||||
|
GroupPatchResponse,
|
||||||
LifecycleActionResponse,
|
LifecycleActionResponse,
|
||||||
ManifestResponse,
|
ManifestResponse,
|
||||||
PriceRuleItem,
|
PriceRuleItem,
|
||||||
PricingCategoryItem,
|
PricingCategoryItem,
|
||||||
|
RepairReferencesResponse,
|
||||||
|
SeatPatchRequest,
|
||||||
|
SeatPatchResponse,
|
||||||
SchemeAuditResponse,
|
SchemeAuditResponse,
|
||||||
SchemeCurrentVersionResponse,
|
SchemeCurrentVersionResponse,
|
||||||
SchemeDetailResponse,
|
SchemeDetailResponse,
|
||||||
@@ -26,6 +41,8 @@ import type {
|
|||||||
SchemeSeatsResponse,
|
SchemeSeatsResponse,
|
||||||
SchemeSectorsResponse,
|
SchemeSectorsResponse,
|
||||||
SchemeVersionsResponse,
|
SchemeVersionsResponse,
|
||||||
|
SectorPatchRequest,
|
||||||
|
SectorPatchResponse,
|
||||||
SchemesListResponse,
|
SchemesListResponse,
|
||||||
TestSeatPreviewResponse,
|
TestSeatPreviewResponse,
|
||||||
UpdatePriceRuleRequest,
|
UpdatePriceRuleRequest,
|
||||||
@@ -68,6 +85,22 @@ async function handleLifecycleReread(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleEditorMutationReread(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
schemeId: string | undefined,
|
||||||
|
expectedSchemeVersionId: string | null | undefined
|
||||||
|
) {
|
||||||
|
if (!schemeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await rereadSchemeState(queryClient, {
|
||||||
|
schemeId,
|
||||||
|
includeEditorDraft: true,
|
||||||
|
expectedSchemeVersionId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useAuthMeQuery() {
|
export function useAuthMeQuery() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.authMe,
|
queryKey: queryKeys.authMe,
|
||||||
@@ -151,6 +184,57 @@ export function useDraftStructureQuery(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDraftSeatRecordQuery(
|
||||||
|
schemeId: string | undefined,
|
||||||
|
expectedSchemeVersionId: string | undefined,
|
||||||
|
seatRecordId: string | undefined,
|
||||||
|
enabled = true
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.draftSeatRecord(schemeId, expectedSchemeVersionId, seatRecordId),
|
||||||
|
enabled: Boolean(schemeId && expectedSchemeVersionId && seatRecordId && enabled),
|
||||||
|
queryFn: async () =>
|
||||||
|
apiGet<DraftSeatRecordResponse>(
|
||||||
|
`/api/v1/schemes/${schemeId}/draft/seats/records/${seatRecordId}`,
|
||||||
|
draftReadParams(expectedSchemeVersionId)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDraftSectorRecordQuery(
|
||||||
|
schemeId: string | undefined,
|
||||||
|
expectedSchemeVersionId: string | undefined,
|
||||||
|
sectorRecordId: string | undefined,
|
||||||
|
enabled = true
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.draftSectorRecord(schemeId, expectedSchemeVersionId, sectorRecordId),
|
||||||
|
enabled: Boolean(schemeId && expectedSchemeVersionId && sectorRecordId && enabled),
|
||||||
|
queryFn: async () =>
|
||||||
|
apiGet<DraftSectorRecordResponse>(
|
||||||
|
`/api/v1/schemes/${schemeId}/draft/sectors/records/${sectorRecordId}`,
|
||||||
|
draftReadParams(expectedSchemeVersionId)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDraftGroupRecordQuery(
|
||||||
|
schemeId: string | undefined,
|
||||||
|
expectedSchemeVersionId: string | undefined,
|
||||||
|
groupRecordId: string | undefined,
|
||||||
|
enabled = true
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.draftGroupRecord(schemeId, expectedSchemeVersionId, groupRecordId),
|
||||||
|
enabled: Boolean(schemeId && expectedSchemeVersionId && groupRecordId && enabled),
|
||||||
|
queryFn: async () =>
|
||||||
|
apiGet<DraftGroupRecordResponse>(
|
||||||
|
`/api/v1/schemes/${schemeId}/draft/groups/records/${groupRecordId}`,
|
||||||
|
draftReadParams(expectedSchemeVersionId)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useDraftValidationQuery(
|
export function useDraftValidationQuery(
|
||||||
schemeId: string | undefined,
|
schemeId: string | undefined,
|
||||||
expectedSchemeVersionId: string | undefined,
|
expectedSchemeVersionId: string | undefined,
|
||||||
@@ -399,6 +483,259 @@ export function useRollbackSchemeMutation(schemeId: string | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCreateDraftSectorMutation(schemeId: string | undefined, expectedSchemeVersionId: string | undefined) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (payload: CreateSectorRequest) => {
|
||||||
|
if (!schemeId || !expectedSchemeVersionId) {
|
||||||
|
throw new Error("schemeId and expectedSchemeVersionId are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiPost<CreateSectorResponse>(`/api/v1/schemes/${schemeId}/draft/sectors`, payload, {
|
||||||
|
params: { expected_scheme_version_id: expectedSchemeVersionId }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
if (isRereadRequiredConflict(error)) {
|
||||||
|
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePatchDraftSectorMutation(
|
||||||
|
schemeId: string | undefined,
|
||||||
|
expectedSchemeVersionId: string | undefined
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (params: { sectorRecordId: string; payload: SectorPatchRequest }) => {
|
||||||
|
if (!schemeId || !expectedSchemeVersionId) {
|
||||||
|
throw new Error("schemeId and expectedSchemeVersionId are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiRequest<SectorPatchResponse>({
|
||||||
|
url: `/api/v1/schemes/${schemeId}/draft/sectors/records/${params.sectorRecordId}`,
|
||||||
|
method: "patch",
|
||||||
|
data: params.payload,
|
||||||
|
params: { expected_scheme_version_id: expectedSchemeVersionId }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
if (isRereadRequiredConflict(error)) {
|
||||||
|
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteDraftSectorMutation(
|
||||||
|
schemeId: string | undefined,
|
||||||
|
expectedSchemeVersionId: string | undefined
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (sectorRecordId: string) => {
|
||||||
|
if (!schemeId || !expectedSchemeVersionId) {
|
||||||
|
throw new Error("schemeId and expectedSchemeVersionId are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiDelete<DeleteEntityResponse>(
|
||||||
|
`/api/v1/schemes/${schemeId}/draft/sectors/records/${sectorRecordId}`,
|
||||||
|
{
|
||||||
|
params: { expected_scheme_version_id: expectedSchemeVersionId }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
if (isRereadRequiredConflict(error)) {
|
||||||
|
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateDraftGroupMutation(schemeId: string | undefined, expectedSchemeVersionId: string | undefined) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (payload: CreateGroupRequest) => {
|
||||||
|
if (!schemeId || !expectedSchemeVersionId) {
|
||||||
|
throw new Error("schemeId and expectedSchemeVersionId are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiPost<CreateGroupResponse>(`/api/v1/schemes/${schemeId}/draft/groups`, payload, {
|
||||||
|
params: { expected_scheme_version_id: expectedSchemeVersionId }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
if (isRereadRequiredConflict(error)) {
|
||||||
|
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePatchDraftGroupMutation(
|
||||||
|
schemeId: string | undefined,
|
||||||
|
expectedSchemeVersionId: string | undefined
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (params: { groupRecordId: string; payload: GroupPatchRequest }) => {
|
||||||
|
if (!schemeId || !expectedSchemeVersionId) {
|
||||||
|
throw new Error("schemeId and expectedSchemeVersionId are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiRequest<GroupPatchResponse>({
|
||||||
|
url: `/api/v1/schemes/${schemeId}/draft/groups/records/${params.groupRecordId}`,
|
||||||
|
method: "patch",
|
||||||
|
data: params.payload,
|
||||||
|
params: { expected_scheme_version_id: expectedSchemeVersionId }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
if (isRereadRequiredConflict(error)) {
|
||||||
|
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteDraftGroupMutation(
|
||||||
|
schemeId: string | undefined,
|
||||||
|
expectedSchemeVersionId: string | undefined
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (groupRecordId: string) => {
|
||||||
|
if (!schemeId || !expectedSchemeVersionId) {
|
||||||
|
throw new Error("schemeId and expectedSchemeVersionId are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiDelete<DeleteEntityResponse>(
|
||||||
|
`/api/v1/schemes/${schemeId}/draft/groups/records/${groupRecordId}`,
|
||||||
|
{
|
||||||
|
params: { expected_scheme_version_id: expectedSchemeVersionId }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
if (isRereadRequiredConflict(error)) {
|
||||||
|
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePatchDraftSeatMutation(schemeId: string | undefined, expectedSchemeVersionId: string | undefined) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (params: { seatRecordId: string; payload: SeatPatchRequest }) => {
|
||||||
|
if (!schemeId || !expectedSchemeVersionId) {
|
||||||
|
throw new Error("schemeId and expectedSchemeVersionId are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiRequest<SeatPatchResponse>({
|
||||||
|
url: `/api/v1/schemes/${schemeId}/draft/seats/records/${params.seatRecordId}`,
|
||||||
|
method: "patch",
|
||||||
|
data: params.payload,
|
||||||
|
params: { expected_scheme_version_id: expectedSchemeVersionId }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
if (isRereadRequiredConflict(error)) {
|
||||||
|
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBulkPatchDraftSeatsMutation(
|
||||||
|
schemeId: string | undefined,
|
||||||
|
expectedSchemeVersionId: string | undefined
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (payload: BulkSeatPatchRequest) => {
|
||||||
|
if (!schemeId || !expectedSchemeVersionId) {
|
||||||
|
throw new Error("schemeId and expectedSchemeVersionId are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiPost<BulkSeatPatchResponse>(`/api/v1/schemes/${schemeId}/draft/seats/bulk`, payload, {
|
||||||
|
params: { expected_scheme_version_id: expectedSchemeVersionId }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
if (isRereadRequiredConflict(error)) {
|
||||||
|
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRepairDraftReferencesMutation(
|
||||||
|
schemeId: string | undefined,
|
||||||
|
expectedSchemeVersionId: string | undefined
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!schemeId || !expectedSchemeVersionId) {
|
||||||
|
throw new Error("schemeId and expectedSchemeVersionId are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiPost<RepairReferencesResponse>(
|
||||||
|
`/api/v1/schemes/${schemeId}/draft/repair-references`,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
params: { expected_scheme_version_id: expectedSchemeVersionId }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
if (isRereadRequiredConflict(error)) {
|
||||||
|
await handleEditorMutationReread(queryClient, schemeId, expectedSchemeVersionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useCreatePricingCategoryMutation(schemeId: string | undefined) {
|
export function useCreatePricingCategoryMutation(schemeId: string | undefined) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ export const queryKeys = {
|
|||||||
["scheme-draft-summary", schemeId, expectedVersionId] as const,
|
["scheme-draft-summary", schemeId, expectedVersionId] as const,
|
||||||
schemeDraftStructure: (schemeId: string | undefined, expectedVersionId: string | undefined) =>
|
schemeDraftStructure: (schemeId: string | undefined, expectedVersionId: string | undefined) =>
|
||||||
["scheme-draft-structure", schemeId, expectedVersionId] as const,
|
["scheme-draft-structure", schemeId, expectedVersionId] as const,
|
||||||
|
draftSeatRecord: (schemeId: string | undefined, expectedVersionId: string | undefined, seatRecordId: string | undefined) =>
|
||||||
|
["draft-seat-record", schemeId, expectedVersionId, seatRecordId] as const,
|
||||||
|
draftSectorRecord: (
|
||||||
|
schemeId: string | undefined,
|
||||||
|
expectedVersionId: string | undefined,
|
||||||
|
sectorRecordId: string | undefined
|
||||||
|
) => ["draft-sector-record", schemeId, expectedVersionId, sectorRecordId] as const,
|
||||||
|
draftGroupRecord: (
|
||||||
|
schemeId: string | undefined,
|
||||||
|
expectedVersionId: string | undefined,
|
||||||
|
groupRecordId: string | undefined
|
||||||
|
) => ["draft-group-record", schemeId, expectedVersionId, groupRecordId] as const,
|
||||||
schemeDraftValidation: (schemeId: string | undefined, expectedVersionId: string | undefined) =>
|
schemeDraftValidation: (schemeId: string | undefined, expectedVersionId: string | undefined) =>
|
||||||
["scheme-draft-validation", schemeId, expectedVersionId] as const,
|
["scheme-draft-validation", schemeId, expectedVersionId] as const,
|
||||||
schemeDraftComparePreview: (schemeId: string | undefined, expectedVersionId: string | undefined) =>
|
schemeDraftComparePreview: (schemeId: string | undefined, expectedVersionId: string | undefined) =>
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ export async function rereadSchemeState(queryClient: QueryClient, options: Rerea
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.schemeDraftStructure(schemeId, expectedSchemeVersionId ?? undefined)
|
queryKey: queryKeys.schemeDraftStructure(schemeId, expectedSchemeVersionId ?? undefined)
|
||||||
}),
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["draft-seat-record", schemeId]
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["draft-sector-record", schemeId]
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["draft-group-record", schemeId]
|
||||||
|
}),
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.schemeDraftValidation(schemeId, expectedSchemeVersionId ?? undefined)
|
queryKey: queryKeys.schemeDraftValidation(schemeId, expectedSchemeVersionId ?? undefined)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -458,6 +458,148 @@ select {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-workspace-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr) minmax(320px, 380px);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pane {
|
||||||
|
min-height: 720px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pane-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-search-input {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-structure-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 560px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-structure-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid #dbe3f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-structure-row.selected {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-structure-row-title {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-structure-row-meta {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-empty-state {
|
||||||
|
border: 1px dashed #d1d5db;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-viewer-surface {
|
||||||
|
min-height: 620px;
|
||||||
|
border: 1px solid #dbe3f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px),
|
||||||
|
linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px),
|
||||||
|
#f8fafc;
|
||||||
|
background-size: 24px 24px;
|
||||||
|
position: relative;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-viewer-surface:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-viewer-host {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-viewer-host svg {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-viewer-host .editor-svg-selected {
|
||||||
|
filter: drop-shadow(0 0 8px rgba(59, 130, 246, 0.65));
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-viewer-host .editor-svg-selected,
|
||||||
|
.editor-viewer-host .editor-svg-selected * {
|
||||||
|
stroke: #2563eb !important;
|
||||||
|
stroke-width: 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-inspector-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-category-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-chip {
|
||||||
|
border: 1px solid #dbe3f0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-rule-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-rule-card {
|
||||||
|
border: 1px solid #dbe3f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.viewer-grid {
|
.viewer-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 2fr) minmax(340px, 1fr);
|
grid-template-columns: minmax(0, 2fr) minmax(340px, 1fr);
|
||||||
@@ -498,6 +640,10 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
|
.editor-workspace-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.viewer-grid {
|
.viewer-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
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 { useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
getApiErrorMessage,
|
getApiErrorMessage,
|
||||||
|
useDisplaySvgMetaQuery,
|
||||||
|
useDisplaySvgQuery,
|
||||||
useDraftComparePreviewQuery,
|
useDraftComparePreviewQuery,
|
||||||
useDraftStructureQuery,
|
useDraftStructureQuery,
|
||||||
useDraftSummaryQuery,
|
useDraftSummaryQuery,
|
||||||
@@ -10,20 +12,17 @@ import {
|
|||||||
useSchemeCurrentQuery,
|
useSchemeCurrentQuery,
|
||||||
useSchemeEditorContextQuery
|
useSchemeEditorContextQuery
|
||||||
} from "../../api/queries";
|
} from "../../api/queries";
|
||||||
import { normalizeApiError, isRereadRequiredConflict } from "../../api/errors";
|
import { isRereadRequiredConflict, normalizeApiError } from "../../api/errors";
|
||||||
import { rereadSchemeState } from "../../api/reread";
|
import { rereadSchemeState } from "../../api/reread";
|
||||||
import { useAuthState } from "../../shared/auth/AuthProvider";
|
import { useAuthState } from "../../shared/auth/AuthProvider";
|
||||||
import { localizeRecommendedAction, localizeRole, localizeStatus } from "../../shared/lib/formatters";
|
import { localizeRecommendedAction, localizeRole, localizeStatus } from "../../shared/lib/formatters";
|
||||||
import { ApiErrorView } from "../../shared/ui/ApiErrorView";
|
import { ApiErrorView } from "../../shared/ui/ApiErrorView";
|
||||||
import { StatCard } from "../../shared/ui/StatCard";
|
import { StatCard } from "../../shared/ui/StatCard";
|
||||||
import type {
|
import type { DraftComparePreviewResponse, DraftStructureResponse, DraftValidationResponse } from "../../shared/types/api";
|
||||||
DraftComparePreviewResponse,
|
import { EditorInspectorPane } from "../editor/EditorInspectorPane";
|
||||||
DraftStructureGroupItem,
|
import { EditorStructurePane } from "../editor/EditorStructurePane";
|
||||||
DraftStructureResponse,
|
import { EditorViewerPane } from "../editor/EditorViewerPane";
|
||||||
DraftStructureSeatItem,
|
import { useEditorSelection } from "../editor/useEditorSelection";
|
||||||
DraftStructureSectorItem,
|
|
||||||
DraftValidationResponse
|
|
||||||
} from "../../shared/types/api";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
schemeId: string;
|
schemeId: string;
|
||||||
@@ -50,18 +49,6 @@ function prettyJson(value: unknown): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function seatBusinessId(item: DraftStructureSeatItem): string {
|
|
||||||
return item.seat_id || item.element_id || "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
function sectorBusinessId(item: DraftStructureSectorItem): string {
|
|
||||||
return item.sector_id || item.element_id || "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupBusinessId(item: DraftStructureGroupItem): string {
|
|
||||||
return item.group_id || item.element_id || "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
function issuesList(payload: DraftValidationResponse | undefined) {
|
function issuesList(payload: DraftValidationResponse | undefined) {
|
||||||
return {
|
return {
|
||||||
issues: Array.isArray(payload?.issues) ? payload.issues : [],
|
issues: Array.isArray(payload?.issues) ? payload.issues : [],
|
||||||
@@ -109,6 +96,8 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
|||||||
const structureQuery = useDraftStructureQuery(schemeId, expectedSchemeVersionId, editorReady);
|
const structureQuery = useDraftStructureQuery(schemeId, expectedSchemeVersionId, editorReady);
|
||||||
const validationQuery = useDraftValidationQuery(schemeId, expectedSchemeVersionId, editorReady);
|
const validationQuery = useDraftValidationQuery(schemeId, expectedSchemeVersionId, editorReady);
|
||||||
const compareQuery = useDraftComparePreviewQuery(schemeId, expectedSchemeVersionId, editorReady);
|
const compareQuery = useDraftComparePreviewQuery(schemeId, expectedSchemeVersionId, editorReady);
|
||||||
|
const displayMetaQuery = useDisplaySvgMetaQuery(schemeId);
|
||||||
|
const displaySvgQuery = useDisplaySvgQuery(schemeId);
|
||||||
|
|
||||||
const [autoEnsureAttemptedFor, setAutoEnsureAttemptedFor] = useState<string | null>(null);
|
const [autoEnsureAttemptedFor, setAutoEnsureAttemptedFor] = useState<string | null>(null);
|
||||||
const [statusNote, setStatusNote] = useState<string | null>(null);
|
const [statusNote, setStatusNote] = useState<string | null>(null);
|
||||||
@@ -120,15 +109,15 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setAutoEnsureAttemptedFor(currentVersionId);
|
setAutoEnsureAttemptedFor(currentVersionId);
|
||||||
setStatusNote("Текущая версия опубликована. Выполняется `draft/ensure` перед чтением editor shell.");
|
setStatusNote("Current version is published. Running `draft/ensure` before opening the editable workspace.");
|
||||||
ensureDraftMutation.mutate({ expectedCurrentSchemeVersionId: currentVersionId });
|
ensureDraftMutation.mutate({ expectedCurrentSchemeVersionId: currentVersionId });
|
||||||
}, [autoEnsureAttemptedFor, currentVersionId, ensureDraftMutation, ensureDraftMutation.isPending, shouldEnsureDraft]);
|
}, [autoEnsureAttemptedFor, currentVersionId, ensureDraftMutation, ensureDraftMutation.isPending, shouldEnsureDraft]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ensureDraftMutation.data?.created) {
|
if (ensureDraftMutation.data?.created) {
|
||||||
setStatusNote("Backend создал новый draft. Выполняется controlled reread current/context и draft read models.");
|
setStatusNote("Backend created a new draft. Current/context and draft read models were reread in a controlled way.");
|
||||||
} else if (ensureDraftMutation.data && ensureDraftMutation.data.created === false) {
|
} else if (ensureDraftMutation.data && ensureDraftMutation.data.created === false) {
|
||||||
setStatusNote("Current draft уже был активен. Editor shell работает в нём.");
|
setStatusNote("Current draft is already active. Workspace uses the existing draft version.");
|
||||||
}
|
}
|
||||||
}, [ensureDraftMutation.data]);
|
}, [ensureDraftMutation.data]);
|
||||||
|
|
||||||
@@ -163,7 +152,7 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLastConflictSignature(signature);
|
setLastConflictSignature(signature);
|
||||||
setStatusNote("Backend вернул stale conflict. Editor shell перечитывает current/context и draft read models.");
|
setStatusNote("Backend returned a stale conflict. Workspace is rereading current/context and draft read models.");
|
||||||
|
|
||||||
void rereadSchemeState(queryClient, {
|
void rereadSchemeState(queryClient, {
|
||||||
schemeId,
|
schemeId,
|
||||||
@@ -187,6 +176,11 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
|||||||
const structure = structureLists(structureQuery.data);
|
const structure = structureLists(structureQuery.data);
|
||||||
const validationState = issuesList(validationQuery.data);
|
const validationState = issuesList(validationQuery.data);
|
||||||
const compareWithoutBaseline = compareBaselineMissing(compareQuery.data);
|
const compareWithoutBaseline = compareBaselineMissing(compareQuery.data);
|
||||||
|
const selectionState = useEditorSelection({
|
||||||
|
seats: structure.seats,
|
||||||
|
sectors: structure.sectors,
|
||||||
|
groups: structure.groups
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="detail-grid">
|
<div className="detail-grid">
|
||||||
@@ -195,11 +189,11 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<h2>Редактор</h2>
|
<h2>Редактор</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Editor shell читает только backend read models: `draft/summary`, `draft/structure`, `draft/validation`,
|
Real MVP workspace: structure pane, viewer pane, inspector pane, seat/sector/group editing, and selected
|
||||||
`draft/compare-preview`.
|
seat/group pricing actions.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="badge">{editorReady ? "Draft shell active" : "Entry in progress"}</div>
|
<div className="badge">{editorReady ? "Draft workspace active" : "Entry in progress"}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="stats-grid">
|
<div className="stats-grid">
|
||||||
@@ -222,9 +216,9 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
|||||||
{!editorReady && shouldEnsureDraft ? (
|
{!editorReady && shouldEnsureDraft ? (
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="state-block state-block-warning">
|
<div className="state-block state-block-warning">
|
||||||
<strong>Требуется подготовка draft.</strong>
|
<strong>Draft preparation required.</strong>
|
||||||
<div className="muted">
|
<div className="muted">
|
||||||
Пока `draft/ensure` не завершится и `current`/`editor/context` не будут перечитаны, editor shell не читает draft models.
|
Workspace waits for `draft/ensure` and a controlled reread before loading editable draft state.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,9 +227,9 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
|||||||
{!editorReady && !shouldEnsureDraft ? (
|
{!editorReady && !shouldEnsureDraft ? (
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="state-block state-block-warning">
|
<div className="state-block state-block-warning">
|
||||||
<strong>Editor shell пока недоступен.</strong>
|
<strong>Editor workspace is not ready.</strong>
|
||||||
<div className="muted">
|
<div className="muted">
|
||||||
Проверь `editor/context`: current_is_draft={boolText(contextQuery.data?.current_is_draft)}, create_draft_available=
|
Check `editor/context`: current_is_draft={boolText(contextQuery.data?.current_is_draft)}, create_draft_available=
|
||||||
{boolText(contextQuery.data?.create_draft_available)}, recommended_action=
|
{boolText(contextQuery.data?.create_draft_available)}, recommended_action=
|
||||||
{localizeRecommendedAction(contextQuery.data?.recommended_action)}.
|
{localizeRecommendedAction(contextQuery.data?.recommended_action)}.
|
||||||
</div>
|
</div>
|
||||||
@@ -247,7 +241,8 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
|||||||
<ApiErrorView title="Ошибка подготовки draft" message={getApiErrorMessage(ensureDraftMutation.error)} />
|
<ApiErrorView title="Ошибка подготовки draft" message={getApiErrorMessage(ensureDraftMutation.error)} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{editorReady && (summaryQuery.isLoading || structureQuery.isLoading || validationQuery.isLoading || compareQuery.isLoading) ? (
|
{editorReady &&
|
||||||
|
(summaryQuery.isLoading || structureQuery.isLoading || validationQuery.isLoading || compareQuery.isLoading) ? (
|
||||||
<div className="panel">Загрузка draft read models...</div>
|
<div className="panel">Загрузка draft read models...</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -267,102 +262,82 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
|||||||
<ApiErrorView title="Ошибка чтения draft compare-preview" message={getApiErrorMessage(compareQuery.error)} />
|
<ApiErrorView title="Ошибка чтения draft compare-preview" message={getApiErrorMessage(compareQuery.error)} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{editorReady && summaryQuery.data ? (
|
{selectionState.staleSelectionMessage ? (
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<h3>Summary</h3>
|
<div className="state-block state-block-warning">
|
||||||
<div className="stats-grid">
|
<strong>Selection was cleared after reread.</strong>
|
||||||
<StatCard label="draft version id" value={summaryQuery.data.scheme_version_id ?? "—"} />
|
<div className="muted">{selectionState.staleSelectionMessage}</div>
|
||||||
<StatCard label="version number" value={summaryQuery.data.version_number ?? "—"} />
|
<div className="toolbar">
|
||||||
<StatCard label="draft status" value={localizeStatus(summaryQuery.data.status)} />
|
<button type="button" className="btn btn-secondary" onClick={selectionState.clearStaleSelectionMessage}>
|
||||||
<StatCard label="publish readiness" value={summaryQuery.data.publish_readiness ? "есть" : "—"} />
|
Dismiss
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
<div className="stats-grid" style={{ marginTop: 10 }}>
|
|
||||||
<StatCard label="total seats" value={summaryQuery.data.total_seats ?? "—"} />
|
|
||||||
<StatCard label="total groups" value={summaryQuery.data.total_groups ?? "—"} />
|
|
||||||
<StatCard label="total sectors" value={summaryQuery.data.total_sectors ?? "—"} />
|
|
||||||
<StatCard label="readiness summary" value={summaryQuery.data.readiness_summary ? "есть" : "—"} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="code-box" style={{ marginTop: 12 }}>
|
|
||||||
{prettyJson({
|
|
||||||
readiness_summary: summaryQuery.data.readiness_summary ?? null,
|
|
||||||
aggregate_counts: summaryQuery.data.aggregate_counts ?? null,
|
|
||||||
validation_summary: summaryQuery.data.validation_summary ?? null,
|
|
||||||
structure_diff_summary: summaryQuery.data.structure_diff_summary ?? null,
|
|
||||||
publish_readiness: summaryQuery.data.publish_readiness ?? null
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{editorReady && structureQuery.data ? (
|
{editorReady && summaryQuery.data ? (
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<h3>Structure</h3>
|
|
||||||
<div className="stats-grid">
|
<div className="stats-grid">
|
||||||
<StatCard label="sectors" value={structure.sectors.length} />
|
<StatCard label="draft version id" value={summaryQuery.data.scheme_version_id ?? "—"} />
|
||||||
<StatCard label="groups" value={structure.groups.length} />
|
<StatCard label="draft status" value={localizeStatus(summaryQuery.data.status)} />
|
||||||
<StatCard label="seats" value={structure.seats.length} />
|
<StatCard label="seats" value={summaryQuery.data.total_seats ?? 0} />
|
||||||
<StatCard label="scheme_version_id" value={structureQuery.data.scheme_version_id ?? "—"} />
|
<StatCard label="groups" value={summaryQuery.data.total_groups ?? 0} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="stats-grid" style={{ marginTop: 10 }}>
|
||||||
<div className="table-wrap" style={{ marginTop: 12 }}>
|
<StatCard label="sectors" value={summaryQuery.data.total_sectors ?? 0} />
|
||||||
<table className="data-table">
|
<StatCard label="validation issues" value={validationState.issues.length} />
|
||||||
<thead>
|
<StatCard label="validation warnings" value={validationState.warnings.length} />
|
||||||
<tr>
|
<StatCard label="compare baseline" value={compareWithoutBaseline ? "none" : "available"} />
|
||||||
<th>Entity</th>
|
|
||||||
<th>Business ID</th>
|
|
||||||
<th>element_id</th>
|
|
||||||
<th>Relations</th>
|
|
||||||
<th>Details</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{structure.sectors.map((item, index) => (
|
|
||||||
<tr key={`sector-${item.sector_id ?? item.element_id ?? index}`}>
|
|
||||||
<td>sector</td>
|
|
||||||
<td>{sectorBusinessId(item)}</td>
|
|
||||||
<td>{valueText(item.element_id)}</td>
|
|
||||||
<td>—</td>
|
|
||||||
<td>{valueText(item.name)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{structure.groups.map((item, index) => (
|
|
||||||
<tr key={`group-${item.group_id ?? item.element_id ?? index}`}>
|
|
||||||
<td>group</td>
|
|
||||||
<td>{groupBusinessId(item)}</td>
|
|
||||||
<td>{valueText(item.element_id)}</td>
|
|
||||||
<td>sector_id={valueText(item.sector_id)}</td>
|
|
||||||
<td>{valueText(item.name)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{structure.seats.map((item, index) => (
|
|
||||||
<tr key={`seat-${item.seat_id ?? item.element_id ?? index}`}>
|
|
||||||
<td>seat</td>
|
|
||||||
<td>{seatBusinessId(item)}</td>
|
|
||||||
<td>{valueText(item.element_id)}</td>
|
|
||||||
<td>
|
|
||||||
sector_id={valueText(item.sector_id)} / group_id={valueText(item.group_id)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
row={valueText(item.row_label)} / seat={valueText(item.seat_number)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{structure.sectors.length === 0 && structure.groups.length === 0 && structure.seats.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5}>Draft structure returned empty lists.</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{editorReady ? (
|
||||||
|
<div className="editor-workspace-grid">
|
||||||
|
<EditorStructurePane
|
||||||
|
entityIndex={selectionState.entityIndex}
|
||||||
|
selection={selectionState.selection}
|
||||||
|
onSelectEntity={selectionState.selectEntity}
|
||||||
|
onClearSelection={selectionState.clearSelection}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{displayMetaQuery.isError || displaySvgQuery.isError ? (
|
||||||
|
<div className="panel editor-pane">
|
||||||
|
<ApiErrorView
|
||||||
|
title="Viewer unavailable"
|
||||||
|
message={getApiErrorMessage(displayMetaQuery.error ?? displaySvgQuery.error)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : displayMetaQuery.isLoading || displaySvgQuery.isLoading ? (
|
||||||
|
<div className="panel editor-pane">Загрузка viewer...</div>
|
||||||
|
) : displaySvgQuery.data ? (
|
||||||
|
<EditorViewerPane
|
||||||
|
svgMarkup={displaySvgQuery.data}
|
||||||
|
meta={displayMetaQuery.data}
|
||||||
|
entityIndex={selectionState.entityIndex}
|
||||||
|
selection={selectionState.selection}
|
||||||
|
onSelectFromViewer={selectionState.selectSelection}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="panel editor-pane">
|
||||||
|
<div className="editor-empty-state">
|
||||||
|
<strong>Display SVG is empty.</strong>
|
||||||
|
<div className="muted">Viewer pane remains controlled even when passthrough SVG is unavailable.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EditorInspectorPane
|
||||||
|
schemeId={schemeId}
|
||||||
|
expectedSchemeVersionId={expectedSchemeVersionId}
|
||||||
|
selection={selectionState.selection}
|
||||||
|
selectedEntity={selectionState.selectedEntity}
|
||||||
|
onClearSelection={selectionState.clearSelection}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{editorReady && validationQuery.data ? (
|
{editorReady && validationQuery.data ? (
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<h3>Validation</h3>
|
<h3>Validation</h3>
|
||||||
@@ -375,8 +350,8 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
|||||||
|
|
||||||
{validationState.issues.length === 0 && validationState.warnings.length === 0 ? (
|
{validationState.issues.length === 0 && validationState.warnings.length === 0 ? (
|
||||||
<div className="state-block state-block-success" style={{ marginTop: 12 }}>
|
<div className="state-block state-block-success" style={{ marginTop: 12 }}>
|
||||||
<strong>Validation issues не найдены.</strong>
|
<strong>Validation issues not found.</strong>
|
||||||
<div className="muted">Пустой набор warnings/issues не считается ошибкой интеграции.</div>
|
<div className="muted">Empty validation issues/warnings is a controlled state.</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -391,12 +366,6 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
|||||||
{prettyJson(validationState.warnings)}
|
{prettyJson(validationState.warnings)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{validationQuery.data.indicators ? (
|
|
||||||
<div className="code-box" style={{ marginTop: 12 }}>
|
|
||||||
{prettyJson(validationQuery.data.indicators)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -412,12 +381,12 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
|||||||
|
|
||||||
{compareWithoutBaseline ? (
|
{compareWithoutBaseline ? (
|
||||||
<div className="state-block state-block-info" style={{ marginTop: 12 }}>
|
<div className="state-block state-block-info" style={{ marginTop: 12 }}>
|
||||||
<strong>Baseline отсутствует.</strong>
|
<strong>Baseline is absent.</strong>
|
||||||
<div className="muted">Для fresh scheme compare-preview может вернуться без baseline, это controlled empty state, а не ошибка.</div>
|
<div className="muted">For fresh schemes compare-preview without baseline stays a controlled empty state.</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="code-box" style={{ marginTop: 12 }}>
|
<div className="code-box" style={{ marginTop: 12 }}>
|
||||||
{prettyJson(compareQuery.data)}
|
{prettyJson(compareQuery.data.summary ?? compareQuery.data.diff_summary ?? compareQuery.data)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -434,6 +403,9 @@ export function SchemeEditorTab({ schemeId }: Props) {
|
|||||||
<div><strong>current_is_draft:</strong> {boolText(contextQuery.data?.current_is_draft)}</div>
|
<div><strong>current_is_draft:</strong> {boolText(contextQuery.data?.current_is_draft)}</div>
|
||||||
<div><strong>recommended_action:</strong> {localizeRecommendedAction(contextQuery.data?.recommended_action)}</div>
|
<div><strong>recommended_action:</strong> {localizeRecommendedAction(contextQuery.data?.recommended_action)}</div>
|
||||||
<div><strong>last conflict code:</strong> {valueText(draftConflictError ? normalizeApiError(draftConflictError).code : null)}</div>
|
<div><strong>last conflict code:</strong> {valueText(draftConflictError ? normalizeApiError(draftConflictError).code : null)}</div>
|
||||||
|
<div><strong>selectedEntityType:</strong> {valueText(selectionState.selection.selectedEntityType)}</div>
|
||||||
|
<div><strong>selectedBusinessId:</strong> {valueText(selectionState.selection.selectedBusinessId)}</div>
|
||||||
|
<div><strong>selectedElementId:</strong> {valueText(selectionState.selection.selectedElementId)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -297,34 +297,58 @@ export type DraftSummaryResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type DraftStructureSectorItem = {
|
export type DraftStructureSectorItem = {
|
||||||
|
sector_record_id?: string;
|
||||||
|
scheme_id?: string;
|
||||||
|
scheme_version_id?: string;
|
||||||
sector_id?: string | null;
|
sector_id?: string | null;
|
||||||
element_id?: string | null;
|
element_id?: string | null;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
|
classes_raw?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DraftStructureGroupItem = {
|
export type DraftStructureGroupItem = {
|
||||||
|
group_record_id?: string;
|
||||||
|
scheme_id?: string;
|
||||||
|
scheme_version_id?: string;
|
||||||
group_id?: string | null;
|
group_id?: string | null;
|
||||||
element_id?: string | null;
|
element_id?: string | null;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
sector_id?: string | null;
|
classes_raw?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DraftStructureSeatItem = {
|
export type DraftStructureSeatItem = {
|
||||||
|
seat_record_id?: string;
|
||||||
|
scheme_id?: string;
|
||||||
|
scheme_version_id?: string;
|
||||||
seat_id?: string | null;
|
seat_id?: string | null;
|
||||||
element_id?: string | null;
|
element_id?: string | null;
|
||||||
sector_id?: string | null;
|
sector_id?: string | null;
|
||||||
group_id?: string | null;
|
group_id?: string | null;
|
||||||
row_label?: string | null;
|
row_label?: string | null;
|
||||||
seat_number?: string | null;
|
seat_number?: string | null;
|
||||||
|
tag?: string | null;
|
||||||
|
classes_raw?: string | null;
|
||||||
|
x?: number | null;
|
||||||
|
y?: number | null;
|
||||||
|
cx?: number | null;
|
||||||
|
cy?: number | null;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
created_at?: string | null;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DraftStructureResponse = {
|
export type DraftStructureResponse = {
|
||||||
scheme_id?: string;
|
scheme_id?: string;
|
||||||
scheme_version_id?: string;
|
scheme_version_id?: string;
|
||||||
version_number?: number | null;
|
status?: string | null;
|
||||||
|
total_seats?: number | null;
|
||||||
|
total_sectors?: number | null;
|
||||||
|
total_groups?: number | null;
|
||||||
sectors?: DraftStructureSectorItem[];
|
sectors?: DraftStructureSectorItem[];
|
||||||
groups?: DraftStructureGroupItem[];
|
groups?: DraftStructureGroupItem[];
|
||||||
seats?: DraftStructureSeatItem[];
|
seats?: DraftStructureSeatItem[];
|
||||||
@@ -352,9 +376,134 @@ export type DraftValidationResponse = {
|
|||||||
export type DraftComparePreviewResponse = {
|
export type DraftComparePreviewResponse = {
|
||||||
scheme_id?: string;
|
scheme_id?: string;
|
||||||
scheme_version_id?: string;
|
scheme_version_id?: string;
|
||||||
|
draft_scheme_version_id?: string;
|
||||||
baseline_scheme_version_id?: string | null;
|
baseline_scheme_version_id?: string | null;
|
||||||
baseline_version_number?: number | null;
|
baseline_version_number?: number | null;
|
||||||
has_structure_changes?: boolean | null;
|
has_structure_changes?: boolean | null;
|
||||||
|
summary?: Record<string, unknown> | null;
|
||||||
|
sectors?: Array<Record<string, unknown>>;
|
||||||
|
groups?: Array<Record<string, unknown>>;
|
||||||
|
seats?: Array<Record<string, unknown>>;
|
||||||
diff_summary?: Record<string, unknown> | null;
|
diff_summary?: Record<string, unknown> | null;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DraftSeatRecordResponse = DraftStructureSeatItem;
|
||||||
|
export type DraftSectorRecordResponse = DraftStructureSectorItem;
|
||||||
|
export type DraftGroupRecordResponse = DraftStructureGroupItem;
|
||||||
|
|
||||||
|
export type SeatPatchRequest = {
|
||||||
|
seat_id?: string | null;
|
||||||
|
sector_id?: string | null;
|
||||||
|
group_id?: string | null;
|
||||||
|
row_label?: string | null;
|
||||||
|
seat_number?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SeatPatchResponse = {
|
||||||
|
scheme_id?: string;
|
||||||
|
scheme_version_id?: string;
|
||||||
|
element_id?: string | null;
|
||||||
|
seat_id?: string | null;
|
||||||
|
sector_id?: string | null;
|
||||||
|
group_id?: string | null;
|
||||||
|
row_label?: string | null;
|
||||||
|
seat_number?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BulkSeatPatchItemRequest = SeatPatchRequest & {
|
||||||
|
seat_record_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BulkSeatPatchRequest = {
|
||||||
|
items: BulkSeatPatchItemRequest[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BulkSeatPatchResultItem = {
|
||||||
|
seat_record_id?: string;
|
||||||
|
updated_seat_id?: string | null;
|
||||||
|
sector_id?: string | null;
|
||||||
|
group_id?: string | null;
|
||||||
|
row_label?: string | null;
|
||||||
|
seat_number?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BulkSeatPatchResponse = {
|
||||||
|
scheme_id?: string;
|
||||||
|
scheme_version_id?: string;
|
||||||
|
updated_count?: number;
|
||||||
|
items?: BulkSeatPatchResultItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateSectorRequest = {
|
||||||
|
element_id?: string | null;
|
||||||
|
sector_id: string;
|
||||||
|
name?: string | null;
|
||||||
|
classes_raw?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateSectorResponse = {
|
||||||
|
scheme_id?: string;
|
||||||
|
scheme_version_id?: string;
|
||||||
|
sector_record_id?: string;
|
||||||
|
element_id?: string | null;
|
||||||
|
sector_id?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SectorPatchRequest = {
|
||||||
|
sector_id?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SectorPatchResponse = {
|
||||||
|
scheme_id?: string;
|
||||||
|
scheme_version_id?: string;
|
||||||
|
element_id?: string | null;
|
||||||
|
sector_id?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateGroupRequest = {
|
||||||
|
element_id?: string | null;
|
||||||
|
group_id: string;
|
||||||
|
name?: string | null;
|
||||||
|
classes_raw?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateGroupResponse = {
|
||||||
|
scheme_id?: string;
|
||||||
|
scheme_version_id?: string;
|
||||||
|
group_record_id?: string;
|
||||||
|
element_id?: string | null;
|
||||||
|
group_id?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GroupPatchRequest = {
|
||||||
|
group_id?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GroupPatchResponse = {
|
||||||
|
scheme_id?: string;
|
||||||
|
scheme_version_id?: string;
|
||||||
|
element_id?: string | null;
|
||||||
|
group_id?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteEntityResponse = {
|
||||||
|
scheme_id?: string;
|
||||||
|
scheme_version_id?: string;
|
||||||
|
deleted?: boolean;
|
||||||
|
record_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RepairReferencesResponse = {
|
||||||
|
scheme_id?: string;
|
||||||
|
scheme_version_id?: string;
|
||||||
|
repaired_sector_refs_count?: number;
|
||||||
|
repaired_group_refs_count?: number;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user