add upload automation view
This commit is contained in:
parent
1528c9fc20
commit
54aee14256
16
addons.txt
Normal file
16
addons.txt
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
xlsx
|
||||||
|
pdfjs-dist
|
||||||
|
uuid
|
||||||
|
framer-motion
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
open-layers
|
||||||
|
geostyler-sld-parser
|
||||||
|
geostyler-openlayers-parser
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
npm install xlsx pdfjs-dist uuid framer-motion ol geostyler-sld-parser geostyler-openlayers-parser
|
||||||
|
npm uninstall xlsx pdfjs-dist uuid framer-motion ol geostyler-sld-parser geostyler-openlayers-parser
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
Key,
|
Key,
|
||||||
ChartBarIncreasing,
|
ChartBarIncreasing,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
FolderUp,
|
||||||
WashingMachine,
|
WashingMachine,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
|
@ -38,6 +39,12 @@ const menuItems: MenuItem[] = [
|
||||||
module: "mapset",
|
module: "mapset",
|
||||||
icon: <BookOpen className="h-5 w-5" />,
|
icon: <BookOpen className="h-5 w-5" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Upload Peta",
|
||||||
|
href: "/admin/mapset-upload",
|
||||||
|
module: "mapset",
|
||||||
|
icon: <FolderUp className="h-5 w-5" />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Manajemen User",
|
name: "Manajemen User",
|
||||||
href: "/admin/user",
|
href: "/admin/user",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,305 @@
|
||||||
|
// app/admin/upload/_components/map/CustomLayerStyle.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// --- HELPER FUNCTION SLD GENERATOR ---
|
||||||
|
const sldHeader = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<StyledLayerDescriptor version="1.0.0"
|
||||||
|
xmlns="http://www.opengis.net/sld"
|
||||||
|
xmlns:ogc="http://www.opengis.net/ogc"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:se="http://www.opengis.net/se"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.opengis.net/sld
|
||||||
|
http://schemas.opengis.net/sld/1.0.0/StyledLayerDescriptor.xsd">
|
||||||
|
`;
|
||||||
|
const sldFooter = `</StyledLayerDescriptor>`;
|
||||||
|
|
||||||
|
// Helper symbolizer generator
|
||||||
|
const symbolizer = (geometryType: string, color: string) => {
|
||||||
|
const geomUpper = geometryType?.toUpperCase();
|
||||||
|
|
||||||
|
if (geomUpper === "POINT" || geomUpper === "MULTIPOINT") {
|
||||||
|
return `
|
||||||
|
<PointSymbolizer>
|
||||||
|
<Graphic>
|
||||||
|
<Mark>
|
||||||
|
<WellKnownName>circle</WellKnownName>
|
||||||
|
<Fill>
|
||||||
|
<CssParameter name="fill">${color}</CssParameter>
|
||||||
|
</Fill>
|
||||||
|
<Stroke>
|
||||||
|
<CssParameter name="stroke">#000000</CssParameter>
|
||||||
|
<CssParameter name="stroke-width">1</CssParameter>
|
||||||
|
</Stroke>
|
||||||
|
</Mark>
|
||||||
|
<Size>10</Size>
|
||||||
|
</Graphic>
|
||||||
|
</PointSymbolizer>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geomUpper === "LINE" || geomUpper === "LINESTRING" || geomUpper === "MULTILINESTRING") {
|
||||||
|
return `
|
||||||
|
<LineSymbolizer>
|
||||||
|
<Stroke>
|
||||||
|
<CssParameter name="stroke">${color}</CssParameter>
|
||||||
|
<CssParameter name="stroke-width">2</CssParameter>
|
||||||
|
</Stroke>
|
||||||
|
</LineSymbolizer>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polygon default
|
||||||
|
return `
|
||||||
|
<PolygonSymbolizer>
|
||||||
|
<Fill>
|
||||||
|
<CssParameter name="fill">${color}</CssParameter>
|
||||||
|
<CssParameter name="fill-opacity">0.5</CssParameter>
|
||||||
|
</Fill>
|
||||||
|
<Stroke>
|
||||||
|
<CssParameter name="stroke">#232323</CssParameter>
|
||||||
|
<CssParameter name="stroke-width">1</CssParameter>
|
||||||
|
</Stroke>
|
||||||
|
</PolygonSymbolizer>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const singleColorSLD = (color: string, geometryType: string) => `${sldHeader}
|
||||||
|
<NamedLayer>
|
||||||
|
<Name>layer</Name>
|
||||||
|
<UserStyle>
|
||||||
|
<FeatureTypeStyle>
|
||||||
|
<Rule>
|
||||||
|
${symbolizer(geometryType, color)}
|
||||||
|
</Rule>
|
||||||
|
</FeatureTypeStyle>
|
||||||
|
</UserStyle>
|
||||||
|
</NamedLayer>
|
||||||
|
${sldFooter}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const uniqueValueSLD = (column: string, rules: any[], geometryType: string) => `${sldHeader}
|
||||||
|
<NamedLayer>
|
||||||
|
<Name>layer</Name>
|
||||||
|
<UserStyle>
|
||||||
|
<FeatureTypeStyle>
|
||||||
|
${rules.map(r => `
|
||||||
|
<Rule>
|
||||||
|
<ogc:Filter>
|
||||||
|
<ogc:PropertyIsEqualTo>
|
||||||
|
<ogc:PropertyName>${column}</ogc:PropertyName>
|
||||||
|
<ogc:Literal>${r.value}</ogc:Literal>
|
||||||
|
</ogc:PropertyIsEqualTo>
|
||||||
|
</ogc:Filter>
|
||||||
|
${symbolizer(geometryType, r.color)}
|
||||||
|
</Rule>
|
||||||
|
`).join("")}
|
||||||
|
</FeatureTypeStyle>
|
||||||
|
</UserStyle>
|
||||||
|
</NamedLayer>
|
||||||
|
${sldFooter}
|
||||||
|
`;
|
||||||
|
const globalIconSLD = (iconCode:string) => `${sldHeader}
|
||||||
|
<NamedLayer>
|
||||||
|
<Name>layer</Name>
|
||||||
|
<UserStyle>
|
||||||
|
<FeatureTypeStyle>
|
||||||
|
<Rule>
|
||||||
|
<PointSymbolizer>
|
||||||
|
<Graphic>
|
||||||
|
<ExternalGraphic>
|
||||||
|
<OnlineResource
|
||||||
|
xlink:type="simple"
|
||||||
|
xlink:href="${iconCode}"/>
|
||||||
|
<Format>image/png</Format>
|
||||||
|
</ExternalGraphic>
|
||||||
|
<Size>10</Size>
|
||||||
|
</Graphic>
|
||||||
|
</PointSymbolizer>
|
||||||
|
</Rule>
|
||||||
|
</FeatureTypeStyle>
|
||||||
|
</UserStyle>
|
||||||
|
</NamedLayer>
|
||||||
|
${sldFooter}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Helper Random Color
|
||||||
|
const randomColor = () => {
|
||||||
|
let color = "#000000"
|
||||||
|
while (color === "#000000") {
|
||||||
|
color = "#" + Math.floor(Math.random() * 16777215).toString(16).padStart(6, "0");
|
||||||
|
}
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomLayerStyleProps {
|
||||||
|
data: any[];
|
||||||
|
geometryType: string;
|
||||||
|
onSubmit: (val: any) => void;
|
||||||
|
onChange?: (val: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomLayerStyle = ({ data = [], geometryType, onSubmit }: CustomLayerStyleProps) => {
|
||||||
|
const [columns, setColumns] = useState<string[]>([]);
|
||||||
|
const [selectedStyle, setSelectedStyle] = useState("single");
|
||||||
|
const [singleColor, setSingleColor] = useState("#3388ff");
|
||||||
|
const [uniqueColumn, setUniqueColumn] = useState("");
|
||||||
|
const [uniqueRules, setUniqueRules] = useState<{value: any, color: string}[]>([]);
|
||||||
|
const [iconGlobal, setIconGlobal] = useState("");
|
||||||
|
const [validIcon, setValidIcon] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
// Ambil keys dari row pertama, filter geometry
|
||||||
|
const keys = Object.keys(data[0]).filter((k) => k !== "geometry");
|
||||||
|
setColumns(keys);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!iconGlobal) return;
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => setValidIcon(true);
|
||||||
|
img.onerror = () => {
|
||||||
|
setValidIcon(false);
|
||||||
|
toast.error("URL icon tidak valid, Mohon ganti.");
|
||||||
|
}
|
||||||
|
img.src = iconGlobal;
|
||||||
|
}, [iconGlobal]);
|
||||||
|
|
||||||
|
|
||||||
|
const generateUniqueRules = (column: string) => {
|
||||||
|
const values = [...new Set(data.map((d) => d[column]))].slice(0, 20); // Limit 20 unique values agar tidak berat
|
||||||
|
const rules = values.map((v) => ({
|
||||||
|
value: v,
|
||||||
|
color: randomColor(),
|
||||||
|
}));
|
||||||
|
setUniqueRules(rules);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (selectedStyle === "icon" && !validIcon) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let xml = "";
|
||||||
|
if (selectedStyle === "single") {
|
||||||
|
xml = singleColorSLD(singleColor, geometryType);
|
||||||
|
} else if (selectedStyle === "unique_value") {
|
||||||
|
xml = uniqueValueSLD(uniqueColumn, uniqueRules, geometryType);
|
||||||
|
} else if (selectedStyle === "icon") {
|
||||||
|
const iconCode = 'https://cdn-icons-png.flaticon.com/512/0/614.png'
|
||||||
|
// xml = globalIconSLD(iconCode);
|
||||||
|
xml = globalIconSLD(iconGlobal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kirim SLD string ke parent
|
||||||
|
onSubmit({
|
||||||
|
styleType: "sld",
|
||||||
|
sldContent: xml
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-4 h-full flex flex-col">
|
||||||
|
{/* <h5 className="font-bold mb-3 text-sm">🎨 Pengaturan Style</h5> */}
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="text-xs font-semibold text-slate-500 mb-1 block">Jenis Styling</label>
|
||||||
|
<select
|
||||||
|
className="w-full border rounded p-2 text-sm"
|
||||||
|
value={selectedStyle}
|
||||||
|
onChange={(e) => setSelectedStyle(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="single">Single Color</option>
|
||||||
|
<option value="unique_value">Kategori (Unique Value)</option>
|
||||||
|
{selectedStyle === "icon" && geometryType === "Point" && (
|
||||||
|
<option value="icon">URL Icon</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- SINGLE COLOR --- */}
|
||||||
|
{selectedStyle === "single" && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="text-xs font-semibold text-slate-500 mb-1 block">Warna Layer</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="w-10 h-10 p-0 border rounded cursor-pointer"
|
||||||
|
value={singleColor}
|
||||||
|
onChange={(e) => setSingleColor(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-600">{singleColor}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- UNIQUE VALUE --- */}
|
||||||
|
{selectedStyle === "unique_value" && (
|
||||||
|
<div className="mb-4 flex-1 overflow-auto">
|
||||||
|
<label className="text-xs font-semibold text-slate-500 mb-1 block">Pilih Kolom Kategori</label>
|
||||||
|
<select
|
||||||
|
className="w-full border rounded p-2 text-sm mb-3"
|
||||||
|
value={uniqueColumn}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUniqueColumn(e.target.value);
|
||||||
|
generateUniqueRules(e.target.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">-- Pilih Kolom --</option>
|
||||||
|
{columns.map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{uniqueRules.length > 0 && (
|
||||||
|
<div className="border rounded p-2 bg-slate-50 max-h-60 overflow-y-auto">
|
||||||
|
<p className="text-xs font-semibold mb-2 text-slate-500">Preview Kategori (Max 20)</p>
|
||||||
|
{uniqueRules.map((r, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 mb-1">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="w-6 h-6 p-0 border rounded-full overflow-hidden shrink-0"
|
||||||
|
value={r.color}
|
||||||
|
onChange={(e) => {
|
||||||
|
const copy = [...uniqueRules];
|
||||||
|
copy[i].color = e.target.value;
|
||||||
|
setUniqueRules(copy);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs truncate">{String(r.value)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---------------- ICON PER FEATURE ---------------- */}
|
||||||
|
{selectedStyle === "icon" && geometryType === "Point" && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="block mb-1">Masukkan URL Icon</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full border rounded p-2"
|
||||||
|
placeholder="https://example.com/icon.png"
|
||||||
|
value={iconGlobal}
|
||||||
|
onChange={(e) => setIconGlobal(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-auto pt-4 border-t">
|
||||||
|
<Button onClick={submit} className="w-full">Terapkan Style</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomLayerStyle;
|
||||||
101
app/(modules)/admin/mapset-upload/_components/map/GeoPreview.tsx
Normal file
101
app/(modules)/admin/mapset-upload/_components/map/GeoPreview.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
// app/admin/upload/_components/map/GeoPreview.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import Map from "ol/Map";
|
||||||
|
import View from "ol/View";
|
||||||
|
import { Tile as TileLayer, Vector as VectorLayer } from "ol/layer";
|
||||||
|
import { OSM } from "ol/source";
|
||||||
|
import VectorSource from "ol/source/Vector";
|
||||||
|
import WKT from "ol/format/WKT";
|
||||||
|
import { Circle as CircleStyle, Fill, Stroke, Style } from "ol/style";
|
||||||
|
import { fromLonLat } from "ol/proj";
|
||||||
|
|
||||||
|
const GeoPreview = ({ features }: { features: any[] }) => {
|
||||||
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapObj = useRef<Map | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!features || features.length === 0 || !mapRef.current) return;
|
||||||
|
|
||||||
|
// Bersihkan map lama jika ada re-render strict mode
|
||||||
|
if (mapObj.current) {
|
||||||
|
mapObj.current.setTarget(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wktFormat = new WKT();
|
||||||
|
const vectorSource = new VectorSource();
|
||||||
|
|
||||||
|
features.forEach((item) => {
|
||||||
|
try {
|
||||||
|
const feature = wktFormat.readFeature(item.geometry, {
|
||||||
|
dataProjection: `EPSG:4326`,
|
||||||
|
featureProjection: "EPSG:3857",
|
||||||
|
});
|
||||||
|
vectorSource.addFeature(feature);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("WKT parse error:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const vectorLayer = new VectorLayer({
|
||||||
|
source: vectorSource,
|
||||||
|
style: (feature) => {
|
||||||
|
const type = feature.getGeometry()?.getType();
|
||||||
|
|
||||||
|
if (type === "Polygon" || type === "MultiPolygon") {
|
||||||
|
return new Style({
|
||||||
|
fill: new Fill({ color: "rgba(0, 153, 255, 0.4)" }),
|
||||||
|
stroke: new Stroke({ color: "#0099ff", width: 2 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type === "LineString" || type === "MultiLineString") {
|
||||||
|
return new Style({
|
||||||
|
stroke: new Stroke({ color: "#0099ff", width: 3 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type === "Point" || type === "MultiPoint") {
|
||||||
|
return new Style({
|
||||||
|
image: new CircleStyle({
|
||||||
|
radius: 6,
|
||||||
|
fill: new Fill({ color: "#0099ff" }),
|
||||||
|
stroke: new Stroke({ color: "#ffffff", width: 2 }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mapObj.current = new Map({
|
||||||
|
target: mapRef.current,
|
||||||
|
layers: [
|
||||||
|
new TileLayer({ source: new OSM() }),
|
||||||
|
vectorLayer,
|
||||||
|
],
|
||||||
|
view: new View({
|
||||||
|
center: fromLonLat([110, -6]),
|
||||||
|
zoom: 5,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const extent = vectorSource.getExtent();
|
||||||
|
if (extent && extent[0] !== Infinity) {
|
||||||
|
mapObj.current.getView().fit(extent, {
|
||||||
|
padding: [20, 20, 20, 20],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (mapObj.current) mapObj.current.setTarget(undefined);
|
||||||
|
};
|
||||||
|
}, [features]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={mapRef}
|
||||||
|
style={{ width: "100%", height: "100%", minHeight: "400px", border: "1px solid #ccc", borderRadius: "8px" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GeoPreview;
|
||||||
|
|
@ -0,0 +1,244 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs";
|
||||||
|
import FormMultiSelect from "@/shared/components/forms/form-multi-select";
|
||||||
|
|
||||||
|
const satupetaCategory = [
|
||||||
|
"Batas Wilayah",
|
||||||
|
"Kependudukan",
|
||||||
|
"Lingkungan Hidup",
|
||||||
|
"Pemerintah Desa",
|
||||||
|
"Pendidikan",
|
||||||
|
"Sosial",
|
||||||
|
"Pendidikan SD",
|
||||||
|
"Pariwisata Kebudayaan",
|
||||||
|
"Kesehatan",
|
||||||
|
"Ekonomi",
|
||||||
|
"Kemiskinan",
|
||||||
|
"Infrastruktur"
|
||||||
|
]
|
||||||
|
const satupetaCategoryId = [
|
||||||
|
"019a0997-5b42-7c34-9ab8-35b4765ecb39",
|
||||||
|
"0196c80b-e680-7dca-9b90-b5ebe65de70d",
|
||||||
|
"0196c80c-855f-77f9-abd0-0c8a30b8c2f5",
|
||||||
|
"0196c80c-f805-76a8-82c7-af50b794871b",
|
||||||
|
"0196c80d-228d-7e1e-9116-78ba912b812c",
|
||||||
|
"0196c80d-3f05-7750-ab2a-f58655fef6ea",
|
||||||
|
"019936a6-4a5b-719f-8d88-d2df0af5aa20",
|
||||||
|
"0196c80c-c4fc-7ea6-afc0-3672a1b44b5b",
|
||||||
|
"0196c80c-61d8-7616-9abc-550a89283a57",
|
||||||
|
"0196c809-a0b0-79fb-b597-422d716fdce8",
|
||||||
|
"0196c80b-bb09-7424-9cd5-e3ec4946c7af",
|
||||||
|
"0196c80b-8710-7577-bc28-3ce66a02f56f"
|
||||||
|
]
|
||||||
|
|
||||||
|
export const getInitialMetadata = (initialValues: any = {}) => {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
|
||||||
|
function getIndexCategoryId(value: string) {
|
||||||
|
return satupetaCategory.indexOf(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Mapping dari API Suggestion (jika ada)
|
||||||
|
title: initialValues.judul || "",
|
||||||
|
abstract: initialValues.abstrak || "",
|
||||||
|
keywords: initialValues.keyword ? (Array.isArray(initialValues.keyword) ? initialValues.keyword.join(', ') : initialValues.keyword) : "",
|
||||||
|
topicCategory: initialValues.kategori || "",
|
||||||
|
|
||||||
|
// Default Values Hardcoded Anda
|
||||||
|
mapsetCategory: satupetaCategoryId[getIndexCategoryId(initialValues.kategori_mapset)] || "",
|
||||||
|
dateCreated: today,
|
||||||
|
status: "completed",
|
||||||
|
language: "eng",
|
||||||
|
|
||||||
|
organization: "PUPR",
|
||||||
|
contactName: "Dimas",
|
||||||
|
contactEmail: "pu@gmail.com",
|
||||||
|
contactPhone: "08222222222",
|
||||||
|
|
||||||
|
// 🌐 Distribusi
|
||||||
|
downloadLink: "",
|
||||||
|
serviceLink: "",
|
||||||
|
format: "",
|
||||||
|
license: "Copyright",
|
||||||
|
|
||||||
|
// 🧭 Referensi Spasial
|
||||||
|
crs: "EPSG:4326",
|
||||||
|
geometryType: "",
|
||||||
|
xmin: "",
|
||||||
|
xmax: "",
|
||||||
|
ymin: "",
|
||||||
|
ymax: "",
|
||||||
|
|
||||||
|
// 🧾 Metadata Umum
|
||||||
|
metadataStandard: "ISO 19115:2003/19139",
|
||||||
|
metadataVersion: "1.0",
|
||||||
|
metadataUUID: "", // Nanti di-generate di useEffect agar tidak hydration error
|
||||||
|
metadataDate: "",
|
||||||
|
charset: "utf8",
|
||||||
|
rsIdentifier: "WGS 1984",
|
||||||
|
|
||||||
|
// Override dengan apapun yang ada di initialValues jika key-nya cocok
|
||||||
|
// ...initialValues
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MetadataForm({ onChange, initialValues = {} }: { onChange: (val: any) => void, initialValues?: any }) {
|
||||||
|
const [formData, setFormData] = useState(initialValues);
|
||||||
|
|
||||||
|
// Generate UUID saat mount (Client Side Only)
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (!formData.metadataUUID) {
|
||||||
|
// setFormData((prev: any) => {
|
||||||
|
// const updated = {
|
||||||
|
// ...prev,
|
||||||
|
// metadataUUID: uuidv4(),
|
||||||
|
// metadataDate: new Date().toISOString().split("T")[0],
|
||||||
|
// };
|
||||||
|
// // Penting: Kabari parent tentang update UUID ini
|
||||||
|
// onChange(updated);
|
||||||
|
// return updated;
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Cek jika UUID belum ada (hanya saat mount)
|
||||||
|
if (!formData.metadataUUID) {
|
||||||
|
const generatedUUID = uuidv4();
|
||||||
|
const generatedDate = new Date().toISOString().split("T")[0];
|
||||||
|
|
||||||
|
// 1. Siapkan data baru
|
||||||
|
const updatedData = {
|
||||||
|
...formData, // Menggunakan state saat ini
|
||||||
|
metadataUUID: generatedUUID,
|
||||||
|
metadataDate: generatedDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Update State Lokal
|
||||||
|
setFormData((prev: any) => ({
|
||||||
|
...prev,
|
||||||
|
metadataUUID: generatedUUID,
|
||||||
|
metadataDate: generatedDate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 3. Update State Parent (PENTING: Dilakukan terpisah di sini, BUKAN di dalam setFormData)
|
||||||
|
onChange(updatedData);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
const updated = { ...formData, [name]: value };
|
||||||
|
setFormData(updated);
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<Tabs defaultValue="identifikasi">
|
||||||
|
{/* <TabsList className="mb-4">
|
||||||
|
<TabsTrigger value="identifikasi">Identifikasi</TabsTrigger>
|
||||||
|
<TabsTrigger value="penanggung">Kontak</TabsTrigger>
|
||||||
|
</TabsList> */}
|
||||||
|
|
||||||
|
<TabsContent value="identifikasi" className="space-y-4">
|
||||||
|
<Input label="Judul Dataset" name="title" value={formData.title} onChange={handleChange} />
|
||||||
|
<Textarea label="Abstrak" name="abstract" value={formData.abstract} onChange={handleChange} />
|
||||||
|
<Input label="Kata Kunci (pisahkan dengan koma)" name="keywords" value={formData.keywords} onChange={handleChange} />
|
||||||
|
|
||||||
|
<SelectMultiple
|
||||||
|
label="Kategori Metadata"
|
||||||
|
name="topicCategory"
|
||||||
|
value={formData.topicCategory}
|
||||||
|
onChange={handleChange}
|
||||||
|
options={[
|
||||||
|
{label: "Biota", value: "Biota"},
|
||||||
|
{label: "Farming", value: "Farming"},
|
||||||
|
{label: "Boundaries", value: "Boundaries"},
|
||||||
|
{label: "Climatology, meteorology, atmospherea", value: "Climatology, meteorology, atmospherea"},
|
||||||
|
{label: "Economy", value: "Economy"},
|
||||||
|
{label: "Elevation", value: "Elevation"},
|
||||||
|
{label: "Environment", value: "Environment"},
|
||||||
|
{label: "Geoscientific information", value: "Geoscientific information"},
|
||||||
|
{label: "Health", value: "Health"},
|
||||||
|
{label: "Imagery base maps earth cover", value: "Imagery base maps earth cover"},
|
||||||
|
{label: "Intelligence military", value: "Intelligence military"},
|
||||||
|
{label: "Inland waters", value: "Inland waters"},
|
||||||
|
{label: "Location", value: "Location"},
|
||||||
|
{label: "Oceans", value: "Oceans"},
|
||||||
|
{label: "Planning cadastre", value: "Planning cadastre"},
|
||||||
|
{label: "Society", value: "Society"},
|
||||||
|
{label: "Structure", value: "Structure"},
|
||||||
|
{label: "Transportation", value: "Transportation"},
|
||||||
|
{label: "Utilities communication", value: "Utilities communication"}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Ganti dengan Select shadcn jika mau, ini versi native HTML agar cepat */}
|
||||||
|
<Select
|
||||||
|
label="Kategori Mapset"
|
||||||
|
name="mapsetCategory"
|
||||||
|
value={formData.mapsetCategory}
|
||||||
|
onChange={handleChange}
|
||||||
|
options={satupetaCategory}
|
||||||
|
optValue={satupetaCategoryId}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* <TabsContent value="penanggung" className="space-y-4">
|
||||||
|
<Input label="Organisasi" name="organization" value={formData.organization} onChange={handleChange} />
|
||||||
|
<Input label="Nama Kontak" name="contactName" value={formData.contactName} onChange={handleChange} />
|
||||||
|
<Input label="Email" name="contactEmail" value={formData.contactEmail} onChange={handleChange} />
|
||||||
|
</TabsContent> */}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper Components (Bisa dipisah file atau inline)
|
||||||
|
function Input({ label, name, value, onChange }: any) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{label} <span className="text-red-500">*</span></label>
|
||||||
|
<input className="w-full border rounded p-2 text-sm" name={name} value={value} onChange={onChange} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function Textarea({ label, name, value, onChange }: any) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{label} <span className="text-red-500">*</span></label>
|
||||||
|
<textarea className="w-full border rounded p-2 text-sm" rows={3} name={name} value={value} onChange={onChange} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function Select({ label, name, value, onChange, options, optValue }: any) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{label} <span className="text-red-500">*</span></label>
|
||||||
|
<select className="w-full border rounded p-2 text-sm bg-white" name={name} value={value} onChange={onChange}>
|
||||||
|
{options.map((opt: string, i: number) => <option key={i} value={optValue[i]}>{opt}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function SelectMultiple({ label, name, value, onChange, options = []}: any) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-semibold text-gray-700 mb-1">
|
||||||
|
{label}{" "}<span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<FormMultiSelect
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
// app/admin/upload/_components/map/StylePreview.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import Map from "ol/Map";
|
||||||
|
import View from "ol/View";
|
||||||
|
import VectorLayer from "ol/layer/Vector";
|
||||||
|
import VectorSource from "ol/source/Vector";
|
||||||
|
import TileLayer from "ol/layer/Tile";
|
||||||
|
import OSM from "ol/source/OSM";
|
||||||
|
import WKT from "ol/format/WKT";
|
||||||
|
import Feature from "ol/Feature";
|
||||||
|
|
||||||
|
import Style from "ol/style/Style";
|
||||||
|
import Fill from "ol/style/Fill";
|
||||||
|
import Stroke from "ol/style/Stroke";
|
||||||
|
import CircleStyle from "ol/style/Circle";
|
||||||
|
import { defaults as defaultControls } from 'ol/control';
|
||||||
|
|
||||||
|
// Import Geostyler dynamic/client side only
|
||||||
|
import SldStyleParser from "geostyler-sld-parser";
|
||||||
|
import OlStyleParser from "geostyler-openlayers-parser";
|
||||||
|
|
||||||
|
// ... (Kode helper parseWKT, normalizeKey, createFeatures sama seperti sebelumnya) ...
|
||||||
|
const wkt = new WKT();
|
||||||
|
|
||||||
|
function parseWKT(str: string) {
|
||||||
|
try {
|
||||||
|
return wkt.readGeometry(str, {
|
||||||
|
dataProjection: "EPSG:4326",
|
||||||
|
featureProjection: "EPSG:3857",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKey(key: string) {
|
||||||
|
return key.replace(/[^a-zA-Z0-9_]/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFeatures(data: any[]) {
|
||||||
|
if (!data) return [];
|
||||||
|
return data.map((row) => {
|
||||||
|
const geometry = parseWKT(row.geometry);
|
||||||
|
if (!geometry) return null; // Handle invalid geometry
|
||||||
|
|
||||||
|
const feat = new Feature();
|
||||||
|
Object.entries(row).forEach(([key, value]) => {
|
||||||
|
if (key === "geometry") return;
|
||||||
|
feat.set(key, value);
|
||||||
|
const normalized = normalizeKey(key).toUpperCase();
|
||||||
|
if (normalized !== key) {
|
||||||
|
feat.set(normalized, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
feat.setGeometry(geometry);
|
||||||
|
return feat;
|
||||||
|
}).filter((f): f is Feature => f !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultStyle = new Style({
|
||||||
|
image: new CircleStyle({
|
||||||
|
radius: 4,
|
||||||
|
fill: new Fill({ color: "#3388ff" }),
|
||||||
|
stroke: new Stroke({ color: "#333", width: 1 }),
|
||||||
|
}),
|
||||||
|
stroke: new Stroke({ color: "#3388ff", width: 2 }),
|
||||||
|
fill: new Fill({ color: "rgba(51,136,255,0.5)" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const SpatialStylePreview = ({ data, styleConfig }: { data: any[], styleConfig: any }) => {
|
||||||
|
const mapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapObj = useRef<Map | null>(null);
|
||||||
|
const vectorLayer = useRef<VectorLayer<VectorSource> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current || !data) return;
|
||||||
|
|
||||||
|
if (mapObj.current) mapObj.current.setTarget(undefined);
|
||||||
|
|
||||||
|
const features = createFeatures(data);
|
||||||
|
const vectorSource = new VectorSource({ features });
|
||||||
|
|
||||||
|
vectorLayer.current = new VectorLayer({
|
||||||
|
source: vectorSource,
|
||||||
|
style: defaultStyle,
|
||||||
|
});
|
||||||
|
|
||||||
|
mapObj.current = new Map({
|
||||||
|
target: mapRef.current,
|
||||||
|
controls: defaultControls({ attribution: false, zoom: true }),
|
||||||
|
layers: [
|
||||||
|
new TileLayer({ source: new OSM() }),
|
||||||
|
vectorLayer.current,
|
||||||
|
],
|
||||||
|
view: new View({
|
||||||
|
center: [12600000, -830000], // Sesuaikan center default Indonesia
|
||||||
|
zoom: 5,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto zoom to extent
|
||||||
|
const extent = vectorSource.getExtent();
|
||||||
|
if (extent && extent[0] !== Infinity) {
|
||||||
|
mapObj.current.getView().fit(extent, { padding: [20, 20, 20, 20] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => { if (mapObj.current) mapObj.current.setTarget(undefined); };
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// Apply SLD Logic
|
||||||
|
useEffect(() => {
|
||||||
|
if (!vectorLayer.current) return;
|
||||||
|
if (!styleConfig || styleConfig.styleType !== "sld") {
|
||||||
|
vectorLayer.current.setStyle(defaultStyle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const applySLD = async () => {
|
||||||
|
try {
|
||||||
|
const sldParser = new SldStyleParser();
|
||||||
|
const olParser = new OlStyleParser();
|
||||||
|
const sldResult = await sldParser.readStyle(styleConfig.sldContent);
|
||||||
|
if (!sldResult.output) throw new Error("Empty style");
|
||||||
|
|
||||||
|
const olResult = await olParser.writeStyle(sldResult.output);
|
||||||
|
const olStyle = olResult.output;
|
||||||
|
|
||||||
|
if (typeof olStyle === "function") {
|
||||||
|
vectorLayer.current?.setStyle((f, r) => olStyle(f, r));
|
||||||
|
} else {
|
||||||
|
vectorLayer.current?.setStyle(olStyle);
|
||||||
|
}
|
||||||
|
vectorLayer.current?.getSource()?.changed();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("SLD parsing failed", err);
|
||||||
|
vectorLayer.current?.setStyle(defaultStyle);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
applySLD();
|
||||||
|
}, [styleConfig]);
|
||||||
|
|
||||||
|
return <div ref={mapRef} className="w-full h-full rounded-lg border shadow-sm min-h-[400px]" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpatialStylePreview;
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
// app/admin/upload/_components/map/StylingLayers.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/shared/components/ui/tabs";
|
||||||
|
import CustomLayerStyle from "./CustomLayerStyle"; // ⬅️ Import Komponen Baru
|
||||||
|
|
||||||
|
function normalizeBase64(xmlString:any) {
|
||||||
|
return xmlString.replace(
|
||||||
|
/xlink:href="base64:([^"?]+)(\?[^"]*)?"/g,
|
||||||
|
(_: any, base64Content: any) => {
|
||||||
|
return `xlink:href="data:image/svg+xml;base64,${base64Content}"`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const StylingLayers = ({
|
||||||
|
data,
|
||||||
|
geometryType,
|
||||||
|
onSubmit,
|
||||||
|
geosStyle
|
||||||
|
}: { data: any, geometryType: string, onSubmit: (val: any) => void, geosStyle: any[] }) => {
|
||||||
|
|
||||||
|
// Ubah default tab ke 'custom' agar user langsung bisa edit warna
|
||||||
|
const [activeTab, setActiveTab] = useState("custom");
|
||||||
|
const [parsedSld, setParsedSld] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full bg-white flex flex-col">
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full flex-1 flex flex-col">
|
||||||
|
<TabsList className="grid grid-cols-2 w-full mb-4">
|
||||||
|
{/* 🔥 Aktifkan Tab Custom */}
|
||||||
|
<TabsTrigger value="custom">Custom</TabsTrigger>
|
||||||
|
<TabsTrigger value="upload">Upload SLD</TabsTrigger>
|
||||||
|
{/* <TabsTrigger value="geoserver">List Style</TabsTrigger> */}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 🔥 Tambahkan Content Tab Custom */}
|
||||||
|
<TabsContent value="custom" className="flex-1 overflow-auto h-full">
|
||||||
|
<CustomLayerStyle
|
||||||
|
data={data}
|
||||||
|
geometryType={geometryType}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="upload" className="flex-1 overflow-auto">
|
||||||
|
<div className="p-4 h-full flex flex-col">
|
||||||
|
<div className="p-4 border rounded-lg text-center">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Upload File SLD</h3>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500 mb-3">
|
||||||
|
Unggah file .sld untuk mengganti style layer.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".sld"
|
||||||
|
className="block w-full text-sm file:mr-4 file:px-4 file:py-2
|
||||||
|
file:rounded-md file:border file:bg-gray-100
|
||||||
|
file:hover:bg-gray-200 cursor-pointer"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = () => {
|
||||||
|
const sld = normalizeBase64(reader.result)
|
||||||
|
setParsedSld(sld)
|
||||||
|
onSubmit({
|
||||||
|
styleType: "sld",
|
||||||
|
sldContent: sld,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="pt-4 border-t mt-auto">
|
||||||
|
<Button
|
||||||
|
className="w-full disabled:bg-gray-400"
|
||||||
|
onClick={() =>
|
||||||
|
onSubmit({
|
||||||
|
styleType: "sld",
|
||||||
|
sldContent: parsedSld,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!parsedSld}
|
||||||
|
>
|
||||||
|
Terapkan Style
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="geoserver" className="flex-1 overflow-auto">
|
||||||
|
{/* ... (Konten List Style tetap sama) ... */}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StylingLayers;
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface TableProps {
|
||||||
|
title: string;
|
||||||
|
columns: string[];
|
||||||
|
rows: any[];
|
||||||
|
total: number;
|
||||||
|
limit?: number;
|
||||||
|
variant?: "preview" | "warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
function Table({ title, columns, rows, total, limit = 100, variant = "preview" }: TableProps) {
|
||||||
|
const displayedRows = rows.slice(0, limit);
|
||||||
|
|
||||||
|
const shorten = (text: any, max = 80) => {
|
||||||
|
if (!text) return "—";
|
||||||
|
const strText = String(text);
|
||||||
|
return strText.length > max ? strText.slice(0, max) + "..." : strText;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="overflow-x-auto border border-gray-200 rounded-lg shadow-sm bg-white">
|
||||||
|
<table className="min-w-max text-sm text-gray-800 w-full">
|
||||||
|
<thead
|
||||||
|
className={`border-b ${
|
||||||
|
variant === "warning" ? "bg-red-50" : "bg-gray-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col}
|
||||||
|
className="px-4 py-3 text-left font-medium text-gray-700 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{displayedRows.length > 0 ? (
|
||||||
|
displayedRows.map((row, idx) => (
|
||||||
|
<tr
|
||||||
|
key={idx}
|
||||||
|
className={`border-t ${
|
||||||
|
variant === "warning"
|
||||||
|
? "bg-red-50/50 hover:bg-red-100/50"
|
||||||
|
: "even:bg-gray-50 hover:bg-blue-50"
|
||||||
|
} transition-colors`}
|
||||||
|
>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td
|
||||||
|
key={col}
|
||||||
|
className="px-4 py-2 border-t border-gray-100 whitespace-nowrap max-w-[250px] overflow-hidden text-ellipsis"
|
||||||
|
title={String(row[col] ?? "")}
|
||||||
|
>
|
||||||
|
{row[col] !== null && row[col] !== undefined && row[col] !== "" ? (
|
||||||
|
col === "geometry" ? (
|
||||||
|
shorten(row[col], 50)
|
||||||
|
) : (
|
||||||
|
shorten(row[col], 80)
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-300">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="text-center text-gray-500 py-6 italic"
|
||||||
|
>
|
||||||
|
Tidak ada data yang dapat ditampilkan
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center px-1 py-2 text-xs text-gray-500">
|
||||||
|
<p>
|
||||||
|
Menampilkan {Math.min(limit, displayedRows.length)} dari {total} baris.
|
||||||
|
</p>
|
||||||
|
{variant === "preview" && (
|
||||||
|
<p className="italic text-gray-400">
|
||||||
|
Hanya menampilkan cuplikan data
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TablePreviewProps {
|
||||||
|
result: {
|
||||||
|
columns?: string[];
|
||||||
|
preview?: any[];
|
||||||
|
geometry_valid?: number;
|
||||||
|
geometry_empty?: number;
|
||||||
|
warning_rows?: any[];
|
||||||
|
[key: string]: any;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TablePreview({ result }: TablePreviewProps) {
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
const {
|
||||||
|
columns = [],
|
||||||
|
preview = [],
|
||||||
|
geometry_valid = 0,
|
||||||
|
geometry_empty = 0,
|
||||||
|
warning_rows = [],
|
||||||
|
} = result;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-6">
|
||||||
|
{/* 1. WARNING TABLE (Jika ada data yang tidak valid geometrinya) */}
|
||||||
|
{warning_rows?.length > 0 && (
|
||||||
|
<div className="border-l-4 border-yellow-400 pl-4 py-2 bg-yellow-50 rounded-r-lg">
|
||||||
|
<h3 className="font-semibold text-yellow-800 mb-1">
|
||||||
|
⚠️ Periksa Data Wilayah
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-yellow-700 mb-3">
|
||||||
|
Sistem tidak dapat mendeteksi geometri untuk data di bawah ini. Pastikan nama wilayah sesuai referensi.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
title="Data Perlu Diperiksa"
|
||||||
|
columns={columns}
|
||||||
|
rows={warning_rows}
|
||||||
|
total={geometry_empty}
|
||||||
|
limit={100}
|
||||||
|
variant="warning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 2. PREVIEW TABLE (Data Valid) */}
|
||||||
|
<div>
|
||||||
|
{geometry_valid > 0 ? (
|
||||||
|
<Table
|
||||||
|
title="Cuplikan Data"
|
||||||
|
columns={columns}
|
||||||
|
rows={preview}
|
||||||
|
total={geometry_valid}
|
||||||
|
limit={10} // Tampilkan 10 baris saja agar tidak terlalu panjang
|
||||||
|
variant="preview"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
!warning_rows?.length && <div className="text-gray-500 italic">Tidak ada data preview tersedia.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
app/(modules)/admin/mapset-upload/_components/step-1-upload.tsx
Normal file
124
app/(modules)/admin/mapset-upload/_components/step-1-upload.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
// app/admin/upload/_components/step-1-upload.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useUploadLogic } from "../_hooks/use-upload";
|
||||||
|
import { FileUpload } from "@/shared/components/file-upload"; // Pastikan path import benar
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@/shared/components/ui/select";
|
||||||
|
import { AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
export default function StepUpload() {
|
||||||
|
const {
|
||||||
|
file,
|
||||||
|
handleFileSelect,
|
||||||
|
handleUploadProcess,
|
||||||
|
loading,
|
||||||
|
fileDesc,
|
||||||
|
setFileDesc,
|
||||||
|
sheetNames,
|
||||||
|
selectedSheet,
|
||||||
|
setSelectedSheet,
|
||||||
|
// Kita butuh fungsi reset file di hook, anggap namanya resetFile
|
||||||
|
setState
|
||||||
|
} = useUploadLogic();
|
||||||
|
|
||||||
|
const handleRemoveFile = () => {
|
||||||
|
// Reset state di context/hook
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
file: null,
|
||||||
|
sheetCount: 0,
|
||||||
|
sheetNames: [],
|
||||||
|
selectedPages: []
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto py-8">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800">Upload Data Baru</h1>
|
||||||
|
<p className="text-slate-500 text-sm mt-1">Unggah file CSV, Excel, PDF, atau ZIP spasial Anda.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border shadow-sm p-6 space-y-6">
|
||||||
|
|
||||||
|
{/* 1. Component File Upload Custom */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>File Sumber</Label>
|
||||||
|
<FileUpload
|
||||||
|
// Kirim file yang ada di state agar sinkron (optional jika komponen controlled)
|
||||||
|
filePreview={file}
|
||||||
|
fileName={file?.name}
|
||||||
|
// Saat drop, kirim raw file ke logic
|
||||||
|
onFileSelect={(f) => handleFileSelect(f)}
|
||||||
|
// Saat remove, reset state
|
||||||
|
onRemove={handleRemoveFile}
|
||||||
|
description=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Logic Form Tambahan (Hanya muncul jika file ada) */}
|
||||||
|
{file && (
|
||||||
|
<div className="space-y-5 animate-in fade-in slide-in-from-top-4 duration-300">
|
||||||
|
|
||||||
|
{/* Input Deskripsi */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="desc">Deskripsi File <span className="text-red-500">*</span></Label>
|
||||||
|
<Input
|
||||||
|
id="desc"
|
||||||
|
value={fileDesc}
|
||||||
|
onChange={(e) => setFileDesc(e.target.value)}
|
||||||
|
placeholder="Contoh: Data Curah Hujan Tahun 2024"
|
||||||
|
className="bg-slate-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sheet Selector (Khusus Excel) */}
|
||||||
|
{sheetNames && sheetNames.length > 1 && (
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-100 rounded-lg space-y-3">
|
||||||
|
<div className="flex items-start gap-2 text-blue-700">
|
||||||
|
<AlertCircle className="w-5 h-5 mt-0.5" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-semibold">File Excel memiliki banyak sheet.</p>
|
||||||
|
<p>Silakan pilih sheet mana yang ingin diproses.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select value={selectedSheet || ""} onValueChange={setSelectedSheet}>
|
||||||
|
<SelectTrigger className="w-full bg-white border-blue-200">
|
||||||
|
<SelectValue placeholder="Pilih Sheet..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sheetNames.map((name: string) => (
|
||||||
|
<SelectItem key={name} value={name}>{name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tombol Aksi */}
|
||||||
|
<div className="pt-4 flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="w-full md:w-auto min-w-[150px]"
|
||||||
|
onClick={() => handleUploadProcess(selectedSheet || undefined)}
|
||||||
|
disabled={loading || !fileDesc || (sheetNames.length > 1 && !selectedSheet)}
|
||||||
|
>
|
||||||
|
{loading ? "Memproses..." : "Lanjut Proses →"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
// app/admin/upload/_components/step-2-pdf-viewer.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePdfViewer } from "../_hooks/use-pdf-viewer";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Checkbox } from "@/shared/components/ui/checkbox";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export default function StepPdfViewer() {
|
||||||
|
const { pages, loading, localSelectedPages, toggleSelectPage, handleProcessPdf } = usePdfViewer();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full gap-4">
|
||||||
|
{/* Sidebar Kiri */}
|
||||||
|
<div className="w-64 border-r pr-4 overflow-y-auto">
|
||||||
|
<h2 className="font-semibold mb-4">Pilih Halaman</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{pages.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.pageNum}
|
||||||
|
className={`flex items-center space-x-2 p-2 rounded cursor-pointer ${
|
||||||
|
localSelectedPages.includes(p.pageNum) ? "bg-blue-50" : "hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleSelectPage(p.pageNum)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={localSelectedPages.includes(p.pageNum)}
|
||||||
|
onCheckedChange={() => toggleSelectPage(p.pageNum)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Halaman {p.pageNum}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 pt-4 border-t sticky bottom-0 bg-white">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleProcessPdf}
|
||||||
|
disabled={loading || localSelectedPages.length === 0}
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="animate-spin mr-2" /> : null}
|
||||||
|
Proses Halaman
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content (Preview Images) */}
|
||||||
|
<div className="flex-1 overflow-y-auto bg-gray-100 p-4 rounded-lg">
|
||||||
|
{loading && pages.length === 0 && (
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<Loader2 className="animate-spin h-8 w-8 text-blue-500" />
|
||||||
|
<span className="ml-2">Merender PDF...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{pages.map((p) => (
|
||||||
|
<motion.div
|
||||||
|
key={p.pageNum}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="bg-white p-2 shadow rounded"
|
||||||
|
>
|
||||||
|
<img src={p.imageUrl} alt={`Page ${p.pageNum}`} className="w-full h-auto" />
|
||||||
|
<p className="text-center text-xs text-gray-500 mt-2">Halaman {p.pageNum}</p>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
// app/admin/upload/_components/step-3-table-picker.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useUploadContext } from "../_context/upload-context";
|
||||||
|
import uploadApi from "@/shared/services/map-upload";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function StepTablePicker() {
|
||||||
|
const { state, setState, goToStep } = useUploadContext();
|
||||||
|
const [selectedTableIdx, setSelectedTableIdx] = useState<number | null>(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const tables = state.result?.data.tables || [];
|
||||||
|
const activeTable = selectedTableIdx !== null ? tables[selectedTableIdx] : null;
|
||||||
|
|
||||||
|
const handleNext = async () => {
|
||||||
|
if (!activeTable) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Kirim tabel yang dipilih kembali ke backend untuk diproses final
|
||||||
|
const res = await uploadApi.processPdf(activeTable, state.file?.name || "doc.pdf", state.fileDesc);
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, result: res })); // Update result dengan hasil final (metadata, preview, dll)
|
||||||
|
goToStep("VALIDATE", { replace: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "Gagal memproses tabel");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full gap-4">
|
||||||
|
{/* Sidebar List Tabel */}
|
||||||
|
<div className="w-64 border-r pr-4">
|
||||||
|
<h3 className="font-semibold mb-3">Daftar Tabel Terdeteksi</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tables.map((t: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
onClick={() => setSelectedTableIdx(idx)}
|
||||||
|
className={`p-3 rounded border cursor-pointer text-sm ${
|
||||||
|
selectedTableIdx === idx ? "bg-blue-50 border-blue-500" : "bg-white hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Tabel {t.title || idx + 1}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleNext} disabled={loading} className="w-full mt-4">
|
||||||
|
{loading ? "Memproses..." : "Proses Tabel →"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Data Tabel */}
|
||||||
|
<div className="flex-1 overflow-auto border rounded bg-white p-4">
|
||||||
|
{activeTable ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-xs border-collapse border">
|
||||||
|
<thead className="bg-gray-100">
|
||||||
|
<tr>
|
||||||
|
{activeTable.columns?.map((col: string, i: number) => (
|
||||||
|
<th key={i} className="border p-2 text-left">{col}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{/* {activeTable.rows?.slice(0, 20).map((row: any[], i: number) => ( */}
|
||||||
|
{activeTable.rows?.map((row: any[], i: number) => (
|
||||||
|
<tr key={i} className="hover:bg-gray-50">
|
||||||
|
{activeTable.columns?.map((_: any, colIdx: number) => (
|
||||||
|
<td key={colIdx} className="border p-2">{row[colIdx]}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/* <p className="text-xs text-gray-500 mt-2">Menampilkan 20 baris pertama.</p> */}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-400 mt-10">Pilih tabel untuk melihat preview</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import dynamic from "next/dynamic"; // ⬅️ PENTING
|
||||||
|
import { useUploadContext } from "../_context/upload-context";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import MetadataForm, { getInitialMetadata } from "./map/MetadataForm";
|
||||||
|
import TablePreview from "./map/TablePreview";
|
||||||
|
import { log } from "node:console";
|
||||||
|
import uploadApi from "@/shared/services/map-upload";
|
||||||
|
import { useUploadLogic } from "../_hooks/use-upload";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// 🔥 IMPORT DYNAMIC UNTUK KOMPONEN PETA
|
||||||
|
const SpatialStylePreview = dynamic(() => import("./map/StylePreview"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div className="h-[400px] bg-slate-100 flex items-center justify-center">Loading Map...</div>
|
||||||
|
});
|
||||||
|
|
||||||
|
const StylingLayers = dynamic(() => import("./map/StylingLayers"), {
|
||||||
|
ssr: false
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function StepValidate() {
|
||||||
|
const { state, setState, goToStep } = useUploadContext();
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
handleSaveToDatabase
|
||||||
|
} = useUploadLogic();
|
||||||
|
|
||||||
|
// State lokal untuk Metadata & Style
|
||||||
|
// const [metadata, setMetadata] = useState(state.result?.data.metadata_suggest || {});
|
||||||
|
const [metadata, setMetadata] = useState(() =>
|
||||||
|
getInitialMetadata(state.result?.data.metadata_suggest || {})
|
||||||
|
);
|
||||||
|
const [styleConfig, setStyleConfig] = useState({"styleType": "sld","sldContent": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<StyledLayerDescriptor version=\"1.0.0\"\n xmlns=\"http://www.opengis.net/sld\"\n xmlns:ogc=\"http://www.opengis.net/ogc\"\n xmlns:xlink=\"http://www.w3.org/1999/xlink\"\n xmlns:se=\"http://www.opengis.net/se\"\n xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n xsi:schemaLocation=\"http://www.opengis.net/sld\n http://schemas.opengis.net/sld/1.0.0/StyledLayerDescriptor.xsd\">\n\n <NamedLayer>\n <Name>layer</Name>\n <UserStyle>\n <FeatureTypeStyle>\n <Rule>\n \n <PolygonSymbolizer>\n <Fill>\n <CssParameter name=\"fill\">#3388ff</CssParameter>\n <CssParameter name=\"fill-opacity\">0.5</CssParameter>\n </Fill>\n <Stroke>\n <CssParameter name=\"stroke\">#232323</CssParameter>\n <CssParameter name=\"stroke-width\">1</CssParameter>\n </Stroke>\n </PolygonSymbolizer>\n \n </Rule>\n </FeatureTypeStyle>\n </UserStyle>\n </NamedLayer>\n</StyledLayerDescriptor>\n"});
|
||||||
|
|
||||||
|
// Ambil dummy geosStyle atau fetch dari API
|
||||||
|
const geosStyle = [{name: "Style A"}, {name: "Style B"}];
|
||||||
|
|
||||||
|
// const handleConfirm = async () => {
|
||||||
|
// const table = state.result?.data;
|
||||||
|
// const data = {
|
||||||
|
// title: metadata.title,
|
||||||
|
// columns: table.columns,
|
||||||
|
// rows: table.preview,
|
||||||
|
// author: metadata,
|
||||||
|
// style: styleConfig.sldContent
|
||||||
|
// }
|
||||||
|
// handleSaveToDatabase
|
||||||
|
// // Gabungkan metadata + style + data result -> Kirim ke API saveToDatabase
|
||||||
|
// // goToStep("SUCCESS");
|
||||||
|
// };
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
// Validasi Frontend
|
||||||
|
if (!metadata.title) {
|
||||||
|
toast.warning("Judul data wajib diisi.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = state.result?.data;
|
||||||
|
const data = {
|
||||||
|
title: metadata.title,
|
||||||
|
columns: table.columns,
|
||||||
|
rows: table.preview,
|
||||||
|
author: metadata,
|
||||||
|
style: styleConfig.sldContent
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleSaveToDatabase(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col gap-6">
|
||||||
|
<h2 className="text-xl font-bold">Validasi & Styling</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||||
|
{/* Bagian Peta Preview */}
|
||||||
|
<div className="lg:col-span-8 h-[500px] border rounded-lg overflow-hidden relative">
|
||||||
|
<SpatialStylePreview
|
||||||
|
data={state.result?.data.preview || []}
|
||||||
|
styleConfig={styleConfig}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bagian Kontrol Style */}
|
||||||
|
<div className="lg:col-span-4 h-[500px] border rounded-lg p-2">
|
||||||
|
<StylingLayers
|
||||||
|
data={state.result?.data.preview}
|
||||||
|
geometryType={state.result?.data.geometry_type}
|
||||||
|
onSubmit={setStyleConfig}
|
||||||
|
geosStyle={geosStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bagian Metadata */}
|
||||||
|
<div className="">
|
||||||
|
<h3 className="font-bold mb-4">{state.result?.data.file_name}</h3>
|
||||||
|
|
||||||
|
{/* <MetadataForm
|
||||||
|
initialValues={metadata}
|
||||||
|
onChange={setMetadata}
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||||
|
{/* Bagian Tabel Preview */}
|
||||||
|
<div className="lg:col-span-8 h-[500px] border rounded-lg p-0">
|
||||||
|
<h4 className="font-bold m-2">Cuplikan Data</h4>
|
||||||
|
{/* <SpatialStylePreview
|
||||||
|
data={state.result?.data.preview || []}
|
||||||
|
styleConfig={styleConfig}
|
||||||
|
/> */}
|
||||||
|
<TablePreview result={state.result?.data} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bagian Info Dataset */}
|
||||||
|
<div className="lg:col-span-4 h-[500px] border rounded-lg p-2">
|
||||||
|
<div className="mb-2 flex align-center justify-between">
|
||||||
|
<h4 className="m-0 font-bold">Info Dataset</h4>
|
||||||
|
<h4 className="m-0 text-gray-500 italic"><span className="text-red-500">*</span>AI Generate</h4>
|
||||||
|
</div>
|
||||||
|
<MetadataForm
|
||||||
|
initialValues={metadata}
|
||||||
|
// onChange={setMetadata}
|
||||||
|
onChange={(updatedData) => setMetadata(updatedData)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-4 pb-10">
|
||||||
|
<Button variant="outline" onClick={() => goToStep("UPLOAD")}>Batal</Button>
|
||||||
|
<Button onClick={handleConfirm} disabled={loading}>
|
||||||
|
{loading ? "Menyimpan..." : "Simpan & Publikasi"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
274
app/(modules)/admin/mapset-upload/_components/step-5-success.tsx
Normal file
274
app/(modules)/admin/mapset-upload/_components/step-5-success.tsx
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useUploadContext } from "../_context/upload-context";
|
||||||
|
import { Button } from "@/shared/components/ui/button"; // Sesuaikan dengan UI kit Anda
|
||||||
|
// Pastikan Anda memiliki variabel env atau konstanta untuk WS_URL
|
||||||
|
// Jika belum ada, bisa ganti sementara dengan string hardcoded atau process.env
|
||||||
|
const WS_URL = process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:8000";
|
||||||
|
|
||||||
|
export default function StepSuccess() {
|
||||||
|
const { state, reset } = useUploadContext();
|
||||||
|
const { validatedData } = state;
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const geomIcons: Record<string, string> = {
|
||||||
|
Point: "📍",
|
||||||
|
MultiPoint: "🔹",
|
||||||
|
LineString: "📏",
|
||||||
|
MultiLineString: "🛣️",
|
||||||
|
Polygon: "⬛",
|
||||||
|
MultiPolygon: "🗾",
|
||||||
|
GeometryCollection: "🧩",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROCESS_STEPS = [
|
||||||
|
{ key: "upload", label: "Upload data" },
|
||||||
|
{ key: "cleansing", label: "Cleansing data" },
|
||||||
|
{ key: "publish_geoserver", label: "Publish GeoServer" },
|
||||||
|
{ key: "done", label: "Publish GeoNetwork" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const INITIAL_STEP_STATUS: Record<string, "pending" | "running" | "done" | "error"> = {
|
||||||
|
upload: "done",
|
||||||
|
cleansing: "pending",
|
||||||
|
publish_geoserver: "pending",
|
||||||
|
done: "pending",
|
||||||
|
};
|
||||||
|
|
||||||
|
const [stepStatus, setStepStatus] = useState(INITIAL_STEP_STATUS);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
|
// Jika user refresh halaman dan data hilang, kembalikan ke awal
|
||||||
|
useEffect(() => {
|
||||||
|
if (!validatedData) {
|
||||||
|
router.replace("/admin/mapset-upload?step=VALIDATE");
|
||||||
|
}
|
||||||
|
}, [validatedData, router]);
|
||||||
|
|
||||||
|
// WebSocket Logic
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (!validatedData?.job_id || validatedData.job_status === "done") return;
|
||||||
|
|
||||||
|
// // Construct WS URL
|
||||||
|
// const wsUrl = `${WS_URL}/ws/job/${validatedData.job_id}`;
|
||||||
|
// const ws = new WebSocket(wsUrl);
|
||||||
|
// wsRef.current = ws;
|
||||||
|
|
||||||
|
// ws.onopen = () => {
|
||||||
|
// console.log("WS connected:", validatedData.job_id);
|
||||||
|
// };
|
||||||
|
|
||||||
|
// ws.onmessage = (event) => {
|
||||||
|
// try {
|
||||||
|
// const data = JSON.parse(event.data);
|
||||||
|
// const finishedStep = data.step;
|
||||||
|
|
||||||
|
// setStepStatus((prev) => {
|
||||||
|
// const updated = { ...prev };
|
||||||
|
// const stepIndex = PROCESS_STEPS.findIndex((s) => s.key === finishedStep);
|
||||||
|
|
||||||
|
// // 1️⃣ step yang dikirim WS → DONE
|
||||||
|
// if (stepIndex >= 0) {
|
||||||
|
// updated[finishedStep] = "done";
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // 2️⃣ step setelahnya → RUNNING
|
||||||
|
// const nextStep = PROCESS_STEPS[stepIndex + 1];
|
||||||
|
// if (nextStep) {
|
||||||
|
// updated[nextStep.key] = "running";
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // 3️⃣ step setelah itu → PENDING
|
||||||
|
// PROCESS_STEPS.slice(stepIndex + 2).forEach((s) => {
|
||||||
|
// if (updated[s.key] !== "done") {
|
||||||
|
// updated[s.key] = "pending";
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
// return updated;
|
||||||
|
// });
|
||||||
|
|
||||||
|
// // 🔥 AUTO CLOSE WS JIKA SELESAI
|
||||||
|
// if (finishedStep === "done") {
|
||||||
|
// setTimeout(() => {
|
||||||
|
// wsRef.current?.close();
|
||||||
|
// }, 2000);
|
||||||
|
// }
|
||||||
|
// } catch (e) {
|
||||||
|
// console.error("Error parsing WS message", e);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// ws.onerror = (err) => {
|
||||||
|
// console.error("WS error", err);
|
||||||
|
// };
|
||||||
|
|
||||||
|
// return () => {
|
||||||
|
// ws.close();
|
||||||
|
// };
|
||||||
|
// }, [validatedData]);
|
||||||
|
|
||||||
|
// Render Helpers
|
||||||
|
const Spinner = () => (
|
||||||
|
<span className="inline-block w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderIcon = (status: string) => {
|
||||||
|
if (status === "running") return <Spinner />;
|
||||||
|
if (status === "done") return "✔";
|
||||||
|
if (status === "error") return "❌";
|
||||||
|
return "⬜";
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!validatedData) return null; // Prevent render if redirecting
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto text-center py-10">
|
||||||
|
<h1 className="text-3xl font-bold text-green-600 mb-4">✅ Upload Berhasil!</h1>
|
||||||
|
<p className="text-gray-700 mb-8">
|
||||||
|
Data Anda berhasil disimpan ke database.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="relative border border-gray-200 bg-gradient-to-b from-white to-gray-50 rounded-2xl shadow-md p-8 mb-10 text-left overflow-hidden">
|
||||||
|
{/* Background Accents */}
|
||||||
|
<div className="absolute top-0 right-0 w-32 h-32 bg-green-100 rounded-full blur-3xl opacity-50 pointer-events-none"></div>
|
||||||
|
<div className="absolute bottom-0 left-0 w-32 h-32 bg-blue-100 rounded-full blur-3xl opacity-50 pointer-events-none"></div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-6 relative z-10">
|
||||||
|
<div className="p-2 bg-green-100 text-green-600 rounded-full shadow-inner">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth="2"
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-6 h-6"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M4.5 12.75l6 6 9-13.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800 tracking-tight">
|
||||||
|
Ringkasan Hasil Upload
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 relative z-10">
|
||||||
|
{validatedData.table_name && (
|
||||||
|
<div className="flex justify-between items-center bg-gray-50 px-4 py-3 rounded-lg border border-gray-200 hover:shadow-sm transition">
|
||||||
|
<span className="text-gray-600 font-medium">📁 Nama Tabel</span>
|
||||||
|
<span className="text-gray-900 font-semibold">{validatedData.table_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{validatedData.total_rows && (
|
||||||
|
<div className="flex justify-between items-center bg-gray-50 px-4 py-3 rounded-lg border border-gray-200 hover:shadow-sm transition">
|
||||||
|
<span className="text-gray-600 font-medium">📊 Jumlah Baris</span>
|
||||||
|
<span className="text-gray-900 font-semibold">
|
||||||
|
{validatedData.total_rows.toLocaleString()} data
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{validatedData.geometry_type && (
|
||||||
|
<div className="flex justify-between items-center bg-gray-50 px-4 py-3 rounded-lg border border-gray-200 hover:shadow-sm transition">
|
||||||
|
<span className="text-gray-600 font-medium">🧭 Jenis Geometry</span>
|
||||||
|
<span className="text-gray-900 font-semibold">
|
||||||
|
{Array.isArray(validatedData.geometry_type)
|
||||||
|
? validatedData.geometry_type.map((g: string) => `${geomIcons[g] || "❓"} ${g}`).join(", ")
|
||||||
|
: validatedData.geometry_type
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{validatedData.upload_time && (
|
||||||
|
<div className="flex justify-between items-center bg-gray-50 px-4 py-3 rounded-lg border border-gray-200 hover:shadow-sm transition">
|
||||||
|
<span className="text-gray-600 font-medium">🕒 Waktu Upload</span>
|
||||||
|
<span className="text-gray-900 font-semibold">
|
||||||
|
{new Date(validatedData.upload_time).toLocaleString("id-ID", {
|
||||||
|
dateStyle: "full",
|
||||||
|
timeStyle: "short",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PROGRESS STEPS (WS LIVE) */}
|
||||||
|
{(validatedData.message && validatedData.job_status !== "done") && (
|
||||||
|
<div className="border border-gray-200 rounded-lg mt-4 overflow-hidden">
|
||||||
|
{PROCESS_STEPS.map((step) => (
|
||||||
|
<div
|
||||||
|
key={step.key}
|
||||||
|
className={`px-4 flex items-center gap-3 text-sm py-3 border-b border-gray-200 ${
|
||||||
|
stepStatus[step.key] === "done"
|
||||||
|
? "bg-green-50"
|
||||||
|
: stepStatus[step.key] === "running"
|
||||||
|
? "bg-blue-50"
|
||||||
|
: "bg-gray-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-5 flex justify-center">
|
||||||
|
{renderIcon(stepStatus[step.key] || "-")}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
stepStatus[step.key] === "done"
|
||||||
|
? "text-green-600 font-medium"
|
||||||
|
: stepStatus[step.key] === "running"
|
||||||
|
? "text-blue-600 font-medium"
|
||||||
|
: "text-gray-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(validatedData.job_status !== "done") && (
|
||||||
|
<p className="mt-3 text-center text-gray-500 text-sm">
|
||||||
|
Sistem sedang melakukan cleansing data dan publikasi ke GeoServer dan GeoNetwork.<br />
|
||||||
|
Anda tidak perlu menunggu di halaman ini.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata Section JSON View */}
|
||||||
|
{validatedData.metadata && (
|
||||||
|
<div className="mt-8 relative z-10">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-600 mb-2">Metadata</h3>
|
||||||
|
<div className="bg-slate-900 text-slate-100 text-xs rounded-lg overflow-auto shadow-inner p-4 max-h-60 font-mono">
|
||||||
|
<pre>{JSON.stringify(validatedData.metadata, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col w-full items-center gap-3">
|
||||||
|
{/* Tombol ke Dashboard (Next.js Link) */}
|
||||||
|
<Link href="/admin/home">
|
||||||
|
<Button className="w-fit bg-blue-600 hover:bg-blue-700 px-8 py-6 text-lg shadow-lg shadow-blue-200">
|
||||||
|
Kembali ke Dashboard
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Tombol Upload Lagi (Reset Context) */}
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="text-gray-500 hover:text-gray-700 text-sm font-medium hover:underline transition"
|
||||||
|
>
|
||||||
|
Upload data lagi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
app/(modules)/admin/mapset-upload/_context/upload-context.tsx
Normal file
111
app/(modules)/admin/mapset-upload/_context/upload-context.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect, Suspense } from "react";
|
||||||
|
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
export type UploadStepType = "UPLOAD" | "PDF_VIEWER" | "TABLE_PICKER" | "VALIDATE" | "SUCCESS";
|
||||||
|
|
||||||
|
type UploadState = {
|
||||||
|
step: UploadStepType;
|
||||||
|
file: File | null;
|
||||||
|
fileDesc: string;
|
||||||
|
result: any;
|
||||||
|
selectedPages: number[];
|
||||||
|
validatedData: any;
|
||||||
|
sheetNames: string[];
|
||||||
|
selectedSheet: string | null;
|
||||||
|
pdfPageCount: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UploadContextType = {
|
||||||
|
state: UploadState;
|
||||||
|
setState: React.Dispatch<React.SetStateAction<UploadState>>;
|
||||||
|
reset: () => void;
|
||||||
|
// 🔥 Update Signature: Tambah parameter options
|
||||||
|
goToStep: (step: UploadStepType, options?: { replace?: boolean }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UploadContext = createContext<UploadContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
function UploadProviderContent({ children }: { children: React.ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const initialState: UploadState = {
|
||||||
|
step: "UPLOAD",
|
||||||
|
file: null,
|
||||||
|
fileDesc: "",
|
||||||
|
result: null,
|
||||||
|
selectedPages: [],
|
||||||
|
validatedData: null,
|
||||||
|
sheetNames: [],
|
||||||
|
selectedSheet: null,
|
||||||
|
pdfPageCount: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const [state, setState] = useState<UploadState>(initialState);
|
||||||
|
|
||||||
|
// 1. SYNC URL -> STATE (Handle Browser Back)
|
||||||
|
useEffect(() => {
|
||||||
|
const stepParam = searchParams.get("step") as UploadStepType;
|
||||||
|
const validSteps: UploadStepType[] = ["UPLOAD", "PDF_VIEWER", "TABLE_PICKER", "VALIDATE", "SUCCESS"];
|
||||||
|
|
||||||
|
if (stepParam && validSteps.includes(stepParam)) {
|
||||||
|
if (stepParam !== "UPLOAD" && !state.file) {
|
||||||
|
router.replace(pathname);
|
||||||
|
setState(prev => ({ ...prev, step: "UPLOAD" }));
|
||||||
|
} else {
|
||||||
|
setState(prev => ({ ...prev, step: stepParam }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setState(prev => ({ ...prev, step: "UPLOAD" }));
|
||||||
|
}
|
||||||
|
}, [searchParams, state.file, pathname, router]);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setState(initialState);
|
||||||
|
router.push(pathname);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. STATE -> URL (Handle Navigation)
|
||||||
|
// 🔥 Update Logic Navigation
|
||||||
|
const goToStep = (newStep: UploadStepType, options?: { replace?: boolean }) => {
|
||||||
|
if (newStep === "UPLOAD") {
|
||||||
|
router.push(pathname);
|
||||||
|
} else {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set("step", newStep);
|
||||||
|
const url = `${pathname}?${params.toString()}`;
|
||||||
|
|
||||||
|
// Jika replace = true, timpa history saat ini (Step 3 ditimpa Step 4)
|
||||||
|
if (options?.replace) {
|
||||||
|
router.replace(url);
|
||||||
|
} else {
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update state immediate agar UI responsif
|
||||||
|
setState((prev) => ({ ...prev, step: newStep }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UploadContext.Provider value={{ state, setState, reset, goToStep }}>
|
||||||
|
{children}
|
||||||
|
</UploadContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div>Loading app...</div>}>
|
||||||
|
<UploadProviderContent>{children}</UploadProviderContent>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUploadContext = () => {
|
||||||
|
const context = useContext(UploadContext);
|
||||||
|
if (!context) throw new Error("useUploadContext must be used within UploadProvider");
|
||||||
|
return context;
|
||||||
|
};
|
||||||
322
app/(modules)/admin/mapset-upload/_hooks/use-pdf-viewer.ts
Normal file
322
app/(modules)/admin/mapset-upload/_hooks/use-pdf-viewer.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
// "use client";
|
||||||
|
|
||||||
|
// import { useState, useEffect } from "react";
|
||||||
|
// import { useUploadContext } from "../_context/upload-context";
|
||||||
|
// // Sesuaikan import ini dengan lokasi service API Anda yang sebenarnya
|
||||||
|
// import uploadApi from "@/shared/services/map-upload";
|
||||||
|
// import { toast } from "sonner";
|
||||||
|
|
||||||
|
// // --- 1. Helper Load PDF.js via CDN (Sama seperti di use-upload.ts) ---
|
||||||
|
// const loadPdfJs = async () => {
|
||||||
|
// return new Promise<any>((resolve, reject) => {
|
||||||
|
// // Cek jika global variable sudah ada
|
||||||
|
// if ((window as any).pdfjsLib) {
|
||||||
|
// resolve((window as any).pdfjsLib);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const script = document.createElement("script");
|
||||||
|
// // Gunakan versi yang sama agar konsisten
|
||||||
|
// script.src = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js";
|
||||||
|
// script.async = true;
|
||||||
|
|
||||||
|
// script.onload = () => {
|
||||||
|
// const pdfjsLib = (window as any).pdfjsLib;
|
||||||
|
// // Set worker
|
||||||
|
// pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||||
|
// "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||||
|
// resolve(pdfjsLib);
|
||||||
|
// };
|
||||||
|
|
||||||
|
// script.onerror = (err) => reject(err);
|
||||||
|
// document.body.appendChild(script);
|
||||||
|
// });
|
||||||
|
// };
|
||||||
|
|
||||||
|
// export function usePdfViewer() {
|
||||||
|
// const { state, setState, goToStep } = useUploadContext();
|
||||||
|
// const [pages, setPages] = useState<{ pageNum: number; imageUrl: string }[]>([]);
|
||||||
|
// const [loading, setLoading] = useState(false);
|
||||||
|
// const [localSelectedPages, setLocalSelectedPages] = useState<number[]>([]);
|
||||||
|
|
||||||
|
// // Load PDF saat component mount atau file berubah
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (state.file && state.step === "PDF_VIEWER") {
|
||||||
|
// renderPdfPages(state.file);
|
||||||
|
// }
|
||||||
|
// // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
// }, [state.file, state.step]);
|
||||||
|
|
||||||
|
// const renderPdfPages = async (pdfFile: File) => {
|
||||||
|
// setLoading(true);
|
||||||
|
// setPages([]); // Reset halaman lama
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// // 1. Load Library dari CDN
|
||||||
|
// const pdfjsLib = await loadPdfJs();
|
||||||
|
|
||||||
|
// // 2. Baca File
|
||||||
|
// const arrayBuffer = await pdfFile.arrayBuffer();
|
||||||
|
// const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||||
|
// const totalPages = pdf.numPages;
|
||||||
|
// const pageImages = [];
|
||||||
|
|
||||||
|
// // 3. Render Setiap Halaman ke Canvas -> Image URL
|
||||||
|
// for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
|
||||||
|
// const page = await pdf.getPage(pageNum);
|
||||||
|
// const viewport = page.getViewport({ scale: 1 }); // Scale 1 cukup untuk thumbnail
|
||||||
|
|
||||||
|
// const canvas = document.createElement("canvas");
|
||||||
|
// const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
// if (ctx) {
|
||||||
|
// canvas.height = viewport.height;
|
||||||
|
// canvas.width = viewport.width;
|
||||||
|
|
||||||
|
// await page.render({
|
||||||
|
// canvasContext: ctx,
|
||||||
|
// viewport: viewport,
|
||||||
|
// }).promise;
|
||||||
|
|
||||||
|
// pageImages.push({
|
||||||
|
// pageNum,
|
||||||
|
// imageUrl: canvas.toDataURL("image/jpeg", 0.8), // Gunakan JPEG kompresi agar ringan
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// setPages(pageImages);
|
||||||
|
// } catch (err) {
|
||||||
|
// console.error("PDF Error:", err);
|
||||||
|
// toast.error("Gagal memuat halaman PDF. Pastikan file tidak korup.");
|
||||||
|
// } finally {
|
||||||
|
// setLoading(false);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const toggleSelectPage = (pageNum: number) => {
|
||||||
|
// setLocalSelectedPages((prev) => {
|
||||||
|
// if (prev.includes(pageNum)) {
|
||||||
|
// return prev.filter((p) => p !== pageNum);
|
||||||
|
// } else {
|
||||||
|
// if (prev.length >= 20) {
|
||||||
|
// toast.warning("Maksimal pilih 20 halaman.");
|
||||||
|
// return prev;
|
||||||
|
// }
|
||||||
|
// return [...prev, pageNum];
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const handleProcessPdf = async () => {
|
||||||
|
// if (localSelectedPages.length === 0) {
|
||||||
|
// toast.warning("Pilih minimal 1 halaman.");
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// setLoading(true);
|
||||||
|
// try {
|
||||||
|
// // Panggil API dengan halaman yang DIPILIH SAJA
|
||||||
|
// const res = await uploadApi.uploadFile(
|
||||||
|
// state.file!,
|
||||||
|
// localSelectedPages,
|
||||||
|
// null,
|
||||||
|
// state.fileDesc
|
||||||
|
// );
|
||||||
|
|
||||||
|
// setState(prev => ({ ...prev, result: res }));
|
||||||
|
|
||||||
|
// // Routing Logic setelah upload PDF selesai
|
||||||
|
// if (!res.tables) {
|
||||||
|
// goToStep("VALIDATE");
|
||||||
|
// } else if (res.tables.length > 1) {
|
||||||
|
// goToStep("TABLE_PICKER");
|
||||||
|
// toast.success("Beberapa tabel terdeteksi. Silakan pilih tabel.");
|
||||||
|
// } else {
|
||||||
|
// goToStep("TABLE_PICKER"); // Atau langsung validate tergantung kebutuhan
|
||||||
|
// }
|
||||||
|
// } catch (err: any) {
|
||||||
|
// toast.error(err.message || "Gagal memproses halaman PDF");
|
||||||
|
// } finally {
|
||||||
|
// setLoading(false);
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// // 🔥 PENTING: Wajib me-return object ini agar tidak "undefined" di component
|
||||||
|
// return {
|
||||||
|
// pages,
|
||||||
|
// loading,
|
||||||
|
// localSelectedPages,
|
||||||
|
// toggleSelectPage,
|
||||||
|
// handleProcessPdf,
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useUploadContext } from "../_context/upload-context";
|
||||||
|
// Pastikan path ini sesuai dengan lokasi service API upload Anda
|
||||||
|
import uploadApi from "@/shared/services/map-upload";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// --- HELPER: Load PDF.js via CDN (Bypassing Webpack agar tidak Error) ---
|
||||||
|
const loadPdfJs = async () => {
|
||||||
|
return new Promise<any>((resolve, reject) => {
|
||||||
|
// 1. Cek jika library sudah ada di window
|
||||||
|
if ((window as any).pdfjsLib) {
|
||||||
|
resolve((window as any).pdfjsLib);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Inject Script jika belum ada
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js";
|
||||||
|
script.async = true;
|
||||||
|
|
||||||
|
script.onload = () => {
|
||||||
|
const pdfjsLib = (window as any).pdfjsLib;
|
||||||
|
// Set worker source ke versi yang sama
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||||
|
"https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||||
|
resolve(pdfjsLib);
|
||||||
|
};
|
||||||
|
|
||||||
|
script.onerror = (err) => reject(err);
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export function usePdfViewer() {
|
||||||
|
const { state, setState, goToStep } = useUploadContext();
|
||||||
|
const [pages, setPages] = useState<{ pageNum: number; imageUrl: string }[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [localSelectedPages, setLocalSelectedPages] = useState<number[]>([]);
|
||||||
|
|
||||||
|
// Efek untuk memuat halaman PDF saat komponen dipasang
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.file && state.step === "PDF_VIEWER") {
|
||||||
|
renderPdfPages(state.file);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [state.file, state.step]);
|
||||||
|
|
||||||
|
// --- FUNGSI RENDER PDF KE GAMBAR ---
|
||||||
|
const renderPdfPages = async (pdfFile: File) => {
|
||||||
|
setLoading(true);
|
||||||
|
setPages([]); // Bersihkan halaman sebelumnya
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load library dari CDN
|
||||||
|
const pdfjsLib = await loadPdfJs();
|
||||||
|
|
||||||
|
// Baca ArrayBuffer
|
||||||
|
const arrayBuffer = await pdfFile.arrayBuffer();
|
||||||
|
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||||
|
const totalPages = pdf.numPages;
|
||||||
|
const pageImages = [];
|
||||||
|
|
||||||
|
// Render setiap halaman ke Canvas -> DataURL
|
||||||
|
for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
|
||||||
|
const page = await pdf.getPage(pageNum);
|
||||||
|
const viewport = page.getViewport({ scale: 1 }); // Skala thumbnail
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
if (ctx) {
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
|
||||||
|
await page.render({
|
||||||
|
canvasContext: ctx,
|
||||||
|
viewport: viewport,
|
||||||
|
}).promise;
|
||||||
|
|
||||||
|
pageImages.push({
|
||||||
|
pageNum,
|
||||||
|
imageUrl: canvas.toDataURL("image/jpeg", 0.7), // Kompresi JPEG biar ringan
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPages(pageImages);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("PDF Render Error:", err);
|
||||||
|
toast.error("Gagal memuat visualisasi PDF.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- FUNGSI PILIH HALAMAN ---
|
||||||
|
const toggleSelectPage = (pageNum: number) => {
|
||||||
|
setLocalSelectedPages((prev) => {
|
||||||
|
if (prev.includes(pageNum)) {
|
||||||
|
return prev.filter((p) => p !== pageNum);
|
||||||
|
} else {
|
||||||
|
if (prev.length >= 20) {
|
||||||
|
toast.warning("Maksimal 20 halaman yang dapat dipilih.");
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return [...prev, pageNum];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- LOGIKA PROSES UPLOAD & ROUTING YANG ANDA MINTA ---
|
||||||
|
const handleProcessPdf = async () => {
|
||||||
|
if (localSelectedPages.length === 0) {
|
||||||
|
toast.warning("Harap pilih minimal 1 halaman untuk diproses.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// 1. Kirim ke API dengan parameter 'pages' (localSelectedPages)
|
||||||
|
const res = await uploadApi.uploadFile(
|
||||||
|
state.file!,
|
||||||
|
localSelectedPages, // Kirim array halaman: [1, 2, 5]
|
||||||
|
null, // sheet null karena ini PDF
|
||||||
|
state.fileDesc
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Simpan hasil response ke context global
|
||||||
|
setState(prev => ({ ...prev, result: res }));
|
||||||
|
|
||||||
|
// 3. Cek Logic Routing
|
||||||
|
if (res.data.tables && res.data.tables.length > 0) {
|
||||||
|
// Jika ada tabel terdeteksi -> Step 3 (Table Picker)
|
||||||
|
goToStep("TABLE_PICKER");
|
||||||
|
toast.success(`Ditemukan ${res.data.tables.length} tabel. Silakan pilih tabel.`);
|
||||||
|
} else if (!res.data.tables) {
|
||||||
|
// Jika TIDAK ada tabel (mungkin teks biasa/gambar) -> Step 4 (Validate/Preview Raw)
|
||||||
|
goToStep("VALIDATE");
|
||||||
|
toast.info("Tabel tidak terdeteksi spesifik, lanjut ke validasi.");
|
||||||
|
} else {
|
||||||
|
toast.warning(res.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error(err.message || "Gagal memproses halaman PDF terpilih.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🔥 Return wajib agar tidak error destructuring undefined
|
||||||
|
return {
|
||||||
|
pages,
|
||||||
|
loading,
|
||||||
|
localSelectedPages,
|
||||||
|
toggleSelectPage,
|
||||||
|
handleProcessPdf,
|
||||||
|
};
|
||||||
|
}
|
||||||
204
app/(modules)/admin/mapset-upload/_hooks/use-upload.ts
Normal file
204
app/(modules)/admin/mapset-upload/_hooks/use-upload.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useUploadContext } from "../_context/upload-context";
|
||||||
|
import uploadApi from "@/shared/services/map-upload"; // Sesuaikan path ini
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// Helper untuk load PDF.js via CDN (Bypassing Webpack)
|
||||||
|
const loadPdfJs = async () => {
|
||||||
|
return new Promise<any>((resolve, reject) => {
|
||||||
|
if ((window as any).pdfjsLib) {
|
||||||
|
resolve((window as any).pdfjsLib);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js";
|
||||||
|
script.async = true;
|
||||||
|
|
||||||
|
script.onload = () => {
|
||||||
|
const pdfjsLib = (window as any).pdfjsLib;
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||||
|
"https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||||
|
resolve(pdfjsLib);
|
||||||
|
};
|
||||||
|
|
||||||
|
script.onerror = (err) => reject(err);
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useUploadLogic() {
|
||||||
|
const { state, setState, goToStep } = useUploadContext();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleFileSelect = async (file: File) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
file,
|
||||||
|
sheetNames: [],
|
||||||
|
selectedSheet: null,
|
||||||
|
pdfPageCount: null,
|
||||||
|
selectedPages: []
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ext = file.name.split(".").pop()?.toLowerCase();
|
||||||
|
|
||||||
|
// A. JIKA FILE EXCEL
|
||||||
|
if (ext === "xlsx" || ext === "xls") {
|
||||||
|
try {
|
||||||
|
const data = await file.arrayBuffer();
|
||||||
|
const workbook = XLSX.read(data, { type: 'array' });
|
||||||
|
const sheetNames = workbook.SheetNames;
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
sheetNames: sheetNames,
|
||||||
|
selectedSheet: sheetNames.length === 1 ? sheetNames[0] : null
|
||||||
|
}));
|
||||||
|
if (sheetNames.length > 1) toast.info("Pilih sheet excel.");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error("Gagal membaca file Excel.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// B. JIKA FILE PDF -> LOAD VIA CDN RUNTIME
|
||||||
|
else if (ext === "pdf") {
|
||||||
|
try {
|
||||||
|
const pdfjsLib = await loadPdfJs();
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pdfPageCount: pdf.numPages
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Opsional: Beri info jika halaman banyak
|
||||||
|
if (pdf.numPages > 1) {
|
||||||
|
toast.info(`PDF terdeteksi memiliki ${pdf.numPages} halaman.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Gagal baca PDF:", error);
|
||||||
|
toast.error("Gagal membaca file PDF atau memuat library.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFile = () => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
file: null,
|
||||||
|
fileDesc: "",
|
||||||
|
sheetNames: [],
|
||||||
|
selectedSheet: null,
|
||||||
|
pdfPageCount: null,
|
||||||
|
selectedPages: []
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Logic Upload ke Backend
|
||||||
|
const handleUploadProcess = async (selectedSheet?: string) => {
|
||||||
|
if (!state.file) return;
|
||||||
|
|
||||||
|
// Validasi Deskripsi
|
||||||
|
if (!state.fileDesc) {
|
||||||
|
toast.warning("Mohon isi deskripsi file terlebih dahulu.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validasi Excel Sheet
|
||||||
|
const isExcel = state.file.name.match(/\.(xlsx|xls)$/i);
|
||||||
|
if (isExcel && state.sheetNames.length > 1 && !selectedSheet) {
|
||||||
|
toast.warning("Mohon pilih sheet yang akan diproses.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 LOGIKA BARU: INTERCEPSI PDF MULTIPAGE
|
||||||
|
const isPdf = state.file.name.match(/\.(pdf)$/i);
|
||||||
|
if (isPdf && state.pdfPageCount && state.pdfPageCount > 1) {
|
||||||
|
// Jangan upload dulu, arahkan ke Step 2 (PDF Viewer)
|
||||||
|
goToStep("PDF_VIEWER");
|
||||||
|
toast.info("File memiliki beberapa halaman. Silakan pilih halaman yang akan diproses.");
|
||||||
|
return; // 🛑 BERHENTI DI SINI
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PROSES UPLOAD LANGSUNG (Untuk Excel, CSV, atau PDF 1 Halaman) ---
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await uploadApi.uploadFile(
|
||||||
|
state.file,
|
||||||
|
state.selectedPages.length > 0 ? state.selectedPages : null,
|
||||||
|
selectedSheet || state.selectedSheet || null,
|
||||||
|
state.fileDesc
|
||||||
|
);
|
||||||
|
|
||||||
|
setState((prev) => ({ ...prev, result: res }));
|
||||||
|
|
||||||
|
// Routing Logic setelah response backend (untuk kasus PDF 1 halaman atau file lain)
|
||||||
|
if (res.file_type === ".pdf" && res.tables && res.tables.length > 1) {
|
||||||
|
goToStep("TABLE_PICKER");
|
||||||
|
toast.success("Beberapa tabel terdeteksi. Silakan pilih tabel.");
|
||||||
|
}
|
||||||
|
else if (res.file_type === ".pdf" && (!res.tables || res.tables.length === 0)) {
|
||||||
|
// Fallback jika PDF 1 halaman tapi tidak ada tabel
|
||||||
|
goToStep("PDF_VIEWER");
|
||||||
|
toast.info("Tabel tidak terdeteksi otomatis. Silakan pilih area manual.");
|
||||||
|
}
|
||||||
|
else if (res.data) {
|
||||||
|
goToStep("VALIDATE");
|
||||||
|
toast.success("File berhasil diproses. Silakan validasi data.");
|
||||||
|
} else {
|
||||||
|
console.log(res);
|
||||||
|
toast.warning(res.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error(err.message || "Gagal mengunggah file.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Logic Upload ke Database
|
||||||
|
const handleSaveToDatabase = async (payload: any) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
|
||||||
|
// Panggil API
|
||||||
|
const res = await uploadApi.saveToDatabase(payload);
|
||||||
|
|
||||||
|
// Simpan hasil final ke context
|
||||||
|
setState(prev => ({ ...prev, validatedData: res }));
|
||||||
|
|
||||||
|
// Pindah ke halaman sukses
|
||||||
|
goToStep("SUCCESS");
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error(err.message || "Gagal menyimpan data ke database.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loading,
|
||||||
|
handleFileSelect,
|
||||||
|
handleUploadProcess,
|
||||||
|
handleSaveToDatabase,
|
||||||
|
resetFile,
|
||||||
|
setState,
|
||||||
|
setFileDesc: (desc: string) => setState(prev => ({...prev, fileDesc: desc})),
|
||||||
|
setSelectedSheet: (sheet: string) => setState(prev => ({...prev, selectedSheet: sheet})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
41
app/(modules)/admin/mapset-upload/page.client.tsx
Normal file
41
app/(modules)/admin/mapset-upload/page.client.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { UploadProvider, useUploadContext } from "./_context/upload-context";
|
||||||
|
|
||||||
|
// Pastikan path import ini benar mengarah ke file masing-masing
|
||||||
|
import StepUpload from "./_components/step-1-upload";
|
||||||
|
import StepPdfViewer from "./_components/step-2-pdf-viewer";
|
||||||
|
import StepTablePicker from "./_components/step-3-table-picker";
|
||||||
|
import StepValidate from "./_components/step-4-validate";
|
||||||
|
import StepSuccess from "./_components/step-5-success";
|
||||||
|
|
||||||
|
// Komponen Wizard Internal
|
||||||
|
function UploadWizard() {
|
||||||
|
const { state } = useUploadContext();
|
||||||
|
|
||||||
|
switch (state.step) {
|
||||||
|
case "UPLOAD":
|
||||||
|
return <StepUpload />;
|
||||||
|
case "PDF_VIEWER":
|
||||||
|
return <StepPdfViewer />;
|
||||||
|
case "TABLE_PICKER":
|
||||||
|
return <StepTablePicker />;
|
||||||
|
case "VALIDATE":
|
||||||
|
return <StepValidate />;
|
||||||
|
case "SUCCESS":
|
||||||
|
return <StepSuccess />;
|
||||||
|
default:
|
||||||
|
return <StepUpload />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Komponen Utama yang Di-export Default
|
||||||
|
export default function UploadPageClient() {
|
||||||
|
return (
|
||||||
|
<UploadProvider>
|
||||||
|
<div className="bg-white h-[calc(100vh-170px)] rounded-lg shadow-sm p-4 overflow-auto">
|
||||||
|
<UploadWizard />
|
||||||
|
</div>
|
||||||
|
</UploadProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
app/(modules)/admin/mapset-upload/page.tsx
Normal file
18
app/(modules)/admin/mapset-upload/page.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
// app/admin/upload/page.tsx
|
||||||
|
import PageHeader from "../_components/page-header";
|
||||||
|
import UploadPageClient from "./page.client";
|
||||||
|
|
||||||
|
export default function UploadPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
className="bg-zinc-50"
|
||||||
|
title="Upload Automation"
|
||||||
|
description="Unggah mapset dan metadata untuk pembaruan otomatis data geospasial di Satu Peta."
|
||||||
|
/>
|
||||||
|
<div className="px-6">
|
||||||
|
<UploadPageClient />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
shared/components/file-upload.tsx
Normal file
141
shared/components/file-upload.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
// components/file-upload.tsx (atau lokasi file Anda)
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { UploadCloud, X, RefreshCcw, File as FileIcon } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react"; // Tambah useEffect
|
||||||
|
import { useDropzone } from "react-dropzone";
|
||||||
|
import { Button } from "@/shared/components/ui/button"; // Sesuaikan path
|
||||||
|
|
||||||
|
interface FileUploadProps {
|
||||||
|
value?: string;
|
||||||
|
fileName?: string;
|
||||||
|
// onChange existing ini sepertinya untuk hasil upload (ID), kita biarkan opsional
|
||||||
|
onChange?: (value: { id: string; name: string }) => void;
|
||||||
|
// 🔥 TAMBAHAN: Callback untuk mengirim Raw File ke Controller
|
||||||
|
onFileSelect?: (file: File) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
description?: string;
|
||||||
|
// Prop tambahan untuk mengontrol dari luar (Controlled Component)
|
||||||
|
filePreview?: File | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileUpload({
|
||||||
|
value,
|
||||||
|
fileName,
|
||||||
|
onChange,
|
||||||
|
onFileSelect, // ⬅️ Gunakan ini
|
||||||
|
onRemove,
|
||||||
|
description,
|
||||||
|
filePreview // ⬅️ Gunakan ini jika ingin state dikontrol parent sepenuhnya
|
||||||
|
}: FileUploadProps) {
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [internalFile, setInternalFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
// Sinkronisasi jika parent mengirim file (opsional, agar stateless)
|
||||||
|
const selectedFile = filePreview || internalFile;
|
||||||
|
|
||||||
|
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||||
|
const file = acceptedFiles[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setInternalFile(file); // Set local state untuk UI
|
||||||
|
|
||||||
|
// 🔥 Kirim file mentah ke parent hook
|
||||||
|
if (onFileSelect) {
|
||||||
|
onFileSelect(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
}, [onFileSelect]);
|
||||||
|
|
||||||
|
const handleRemove = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setInternalFile(null);
|
||||||
|
onRemove();
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
onDrop,
|
||||||
|
accept: {
|
||||||
|
"text/csv": [".csv"],
|
||||||
|
"application/vnd.ms-excel": [".xls"],
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [
|
||||||
|
".xlsx",
|
||||||
|
],
|
||||||
|
"application/pdf": [".pdf"],
|
||||||
|
"application/zip": [".zip"],
|
||||||
|
},
|
||||||
|
maxFiles: 1,
|
||||||
|
maxSize: 100 * 1024 * 1024,
|
||||||
|
disabled: isUploading,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tentukan apakah file sudah ada (baik dari value/server atau lokal/selected)
|
||||||
|
const hasFile = selectedFile || value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
className={`flex flex-col justify-center rounded-lg border-2 border-dashed p-4 transition-colors cursor-pointer w-full
|
||||||
|
${isDragActive ? "border-primary bg-primary/5" : "border-input"}
|
||||||
|
${isUploading ? "pointer-events-none opacity-50" : ""}
|
||||||
|
${hasFile ? "bg-slate-50 border-solid" : "hover:bg-slate-50"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
|
||||||
|
{/* STATE: BELUM ADA FILE */}
|
||||||
|
{!hasFile && (
|
||||||
|
<div className="flex flex-col items-center py-4">
|
||||||
|
<UploadCloud className="mb-3 h-10 w-10 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground text-center font-medium">
|
||||||
|
Drag & drop file, atau klik untuk pilih
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground text-center">
|
||||||
|
{/* CSV, XLS, XLSX, PDF, ZIP — Maks 100MB */}
|
||||||
|
.xlsx, .csv, .pdf, .zip (SHP/GDB) - Maks 100MB
|
||||||
|
</p>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-2 text-xs text-slate-400 italic">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* STATE: FILE SUDAH DIPILIH */}
|
||||||
|
{hasFile && (
|
||||||
|
<div className="flex w-full items-center justify-between gap-4 p-2">
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
<div className="p-2 bg-white rounded-md border shadow-sm">
|
||||||
|
<FileIcon className={`h-6 w-6 ${selectedFile?.name.endsWith('.pdf') ? 'text-red-500' : 'text-green-500'}`} />
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden flex flex-col">
|
||||||
|
<p className="truncate text-sm font-medium text-slate-900">
|
||||||
|
{selectedFile?.name || fileName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
{selectedFile ? (
|
||||||
|
<>
|
||||||
|
<span>{(selectedFile.size / 1024 / 1024).toFixed(2)} MB</span>
|
||||||
|
<span className="text-slate-300">•</span>
|
||||||
|
<span className="text-amber-600">Siap diproses</span>
|
||||||
|
</>
|
||||||
|
) : "File berhasil diunggah"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200"
|
||||||
|
onClick={handleRemove}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 mr-1"/> Hapus
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
shared/components/forms/form-multi-select.tsx
Normal file
110
shared/components/forms/form-multi-select.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/shared/components/ui/popover"
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface MultiSelectProps {
|
||||||
|
name: string;
|
||||||
|
options?: any[];
|
||||||
|
value?: any[];
|
||||||
|
onChange: (e: { target: { name: string; value: string[] } }) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FormMultiSelect({
|
||||||
|
name,
|
||||||
|
options = [],
|
||||||
|
value = [],
|
||||||
|
onChange,
|
||||||
|
placeholder = "Pilih data",
|
||||||
|
}: MultiSelectProps) {
|
||||||
|
const toggle = (val:string) => {
|
||||||
|
let newValue:string[];
|
||||||
|
|
||||||
|
if (value.includes(val)) {
|
||||||
|
newValue = value.filter((v) => v !== val)
|
||||||
|
} else {
|
||||||
|
newValue = [...value, val]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onChange) {
|
||||||
|
onChange({
|
||||||
|
target: {
|
||||||
|
name,
|
||||||
|
value: newValue,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedLabels = options
|
||||||
|
.filter((o) => value.includes(o.value))
|
||||||
|
.map((o) => o.label)
|
||||||
|
.join(", ")
|
||||||
|
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [isAtBottom, setIsAtBottom] = useState(false);
|
||||||
|
const handleScroll = () => {
|
||||||
|
console.log('gtw');
|
||||||
|
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const atBottom =
|
||||||
|
el.scrollTop + el.clientHeight >= el.scrollHeight - 2;
|
||||||
|
|
||||||
|
setIsAtBottom(atBottom);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start text-left font-normal border-gray-300 rounded-md px-2 shadow-none"
|
||||||
|
>
|
||||||
|
<div className="flex-1 overflow-x-auto whitespace-nowrap scrollbar-hide">
|
||||||
|
{value.length > 0 ? selectedLabels : placeholder}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
side="bottom"
|
||||||
|
align="start"
|
||||||
|
sideOffset={4}
|
||||||
|
className="w-[var(--radix-popover-trigger-width)] max-h-60 overflow-y-auto z-50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="space-y-2"
|
||||||
|
>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<label
|
||||||
|
key={opt.value}
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={value.includes(opt.value)}
|
||||||
|
onCheckedChange={() => toggle(opt.value)}
|
||||||
|
/>
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{!isAtBottom && (
|
||||||
|
<div className="arrow-symbol pt-0 pb-2 bg-white w-full text-center fixed bottom-0">⌄</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
shared/services/api-local.ts
Normal file
112
shared/services/api-local.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
// lib/api.ts
|
||||||
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_MICROSERVICE_URL;
|
||||||
|
|
||||||
|
// Main axios instance
|
||||||
|
const api: AxiosInstance = axios.create({
|
||||||
|
baseURL: API_URL,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let isRefreshing = false;
|
||||||
|
let failedQueue: Array<{
|
||||||
|
resolve: (value?: unknown) => void;
|
||||||
|
reject: (reason?: unknown) => void;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const processQueue = (error: Error | null = null, token: string | null = null) => {
|
||||||
|
failedQueue.forEach((prom) => {
|
||||||
|
if (error) {
|
||||||
|
prom.reject(error);
|
||||||
|
} else {
|
||||||
|
prom.resolve(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
failedQueue = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a session manager that works with the global state
|
||||||
|
export const setupApiInterceptors = (getToken: () => string | null) => {
|
||||||
|
// Request interceptor
|
||||||
|
api.interceptors.request.use(async (config) => {
|
||||||
|
try {
|
||||||
|
const token = getToken();
|
||||||
|
// Allow callers to skip auth explicitly (e.g., public/landing pages)
|
||||||
|
const skipAuth = config.headers && (config.headers["X-Skip-Auth"] === "true" || config.headers["x-skip-auth"] === "true");
|
||||||
|
if (token && !skipAuth) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in request interceptor:", error);
|
||||||
|
return config; // Continue with the request even if token retrieval fails
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Response interceptor
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const originalRequest = error.config as InternalAxiosRequestConfig;
|
||||||
|
|
||||||
|
// Skip if it's an auth endpoint or if we've already tried to refresh
|
||||||
|
if (!originalRequest || originalRequest.url?.includes("/auth/refresh") || originalRequest.url?.includes("/me") || originalRequest.url?.includes("/auth/session") || originalRequest.headers?.["X-Retry-After-Refresh"]) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get a 401 and haven't tried to refresh yet
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
if (isRefreshing) {
|
||||||
|
// If we're already refreshing, queue this request
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({ resolve, reject });
|
||||||
|
})
|
||||||
|
.then((token) => {
|
||||||
|
if (originalRequest.headers && token) {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return api.request(originalRequest);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshing = true;
|
||||||
|
originalRequest.headers.set("X-Retry-After-Refresh", "true");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Wait a bit before retrying to allow NextAuth to refresh
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
// The session will be automatically refreshed by NextAuth
|
||||||
|
// We just need to retry the original request
|
||||||
|
const response = await api.request(originalRequest);
|
||||||
|
processQueue(null, getToken());
|
||||||
|
return response;
|
||||||
|
} catch (refreshError) {
|
||||||
|
const error = refreshError instanceof Error ? refreshError : new Error(String(refreshError));
|
||||||
|
processQueue(error, null);
|
||||||
|
return Promise.reject(error);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
export const apiHelpers = {
|
||||||
|
get: <T>(url: string, config?: AxiosRequestConfig) => api.get<T>(url, config).then((res) => res.data),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
post: <T>(url: string, data?: any, config?: AxiosRequestConfig) => api.post<T>(url, data, config).then((res) => res.data),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
patch: <T>(url: string, data?: any, config?: AxiosRequestConfig) => api.patch<T>(url, data, config).then((res) => res.data),
|
||||||
|
delete: <T>(url: string, config?: AxiosRequestConfig) => api.delete<T>(url, config).then((res) => res.data),
|
||||||
|
};
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError, InternalAxiosRequestConfig } from "axios";
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
// const API_URL = "http://localhost:8000"
|
|
||||||
|
|
||||||
// Main axios instance
|
// Main axios instance
|
||||||
const api: AxiosInstance = axios.create({
|
const api: AxiosInstance = axios.create({
|
||||||
|
|
|
||||||
30
shared/services/map-upload.ts
Normal file
30
shared/services/map-upload.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { apiHelpers } from "./api-local";
|
||||||
|
|
||||||
|
const uploadApi = {
|
||||||
|
uploadFile: async (file: File, page: any | null, sheet: string | null, fileDesc: string): Promise<any> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
if (page) formData.append("page", page);
|
||||||
|
if (sheet) formData.append("sheet", sheet);
|
||||||
|
formData.append("file_desc", fileDesc);
|
||||||
|
|
||||||
|
return apiHelpers.post("/upload/file", formData, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
processPdf: async (data: any, fileName: string, fileDesc: string): Promise<any> => {
|
||||||
|
const payload = { ...data, fileName, fileDesc };
|
||||||
|
return apiHelpers.post("/upload/process-pdf", payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
saveToDatabase: async (data: any): Promise<any> => {
|
||||||
|
return apiHelpers.post("/upload/to-postgis", data);
|
||||||
|
},
|
||||||
|
|
||||||
|
getStyles: async (): Promise<any> => {
|
||||||
|
return apiHelpers.get("/dataset/styles");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default uploadApi;
|
||||||
Loading…
Reference in New Issue
Block a user