demo 20-4-2026
This commit is contained in:
@@ -11,7 +11,7 @@ import {
|
||||
Section,
|
||||
SectionSubmission,
|
||||
} from "@/api/sections";
|
||||
import { DEFAULT_BACKGROUND_LAYER_VISIBILITY } from "@/lib/backgroundLayers";
|
||||
import { HIDDEN_BACKGROUND_LAYER_VISIBILITY } from "@/lib/backgroundLayers";
|
||||
import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState";
|
||||
|
||||
type SubmissionStatusFilter = "pending" | "all";
|
||||
@@ -37,6 +37,11 @@ const EMPTY_FEATURE_COLLECTION: FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
};
|
||||
const REVIEW_BACKGROUND_LAYER_VISIBILITY = {
|
||||
...HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||
"bg-countries-fill": true,
|
||||
"bg-country-borders-line": true,
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<SectionSubmission["status"], string> = {
|
||||
pending: "Chờ duyệt",
|
||||
@@ -54,6 +59,7 @@ export default function SubmittedPage() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [reviewingSubmissionId, setReviewingSubmissionId] = useState<string | null>(null);
|
||||
const [selectedPreviewFeatureId, setSelectedPreviewFeatureId] = useState<string | number | null>(null);
|
||||
const [isJsonPopupOpen, setIsJsonPopupOpen] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
@@ -130,6 +136,7 @@ export default function SubmittedPage() {
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedPreviewFeatureId(null);
|
||||
setIsJsonPopupOpen(false);
|
||||
}, [selectedRow?.submission.id]);
|
||||
|
||||
const handleReview = async (action: ReviewAction) => {
|
||||
@@ -336,6 +343,16 @@ export default function SubmittedPage() {
|
||||
<Info label="Reviewed at" value={formatDateTime(selectedRow.submission.reviewed_at)} />
|
||||
</div>
|
||||
|
||||
<div style={styles.dataActions}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsJsonPopupOpen(true)}
|
||||
style={styles.secondaryButton}
|
||||
>
|
||||
View commit JSON
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedRow.submission.review_note ? (
|
||||
<div style={styles.noteBox}>
|
||||
<strong>Review note</strong>
|
||||
@@ -351,14 +368,16 @@ export default function SubmittedPage() {
|
||||
<div style={styles.mapPreview}>
|
||||
<Map
|
||||
key={selectedRow.submission.id}
|
||||
mode="idle"
|
||||
mode="select"
|
||||
draft={selectedFeatureCollection}
|
||||
backgroundVisibility={DEFAULT_BACKGROUND_LAYER_VISIBILITY}
|
||||
backgroundVisibility={REVIEW_BACKGROUND_LAYER_VISIBILITY}
|
||||
selectedFeatureId={selectedPreviewFeatureId}
|
||||
onSelectFeatureId={setSelectedPreviewFeatureId}
|
||||
allowGeometryEditing={false}
|
||||
respectBindingFilter={false}
|
||||
height="420px"
|
||||
fitToDraftBounds
|
||||
fitBoundsKey={selectedRow.submission.id}
|
||||
/>
|
||||
</div>
|
||||
<div style={styles.featurePanel}>
|
||||
@@ -399,15 +418,16 @@ export default function SubmittedPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div style={styles.emptyState}>
|
||||
Snapshot này không có editor_feature_collection để render bản đồ.
|
||||
Submission này không có geometry renderable trong phần cần duyệt.
|
||||
Nếu đây là thay đổi delete-only, hãy xem phần operation summary bên dưới.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 style={styles.sectionHeading}>Snapshot</h3>
|
||||
<div style={styles.summaryGrid}>
|
||||
<Metric label="Editor features" value={selectedSummary.featureCount} />
|
||||
<Metric label="Entities" value={selectedSummary.entityCount} />
|
||||
<Metric label="Geometries" value={selectedSummary.geometryCount} />
|
||||
<Metric label="Preview features" value={selectedSummary.featureCount} />
|
||||
<Metric label="Entities in review" value={selectedSummary.entityCount} />
|
||||
<Metric label="Geometries in review" value={selectedSummary.geometryCount} />
|
||||
<Metric label="Link scopes" value={selectedSummary.linkScopeCount} />
|
||||
</div>
|
||||
<div style={styles.operationGrid}>
|
||||
@@ -447,6 +467,13 @@ export default function SubmittedPage() {
|
||||
{isReviewingSelected ? "Đang xử lý..." : "Duyệt"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<JsonDataPopup
|
||||
open={isJsonPopupOpen}
|
||||
title={`Commit ${selectedRow.submission.commit_id}`}
|
||||
data={selectedRow.submission.snapshot ?? null}
|
||||
onClose={() => setIsJsonPopupOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -473,6 +500,56 @@ function Info({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function JsonDataPopup({
|
||||
open,
|
||||
title,
|
||||
data,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
data: unknown;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div role="presentation" onClick={onClose} style={styles.jsonOverlay}>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Commit JSON data"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={styles.jsonDialog}
|
||||
>
|
||||
<div style={styles.jsonDialogHeader}>
|
||||
<div>
|
||||
<h3 style={styles.jsonDialogTitle}>{title}</h3>
|
||||
<p style={styles.jsonDialogSubtitle}>Raw data của submission đang duyệt.</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} style={styles.jsonCloseButton}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<pre style={styles.jsonPre}>{JSON.stringify(data, null, 2)}</pre>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: SectionSubmission["status"] }) {
|
||||
return (
|
||||
<span
|
||||
@@ -518,22 +595,35 @@ function extractSnapshotFeatureCollection(rawSnapshot: unknown): FeatureCollecti
|
||||
}
|
||||
|
||||
const snapshot = rawSnapshot as Record<string, unknown>;
|
||||
const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : [];
|
||||
const reviewGeometryIds = getReviewGeometryIds(snapshot);
|
||||
const renderableGeometryIds = new Set<string>();
|
||||
for (const item of geometries) {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
||||
const source = item as Record<string, unknown>;
|
||||
const id = normalizeSnapshotId(source.id);
|
||||
if (!id || !reviewGeometryIds.has(id)) continue;
|
||||
if (normalizeOperationName(source.operation) === "delete") continue;
|
||||
renderableGeometryIds.add(id);
|
||||
}
|
||||
|
||||
const editorCollection = snapshot.editor_feature_collection;
|
||||
if (isFeatureCollection(editorCollection)) {
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: editorCollection.features.map(cloneFeature),
|
||||
features: editorCollection.features
|
||||
.filter((feature) => renderableGeometryIds.has(String(feature.properties.id)))
|
||||
.map(cloneFeature),
|
||||
};
|
||||
}
|
||||
|
||||
const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : [];
|
||||
const features = geometries
|
||||
.map((item): Feature | null => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
|
||||
const source = item as Record<string, unknown>;
|
||||
if (source.operation === "delete") return null;
|
||||
|
||||
const id = source.id;
|
||||
const id = normalizeSnapshotId(source.id);
|
||||
if (!id || !renderableGeometryIds.has(id)) return null;
|
||||
const geometry = source.draw_geometry || source.geometry;
|
||||
if ((typeof id !== "string" && typeof id !== "number") || !isGeometry(geometry)) {
|
||||
return null;
|
||||
@@ -570,25 +660,72 @@ function summarizeSnapshot(rawSnapshot: unknown): SnapshotSummary | null {
|
||||
}
|
||||
|
||||
const snapshot = rawSnapshot as Record<string, unknown>;
|
||||
const featureCollection = snapshot.editor_feature_collection as Record<string, unknown> | undefined;
|
||||
const features = Array.isArray(featureCollection?.features)
|
||||
? featureCollection.features
|
||||
: [];
|
||||
const entities = Array.isArray(snapshot.entities) ? snapshot.entities : [];
|
||||
const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : [];
|
||||
const linkScopes = Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : [];
|
||||
const reviewGeometryIds = getReviewGeometryIds(snapshot);
|
||||
const reviewEntities = entities.filter((item) =>
|
||||
normalizeOperationName(getRecord(item)?.operation) !== "reference"
|
||||
);
|
||||
const reviewGeometries = geometries.filter((item) => {
|
||||
const record = getRecord(item);
|
||||
if (!record) return false;
|
||||
const id = normalizeSnapshotId(record.id);
|
||||
return Boolean(id && reviewGeometryIds.has(id));
|
||||
});
|
||||
|
||||
return {
|
||||
featureCount: features.length,
|
||||
entityCount: entities.length,
|
||||
geometryCount: geometries.length,
|
||||
featureCount: extractSnapshotFeatureCollection(rawSnapshot).features.length,
|
||||
entityCount: reviewEntities.length,
|
||||
geometryCount: reviewGeometries.length,
|
||||
linkScopeCount: linkScopes.length,
|
||||
entityOperations: countOperations(entities),
|
||||
geometryOperations: countOperations(geometries),
|
||||
entityOperations: countOperations(reviewEntities),
|
||||
geometryOperations: countOperations(reviewGeometries),
|
||||
linkOperations: countOperations(linkScopes),
|
||||
};
|
||||
}
|
||||
|
||||
function getReviewGeometryIds(snapshot: Record<string, unknown>): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : [];
|
||||
const linkScopes = Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : [];
|
||||
|
||||
for (const item of geometries) {
|
||||
const record = getRecord(item);
|
||||
if (!record) continue;
|
||||
const id = normalizeSnapshotId(record.id);
|
||||
if (!id) continue;
|
||||
const operation = normalizeOperationName(record.operation);
|
||||
if (operation && operation !== "reference") {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of linkScopes) {
|
||||
const record = getRecord(item);
|
||||
if (!record) continue;
|
||||
const geometryId = normalizeSnapshotId(record.geometry_id);
|
||||
if (geometryId) ids.add(geometryId);
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
function getRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeSnapshotId(value: unknown): string | null {
|
||||
if (typeof value !== "string" && typeof value !== "number") return null;
|
||||
const normalized = String(value).trim();
|
||||
return normalized.length ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeOperationName(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function isFeatureCollection(value: unknown): value is FeatureCollection {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const source = value as Record<string, unknown>;
|
||||
@@ -928,7 +1065,7 @@ const styles: Record<string, CSSProperties> = {
|
||||
cursor: "pointer",
|
||||
},
|
||||
submissionItemSelected: {
|
||||
borderColor: "#71a66a",
|
||||
border: "1px solid #71a66a",
|
||||
background: "#f0f8ee",
|
||||
},
|
||||
itemTopLine: {
|
||||
@@ -1010,6 +1147,21 @@ const styles: Record<string, CSSProperties> = {
|
||||
lineHeight: 1.5,
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
dataActions: {
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
marginBottom: "16px",
|
||||
},
|
||||
secondaryButton: {
|
||||
minHeight: "38px",
|
||||
border: "1px solid #c7cebc",
|
||||
borderRadius: "8px",
|
||||
padding: "8px 14px",
|
||||
background: "#ffffff",
|
||||
color: "#2a3326",
|
||||
fontWeight: 800,
|
||||
cursor: "pointer",
|
||||
},
|
||||
sectionHeading: {
|
||||
margin: "18px 0 10px",
|
||||
fontSize: "18px",
|
||||
@@ -1100,4 +1252,66 @@ const styles: Record<string, CSSProperties> = {
|
||||
fontWeight: 800,
|
||||
cursor: "pointer",
|
||||
},
|
||||
jsonOverlay: {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 1000,
|
||||
padding: "24px",
|
||||
background: "rgba(31, 36, 31, 0.72)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
jsonDialog: {
|
||||
width: "min(980px, calc(100vw - 48px))",
|
||||
maxHeight: "min(760px, calc(100vh - 48px))",
|
||||
border: "1px solid #9da891",
|
||||
borderRadius: "8px",
|
||||
background: "#ffffff",
|
||||
color: "#1f241f",
|
||||
boxShadow: "0 24px 70px rgba(0, 0, 0, 0.32)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
},
|
||||
jsonDialogHeader: {
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
gap: "16px",
|
||||
padding: "14px 16px",
|
||||
borderBottom: "1px solid #dfe5d6",
|
||||
background: "#f7f8f3",
|
||||
},
|
||||
jsonDialogTitle: {
|
||||
margin: "0 0 5px",
|
||||
fontSize: "18px",
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
jsonDialogSubtitle: {
|
||||
margin: 0,
|
||||
color: "#66705f",
|
||||
fontSize: "13px",
|
||||
},
|
||||
jsonCloseButton: {
|
||||
minHeight: "34px",
|
||||
border: "1px solid #c7cebc",
|
||||
borderRadius: "8px",
|
||||
padding: "7px 12px",
|
||||
background: "#ffffff",
|
||||
color: "#2a3326",
|
||||
fontWeight: 800,
|
||||
cursor: "pointer",
|
||||
},
|
||||
jsonPre: {
|
||||
margin: 0,
|
||||
padding: "16px",
|
||||
overflow: "auto",
|
||||
background: "#10140f",
|
||||
color: "#edf7e7",
|
||||
fontSize: "12px",
|
||||
lineHeight: 1.55,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
tabSize: 2,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user