demo 20-4-2026

This commit is contained in:
taDuc
2026-04-19 23:43:31 +07:00
parent 57a7843d80
commit 2508172489
10 changed files with 1443 additions and 98 deletions

View File

@@ -933,7 +933,7 @@ export default function Page() {
const handleSubmitSection = async () => {
if (!activeSection || !sectionState?.head_commit_id) {
setEntityStatus("Chưa có commit để submit.");
setEntityStatus("Section hiện tại chưa có head để submit.");
return;
}
if (pendingSaveCount > 0) {
@@ -946,7 +946,6 @@ export default function Page() {
try {
const submission = await submitSection(activeSection.id, {
submitted_by: editorUserId,
commit_id: sectionState.head_commit_id,
});
setSectionState((prev) => prev ? { ...prev, status: "submitted" } : prev);
setEntityStatus(`Đã submit section, submission ${submission.id}.`);
@@ -1001,6 +1000,9 @@ export default function Page() {
};
const pendingSaveCount = editor.changeCount + pendingEntityCreates.length;
const headCommit = sectionState?.head_commit_id
? sectionCommits.find((commit) => commit.id === sectionState.head_commit_id) || null
: null;
const timelineDisabled = !isTimelineReady || isSaving || pendingSaveCount > 0;
const timelineStatusText =
pendingSaveCount > 0
@@ -1043,7 +1045,9 @@ export default function Page() {
onOpenSection={handleOpenSelectedSection}
onCreateSection={handleCreateAndOpenSection}
commitCount={sectionCommits.length}
latestCommitLabel={sectionCommits[0] ? `Head #${sectionCommits[0].commit_no}` : null}
hasHeadCommit={Boolean(sectionState?.head_commit_id)}
headCommitId={sectionState?.head_commit_id || null}
latestCommitLabel={headCommit ? `Head: ${formatCommitTitle(headCommit)}` : null}
commits={sectionCommits}
changesCount={pendingSaveCount}
undoStack={editor.undoStack}
@@ -1398,6 +1402,10 @@ function isYearNumber(value: number | null | undefined): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function formatCommitTitle(commit: SectionCommit): string {
return commit.title?.trim() || `Commit #${commit.commit_no}`;
}
function getDefaultTypeIdForFeature(feature: Feature): string {
const preset = feature.properties.geometry_preset;
if (preset === "line") return "defense_line";

View File

@@ -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 editor_feature_collection đ render bản đ.
Submission này không geometry renderable trong phần cần duyệt.
Nếu đây 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,
},
};