Files
History-client/app/submited/page.tsx
2026-04-19 23:43:31 +07:00

1318 lines
47 KiB
TypeScript

"use client";
import { type CSSProperties, useEffect, useMemo, useState } from "react";
import Map from "@/components/Map";
import { ApiError } from "@/api/http";
import {
approveSubmission,
fetchSections,
fetchSectionSubmissions,
rejectSubmission,
Section,
SectionSubmission,
} from "@/api/sections";
import { HIDDEN_BACKGROUND_LAYER_VISIBILITY } from "@/lib/backgroundLayers";
import { Feature, FeatureCollection, Geometry } from "@/lib/useEditorState";
type SubmissionStatusFilter = "pending" | "all";
type ReviewAction = "approve" | "reject";
type SubmissionRow = {
section: Section;
submission: SectionSubmission;
};
type SnapshotSummary = {
featureCount: number;
entityCount: number;
geometryCount: number;
linkScopeCount: number;
entityOperations: Record<string, number>;
geometryOperations: Record<string, number>;
linkOperations: Record<string, number>;
};
const DEFAULT_REVIEWER_ID = "admin";
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",
approved: "Đã duyệt",
rejected: "Từ chối",
conflicted: "Conflict",
};
export default function SubmittedPage() {
const [rows, setRows] = useState<SubmissionRow[]>([]);
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<SubmissionStatusFilter>("pending");
const [reviewerId, setReviewerId] = useState(DEFAULT_REVIEWER_ID);
const [reviewNote, setReviewNote] = useState("");
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);
const loadSubmissions = async () => {
setIsLoading(true);
setErrorMessage(null);
setStatusMessage(null);
try {
const sections = await fetchSections();
const submissionGroups = await Promise.all(
sections.map(async (section) => {
const submissions = await fetchSectionSubmissions(section.id, {
includeSnapshot: true,
});
return submissions.map<SubmissionRow>((submission) => ({
section,
submission,
}));
})
);
const nextRows = submissionGroups
.flat()
.sort((a, b) =>
Date.parse(b.submission.submitted_at) - Date.parse(a.submission.submitted_at)
);
setRows(nextRows);
setSelectedSubmissionId((current) => {
if (current && nextRows.some((row) => row.submission.id === current)) {
return current;
}
return nextRows.find((row) => row.submission.status === "pending")?.submission.id
|| nextRows[0]?.submission.id
|| null;
});
} catch (err) {
setErrorMessage(getErrorMessage(err, "Không tải được danh sách section đã submit."));
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadSubmissions();
}, []);
const visibleRows = useMemo(() => {
if (statusFilter === "all") return rows;
return rows.filter((row) => row.submission.status === "pending");
}, [rows, statusFilter]);
const selectedRow = useMemo(() => {
if (!selectedSubmissionId) return visibleRows[0] || null;
return (
visibleRows.find((row) => row.submission.id === selectedSubmissionId) ||
rows.find((row) => row.submission.id === selectedSubmissionId) ||
visibleRows[0] ||
null
);
}, [rows, selectedSubmissionId, visibleRows]);
const pendingCount = rows.filter((row) => row.submission.status === "pending").length;
const reviewedCount = rows.length - pendingCount;
const selectedSummary = selectedRow
? summarizeSnapshot(selectedRow.submission.snapshot)
: null;
const selectedFeatureCollection = selectedRow
? extractSnapshotFeatureCollection(selectedRow.submission.snapshot)
: EMPTY_FEATURE_COLLECTION;
const selectedPreviewFeature = selectedFeatureCollection.features.find((feature) =>
String(feature.properties.id) === String(selectedPreviewFeatureId)
) || null;
useEffect(() => {
setSelectedPreviewFeatureId(null);
setIsJsonPopupOpen(false);
}, [selectedRow?.submission.id]);
const handleReview = async (action: ReviewAction) => {
if (!selectedRow) return;
const actor = reviewerId.trim();
if (!actor) {
setErrorMessage("Reviewer là bắt buộc.");
return;
}
if (selectedRow.submission.status !== "pending") {
setErrorMessage("Submission này không còn ở trạng thái chờ duyệt.");
return;
}
const label = action === "approve" ? "duyệt" : "từ chối";
const confirmed = window.confirm(`Xác nhận ${label} section "${selectedRow.section.title}"?`);
if (!confirmed) return;
setReviewingSubmissionId(selectedRow.submission.id);
setErrorMessage(null);
setStatusMessage(null);
try {
const input = {
reviewed_by: actor,
review_note: reviewNote.trim() || null,
};
const updatedSubmission = action === "approve"
? await approveSubmission(selectedRow.submission.id, input)
: await rejectSubmission(selectedRow.submission.id, input);
setRows((currentRows) =>
currentRows.map((row) =>
row.submission.id === updatedSubmission.id
? {
...row,
section: {
...row.section,
state: {
...row.section.state,
status: updatedSubmission.status === "approved"
? "approved"
: updatedSubmission.status === "rejected"
? "rejected"
: row.section.state.status,
},
},
submission: updatedSubmission,
}
: row
)
);
setReviewNote("");
setStatusMessage(
action === "approve"
? "Đã duyệt submission và apply vào published data."
: "Đã từ chối submission."
);
} catch (err) {
setErrorMessage(getErrorMessage(err, `Không thể ${label} submission.`));
} finally {
setReviewingSubmissionId(null);
}
};
const isReviewingSelected = reviewingSubmissionId === selectedRow?.submission.id;
const canReviewSelected = Boolean(
selectedRow &&
selectedRow.submission.status === "pending" &&
!isReviewingSelected
);
return (
<main style={styles.page}>
<section style={styles.header}>
<div>
<p style={styles.kicker}>Review sections</p>
<h1 style={styles.title}>Submitted sections</h1>
<p style={styles.subtitle}>
Xem các section đã submit, kiểm tra snapshot duyệt dữ liệu vào published map.
</p>
</div>
<div style={styles.headerActions}>
<a href="/editor" style={styles.secondaryLink}>Mở editor</a>
<button
type="button"
onClick={loadSubmissions}
disabled={isLoading}
style={styles.primaryButton}
>
{isLoading ? "Đang tải..." : "Refresh"}
</button>
</div>
</section>
<section style={styles.statsGrid}>
<Metric label="Tổng submission" value={rows.length} />
<Metric label="Chờ duyệt" value={pendingCount} />
<Metric label="Đã xử lý" value={reviewedCount} />
</section>
{statusMessage ? <div style={styles.successBanner}>{statusMessage}</div> : null}
{errorMessage ? <div style={styles.errorBanner}>{errorMessage}</div> : null}
<section style={styles.toolbar}>
<label style={styles.fieldLabel}>
Reviewer
<input
value={reviewerId}
onChange={(event) => setReviewerId(event.target.value)}
style={styles.input}
placeholder="reviewer id"
/>
</label>
<label style={styles.fieldLabelWide}>
Ghi chú review
<input
value={reviewNote}
onChange={(event) => setReviewNote(event.target.value)}
style={styles.input}
placeholder="Ghi chú optional"
/>
</label>
<label style={styles.fieldLabel}>
Bộ lọc
<select
value={statusFilter}
onChange={(event) =>
setStatusFilter(event.target.value as SubmissionStatusFilter)
}
style={styles.input}
>
<option value="pending">Chờ duyệt</option>
<option value="all">Tất cả</option>
</select>
</label>
</section>
<section style={styles.contentGrid}>
<div style={styles.listPanel}>
<div style={styles.panelHeader}>
<h2 style={styles.panelTitle}>Sections</h2>
<span style={styles.mutedText}>{visibleRows.length} item</span>
</div>
{isLoading ? (
<div style={styles.emptyState}>Đang tải submissions...</div>
) : visibleRows.length === 0 ? (
<div style={styles.emptyState}>
Không submission nào trong bộ lọc hiện tại.
</div>
) : (
<div style={styles.submissionList}>
{visibleRows.map((row) => {
const selected = selectedRow?.submission.id === row.submission.id;
return (
<button
key={row.submission.id}
type="button"
onClick={() => setSelectedSubmissionId(row.submission.id)}
style={{
...styles.submissionItem,
...(selected ? styles.submissionItemSelected : null),
}}
>
<span style={styles.itemTopLine}>
<strong style={styles.itemTitle}>{row.section.title}</strong>
<StatusBadge status={row.submission.status} />
</span>
<span style={styles.itemMeta}>
Submit bởi {row.submission.submitted_by || "unknown"}
</span>
<span style={styles.itemMeta}>
{formatDateTime(row.submission.submitted_at)}
</span>
</button>
);
})}
</div>
)}
</div>
<div style={styles.detailPanel}>
{!selectedRow ? (
<div style={styles.emptyState}>Chọn một section đ xem chi tiết.</div>
) : (
<>
<div style={styles.detailHeader}>
<div>
<h2 style={styles.detailTitle}>{selectedRow.section.title}</h2>
<p style={styles.mutedText}>
Section status: {selectedRow.section.state.status} · Version {selectedRow.section.state.version}
</p>
</div>
<StatusBadge status={selectedRow.submission.status} />
</div>
<div style={styles.metaGrid}>
<Info label="Submission ID" value={selectedRow.submission.id} />
<Info label="Commit ID" value={selectedRow.submission.commit_id} />
<Info label="Submitted by" value={selectedRow.submission.submitted_by} />
<Info label="Submitted at" value={formatDateTime(selectedRow.submission.submitted_at)} />
<Info label="Reviewed by" value={selectedRow.submission.reviewed_by || "Chưa có"} />
<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>
<p style={styles.noteText}>{selectedRow.submission.review_note}</p>
</div>
) : null}
{selectedSummary ? (
<>
<h3 style={styles.sectionHeading}>Map preview</h3>
{selectedFeatureCollection.features.length > 0 ? (
<div style={styles.previewGrid}>
<div style={styles.mapPreview}>
<Map
key={selectedRow.submission.id}
mode="select"
draft={selectedFeatureCollection}
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}>
<strong>Selected geometry</strong>
{selectedPreviewFeature ? (
<div style={styles.featureDetails}>
<Info
label="Geometry ID"
value={String(selectedPreviewFeature.properties.id)}
/>
<Info
label="Geometry type"
value={selectedPreviewFeature.geometry.type}
/>
<Info
label="Semantic type"
value={selectedPreviewFeature.properties.type || "Chưa có"}
/>
<Info
label="Entity"
value={formatFeatureEntities(selectedPreviewFeature)}
/>
<Info
label="Time"
value={formatFeatureTimeRange(selectedPreviewFeature)}
/>
<Info
label="Binding"
value={formatFeatureBinding(selectedPreviewFeature)}
/>
</div>
) : (
<p style={styles.mutedText}>
Chọn một geometry trên preview đ xem metadata.
</p>
)}
</div>
</div>
) : (
<div style={styles.emptyState}>
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="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}>
<OperationBlock
title="Entity operations"
operations={selectedSummary.entityOperations}
/>
<OperationBlock
title="Geometry operations"
operations={selectedSummary.geometryOperations}
/>
<OperationBlock
title="Link operations"
operations={selectedSummary.linkOperations}
/>
</div>
</>
) : (
<div style={styles.emptyState}>Submission này không snapshot đ hiển thị.</div>
)}
<div style={styles.reviewActions}>
<button
type="button"
onClick={() => handleReview("reject")}
disabled={!canReviewSelected}
style={styles.dangerButton}
>
{isReviewingSelected ? "Đang xử lý..." : "Từ chối"}
</button>
<button
type="button"
onClick={() => handleReview("approve")}
disabled={!canReviewSelected}
style={styles.approveButton}
>
{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>
</section>
</main>
);
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<div style={styles.metric}>
<span style={styles.metricValue}>{value}</span>
<span style={styles.metricLabel}>{label}</span>
</div>
);
}
function Info({ label, value }: { label: string; value: string }) {
return (
<div style={styles.infoCell}>
<span style={styles.infoLabel}>{label}</span>
<span style={styles.infoValue}>{value}</span>
</div>
);
}
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
style={{
...styles.statusBadge,
...getStatusStyle(status),
}}
>
{STATUS_LABELS[status] || status}
</span>
);
}
function OperationBlock({
title,
operations,
}: {
title: string;
operations: Record<string, number>;
}) {
const entries = Object.entries(operations);
return (
<div style={styles.operationBlock}>
<strong>{title}</strong>
{entries.length === 0 ? (
<p style={styles.mutedText}>Không operation.</p>
) : (
<div style={styles.operationRows}>
{entries.map(([operation, count]) => (
<span key={operation} style={styles.operationPill}>
{operation}: {count}
</span>
))}
</div>
)}
</div>
);
}
function extractSnapshotFeatureCollection(rawSnapshot: unknown): FeatureCollection {
if (!rawSnapshot || typeof rawSnapshot !== "object" || Array.isArray(rawSnapshot)) {
return EMPTY_FEATURE_COLLECTION;
}
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
.filter((feature) => renderableGeometryIds.has(String(feature.properties.id)))
.map(cloneFeature),
};
}
const features = geometries
.map((item): Feature | null => {
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
const source = item as Record<string, unknown>;
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;
}
return {
type: "Feature",
properties: {
id,
type: typeof source.type === "string" ? source.type : null,
geometry_preset: null,
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
binding: normalizeStringArray(source.binding),
time_start: normalizeOptionalNumber(source.time_start),
time_end: normalizeOptionalNumber(source.time_end),
},
geometry: cloneGeometry(geometry),
};
})
.filter((feature): feature is Feature => feature !== null);
return {
type: "FeatureCollection",
features,
};
}
function summarizeSnapshot(rawSnapshot: unknown): SnapshotSummary | null {
if (!rawSnapshot || typeof rawSnapshot !== "object" || Array.isArray(rawSnapshot)) {
return null;
}
const snapshot = rawSnapshot as Record<string, unknown>;
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: extractSnapshotFeatureCollection(rawSnapshot).features.length,
entityCount: reviewEntities.length,
geometryCount: reviewGeometries.length,
linkScopeCount: linkScopes.length,
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>;
return source.type === "FeatureCollection" &&
Array.isArray(source.features) &&
source.features.every(isFeature);
}
function isFeature(value: unknown): value is Feature {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const source = value as Record<string, unknown>;
const properties = source.properties;
if (source.type !== "Feature" || !isGeometry(source.geometry)) return false;
if (!properties || typeof properties !== "object" || Array.isArray(properties)) return false;
const id = (properties as Record<string, unknown>).id;
return typeof id === "string" || typeof id === "number";
}
function isGeometry(value: unknown): value is Geometry {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const source = value as Record<string, unknown>;
return typeof source.type === "string" && Array.isArray(source.coordinates);
}
function cloneFeature(feature: Feature): Feature {
return JSON.parse(JSON.stringify(feature)) as Feature;
}
function cloneGeometry(geometry: Geometry): Geometry {
return JSON.parse(JSON.stringify(geometry)) as Geometry;
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
if (typeof item !== "string" && typeof item !== "number") return "";
return String(item).trim();
})
.filter((item) => item.length > 0);
}
function normalizeOptionalNumber(value: unknown): number | null {
if (value === null || value === undefined || value === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function formatFeatureEntities(feature: Feature): string {
const named = normalizeStringArray(feature.properties.entity_names);
if (named.length > 0) return named.join(", ");
if (feature.properties.entity_name) return feature.properties.entity_name;
const ids = normalizeStringArray(feature.properties.entity_ids);
if (ids.length > 0) return ids.join(", ");
return feature.properties.entity_id || "Chưa gắn";
}
function formatFeatureTimeRange(feature: Feature): string {
const start = feature.properties.time_start;
const end = feature.properties.time_end;
if (start == null && end == null) return "Không giới hạn";
if (start != null && end != null) return `${start} - ${end}`;
if (start != null) return `Từ ${start}`;
return `Đến ${end}`;
}
function formatFeatureBinding(feature: Feature): string {
const binding = normalizeStringArray(feature.properties.binding);
return binding.length > 0 ? binding.join(", ") : "Không có";
}
function countOperations(items: unknown[]): Record<string, number> {
return items.reduce<Record<string, number>>((acc, item) => {
const source = item && typeof item === "object" && !Array.isArray(item)
? item as Record<string, unknown>
: {};
const operation = typeof source.operation === "string" && source.operation.trim()
? source.operation.trim()
: "unknown";
acc[operation] = (acc[operation] || 0) + 1;
return acc;
}, {});
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return "Chưa có";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("vi-VN", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
function getErrorMessage(err: unknown, fallback: string): string {
if (err instanceof ApiError) {
const parsed = parseErrorBody(err.body);
return parsed || err.body || fallback;
}
if (err instanceof Error) return err.message;
return fallback;
}
function parseErrorBody(body: string): string | null {
try {
const parsed = JSON.parse(body) as unknown;
if (parsed && typeof parsed === "object" && "error" in parsed) {
const error = (parsed as { error?: unknown }).error;
return typeof error === "string" ? error : null;
}
} catch {
return null;
}
return null;
}
function getStatusStyle(status: SectionSubmission["status"]): CSSProperties {
if (status === "approved") {
return {
background: "#d9f5df",
color: "#17652a",
borderColor: "#8bd19a",
};
}
if (status === "rejected") {
return {
background: "#ffe2dd",
color: "#8a1f12",
borderColor: "#ef9a8d",
};
}
if (status === "conflicted") {
return {
background: "#fff1c7",
color: "#755400",
borderColor: "#dfbf59",
};
}
return {
background: "#e2f1ff",
color: "#0c4f85",
borderColor: "#8bbde4",
};
}
const styles: Record<string, CSSProperties> = {
page: {
minHeight: "100vh",
padding: "32px",
background: "#f7f8f3",
color: "#1f241f",
},
header: {
display: "flex",
justifyContent: "space-between",
gap: "20px",
alignItems: "flex-start",
flexWrap: "wrap",
marginBottom: "22px",
},
kicker: {
margin: "0 0 8px",
color: "#4f6f39",
fontSize: "13px",
fontWeight: 700,
textTransform: "uppercase",
},
title: {
margin: 0,
fontSize: "36px",
lineHeight: 1.15,
letterSpacing: 0,
},
subtitle: {
maxWidth: "680px",
margin: "10px 0 0",
color: "#596055",
lineHeight: 1.5,
},
headerActions: {
display: "flex",
gap: "10px",
flexWrap: "wrap",
justifyContent: "flex-end",
},
primaryButton: {
minHeight: "38px",
border: "1px solid #284f2f",
borderRadius: "8px",
padding: "8px 14px",
background: "#2f6d3a",
color: "#ffffff",
fontWeight: 700,
cursor: "pointer",
},
secondaryLink: {
minHeight: "38px",
border: "1px solid #c7cebc",
borderRadius: "8px",
padding: "8px 14px",
background: "#ffffff",
color: "#2a3326",
fontWeight: 700,
textDecoration: "none",
},
statsGrid: {
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
gap: "12px",
marginBottom: "16px",
},
metric: {
border: "1px solid #d5dbc8",
borderRadius: "8px",
padding: "14px",
background: "#ffffff",
},
metricValue: {
display: "block",
fontSize: "28px",
lineHeight: 1.1,
fontWeight: 800,
color: "#254b2b",
},
metricLabel: {
display: "block",
marginTop: "6px",
color: "#5a6356",
fontSize: "13px",
},
successBanner: {
border: "1px solid #9dcc9c",
borderRadius: "8px",
padding: "12px 14px",
marginBottom: "12px",
background: "#e6f6e5",
color: "#1d5b25",
},
errorBanner: {
border: "1px solid #ef9a8d",
borderRadius: "8px",
padding: "12px 14px",
marginBottom: "12px",
background: "#ffe2dd",
color: "#8a1f12",
},
toolbar: {
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 220px), 1fr))",
gap: "12px",
marginBottom: "16px",
alignItems: "end",
},
fieldLabel: {
display: "grid",
gap: "6px",
color: "#424a3f",
fontSize: "13px",
fontWeight: 700,
},
fieldLabelWide: {
display: "grid",
gap: "6px",
color: "#424a3f",
fontSize: "13px",
fontWeight: 700,
},
input: {
width: "100%",
minHeight: "38px",
border: "1px solid #c9d0bd",
borderRadius: "8px",
padding: "8px 10px",
background: "#ffffff",
color: "#202820",
outline: "none",
},
contentGrid: {
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 360px), 1fr))",
gap: "16px",
alignItems: "start",
},
listPanel: {
border: "1px solid #d5dbc8",
borderRadius: "8px",
background: "#ffffff",
minHeight: "520px",
overflow: "hidden",
},
detailPanel: {
border: "1px solid #d5dbc8",
borderRadius: "8px",
background: "#ffffff",
minHeight: "520px",
padding: "18px",
},
panelHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "14px",
borderBottom: "1px solid #e3e7da",
},
panelTitle: {
margin: 0,
fontSize: "18px",
},
mutedText: {
margin: 0,
color: "#66705f",
fontSize: "13px",
lineHeight: 1.45,
},
emptyState: {
padding: "18px",
color: "#66705f",
lineHeight: 1.5,
},
submissionList: {
display: "grid",
gap: "8px",
padding: "10px",
maxHeight: "calc(100vh - 310px)",
overflow: "auto",
},
submissionItem: {
width: "100%",
display: "grid",
gap: "6px",
textAlign: "left",
border: "1px solid #e1e5d9",
borderRadius: "8px",
padding: "12px",
background: "#ffffff",
color: "#202820",
cursor: "pointer",
},
submissionItemSelected: {
border: "1px solid #71a66a",
background: "#f0f8ee",
},
itemTopLine: {
display: "flex",
gap: "8px",
justifyContent: "space-between",
alignItems: "center",
},
itemTitle: {
minWidth: 0,
overflowWrap: "anywhere",
},
itemMeta: {
color: "#5f695a",
fontSize: "13px",
overflowWrap: "anywhere",
},
statusBadge: {
flex: "0 0 auto",
display: "inline-flex",
alignItems: "center",
minHeight: "24px",
border: "1px solid",
borderRadius: "8px",
padding: "2px 8px",
fontSize: "12px",
fontWeight: 800,
whiteSpace: "nowrap",
},
detailHeader: {
display: "flex",
justifyContent: "space-between",
gap: "16px",
alignItems: "flex-start",
marginBottom: "16px",
},
detailTitle: {
margin: "0 0 6px",
fontSize: "24px",
lineHeight: 1.25,
overflowWrap: "anywhere",
},
metaGrid: {
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: "10px",
marginBottom: "16px",
},
infoCell: {
border: "1px solid #e1e5d9",
borderRadius: "8px",
padding: "10px",
background: "#fbfcf8",
minWidth: 0,
},
infoLabel: {
display: "block",
color: "#66705f",
fontSize: "12px",
fontWeight: 700,
marginBottom: "5px",
},
infoValue: {
display: "block",
color: "#202820",
fontSize: "13px",
overflowWrap: "anywhere",
},
noteBox: {
border: "1px solid #e1e5d9",
borderRadius: "8px",
padding: "12px",
marginBottom: "16px",
background: "#fbfcf8",
},
noteText: {
margin: "6px 0 0",
color: "#40483c",
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",
},
summaryGrid: {
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))",
gap: "10px",
marginBottom: "12px",
},
operationGrid: {
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: "10px",
},
operationBlock: {
border: "1px solid #e1e5d9",
borderRadius: "8px",
padding: "12px",
background: "#fbfcf8",
},
operationRows: {
display: "flex",
flexWrap: "wrap",
gap: "8px",
marginTop: "10px",
},
operationPill: {
border: "1px solid #cfd8c2",
borderRadius: "8px",
padding: "4px 8px",
background: "#ffffff",
color: "#394136",
fontSize: "13px",
},
previewGrid: {
display: "grid",
gridTemplateColumns: "minmax(min(100%, 420px), 1fr) minmax(min(100%, 260px), 320px)",
gap: "12px",
marginBottom: "16px",
alignItems: "stretch",
},
mapPreview: {
height: "420px",
minHeight: "320px",
border: "1px solid #d5dbc8",
borderRadius: "8px",
overflow: "hidden",
background: "#0b1220",
},
featurePanel: {
border: "1px solid #e1e5d9",
borderRadius: "8px",
padding: "12px",
background: "#fbfcf8",
minWidth: 0,
},
featureDetails: {
display: "grid",
gap: "8px",
marginTop: "10px",
},
reviewActions: {
display: "flex",
justifyContent: "flex-end",
gap: "10px",
marginTop: "22px",
paddingTop: "16px",
borderTop: "1px solid #e3e7da",
},
dangerButton: {
minHeight: "40px",
border: "1px solid #a93322",
borderRadius: "8px",
padding: "8px 16px",
background: "#b94330",
color: "#ffffff",
fontWeight: 800,
cursor: "pointer",
},
approveButton: {
minHeight: "40px",
border: "1px solid #276131",
borderRadius: "8px",
padding: "8px 16px",
background: "#2f7a3b",
color: "#ffffff",
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,
},
};