1104 lines
40 KiB
TypeScript
1104 lines
40 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 { DEFAULT_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 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 [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);
|
|
}, [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 và 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 có 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>
|
|
|
|
{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="idle"
|
|
draft={selectedFeatureCollection}
|
|
backgroundVisibility={DEFAULT_BACKGROUND_LAYER_VISIBILITY}
|
|
selectedFeatureId={selectedPreviewFeatureId}
|
|
onSelectFeatureId={setSelectedPreviewFeatureId}
|
|
allowGeometryEditing={false}
|
|
respectBindingFilter={false}
|
|
height="420px"
|
|
/>
|
|
</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}>
|
|
Snapshot này không có editor_feature_collection để render bản đồ.
|
|
</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="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 có 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>
|
|
</>
|
|
)}
|
|
</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 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 có 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 editorCollection = snapshot.editor_feature_collection;
|
|
if (isFeatureCollection(editorCollection)) {
|
|
return {
|
|
type: "FeatureCollection",
|
|
features: editorCollection.features.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 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 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 : [];
|
|
|
|
return {
|
|
featureCount: features.length,
|
|
entityCount: entities.length,
|
|
geometryCount: geometries.length,
|
|
linkScopeCount: linkScopes.length,
|
|
entityOperations: countOperations(entities),
|
|
geometryOperations: countOperations(geometries),
|
|
linkOperations: countOperations(linkScopes),
|
|
};
|
|
}
|
|
|
|
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: {
|
|
borderColor: "#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",
|
|
},
|
|
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",
|
|
},
|
|
};
|