Compare commits
No commits in common. "fe" and "main" have entirely different histories.
48
.gitignore
vendored
48
.gitignore
vendored
|
|
@ -1,48 +0,0 @@
|
||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
/node_modules
|
|
||||||
/.pnp
|
|
||||||
.pnp.*
|
|
||||||
.yarn/*
|
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
|
||||||
.env*
|
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
next-env.d.ts
|
|
||||||
|
|
||||||
# husky
|
|
||||||
.husky
|
|
||||||
|
|
||||||
# editor configs (local only)
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{ "plugins": ["prettier-plugin-tailwindcss"] }
|
|
||||||
57
Dockerfile
57
Dockerfile
|
|
@ -1,57 +0,0 @@
|
||||||
# ------------------------------------
|
|
||||||
# Base image + pnpm
|
|
||||||
# ------------------------------------
|
|
||||||
FROM node:20-alpine AS base
|
|
||||||
WORKDIR /app
|
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
RUN corepack enable && corepack prepare pnpm@9 --activate
|
|
||||||
|
|
||||||
# ------------------------------------
|
|
||||||
# Install dependencies
|
|
||||||
# ------------------------------------
|
|
||||||
FROM base AS deps
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package.json pnpm-lock.yaml* ./
|
|
||||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
|
||||||
pnpm install --no-frozen-lockfile
|
|
||||||
|
|
||||||
# ------------------------------------
|
|
||||||
# Build the application
|
|
||||||
# ------------------------------------
|
|
||||||
FROM base AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Jadikan build lebih netral tanpa API URL/SECRET
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
RUN pnpm build
|
|
||||||
|
|
||||||
# ------------------------------------
|
|
||||||
# Production image
|
|
||||||
# ------------------------------------
|
|
||||||
FROM node:20-alpine AS runner
|
|
||||||
WORKDIR /app
|
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
|
|
||||||
RUN addgroup -g 1001 nodejs && \
|
|
||||||
adduser -u 1001 -G nodejs -s /bin/sh -D nextjs
|
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
||||||
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
# => ENV hanya untuk runtime, BUKAN build time!
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV HOSTNAME=0.0.0.0
|
|
||||||
ENV PORT=4000
|
|
||||||
|
|
||||||
EXPOSE 4000
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
|
||||||
CMD node -e "require('http').get('http://localhost:4000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
|
||||||
38
README.md
Executable file → Normal file
38
README.md
Executable file → Normal file
|
|
@ -1,36 +1,4 @@
|
||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# Satu Peta + automation
|
||||||
|
|
||||||
## Getting Started
|
Frontend -> fe
|
||||||
|
Backend -> be
|
||||||
First, run the development server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
|
||||||
|
|
||||||
## Deploy on Vercel
|
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
||||||
16
addons.txt
16
addons.txt
|
|
@ -1,16 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
// MainMapsetCardSkeleton.tsx
|
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
|
||||||
|
|
||||||
export function MainMapsetCardSkeleton() {
|
|
||||||
return (
|
|
||||||
<Card className="font-onest h-full w-full overflow-hidden p-0">
|
|
||||||
<CardContent className="relative flex h-full animate-pulse flex-col p-0">
|
|
||||||
{/* Map preview placeholder */}
|
|
||||||
<div className="relative aspect-video w-full bg-zinc-200" />
|
|
||||||
|
|
||||||
{/* Category badge */}
|
|
||||||
<div className="absolute top-2 left-2 h-5 w-20 rounded-md bg-zinc-300" />
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex flex-1 flex-col space-y-4 p-5">
|
|
||||||
{/* Title lines */}
|
|
||||||
<div className="h-5 w-3/4 rounded bg-zinc-300" />
|
|
||||||
<div className="h-5 w-2/3 rounded bg-zinc-300" />
|
|
||||||
|
|
||||||
{/* Stats row */}
|
|
||||||
<div className="mt-auto pt-2">
|
|
||||||
<div className="flex w-full items-center justify-between gap-4">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="h-4 w-4 rounded bg-zinc-300" />
|
|
||||||
<div className="h-4 w-8 rounded bg-zinc-300" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<div className="h-4 w-4 rounded bg-zinc-300" />
|
|
||||||
<div className="h-4 w-8 rounded bg-zinc-300" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { MainMapsetCard } from "./main-mapset-card";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import mapsetApi from "@/shared/services/mapset";
|
|
||||||
import { Button } from "@/shared/components/button/button";
|
|
||||||
import { MainMapsetCardSkeleton } from "./_components/main-mapset-card-skeleton";
|
|
||||||
import { ErrorState } from "@/shared/components/error-state";
|
|
||||||
|
|
||||||
export function CatalogSection() {
|
|
||||||
const {
|
|
||||||
data: mapsets,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
error,
|
|
||||||
isSuccess,
|
|
||||||
refetch,
|
|
||||||
isFetching,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["mapsets-catalog"],
|
|
||||||
queryFn: () =>
|
|
||||||
mapsetApi
|
|
||||||
.getMapsets(
|
|
||||||
{
|
|
||||||
limit: 5,
|
|
||||||
filter: JSON.stringify([
|
|
||||||
"is_active=true",
|
|
||||||
"status_validation=approved",
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
{ skipAuth: true },
|
|
||||||
)
|
|
||||||
.then((res) => {
|
|
||||||
return res.items;
|
|
||||||
}),
|
|
||||||
staleTime: 5000,
|
|
||||||
retry: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const showSkeleton = isLoading || (isFetching && !isSuccess);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<section id="catalog" className="font-onest @container container mt-24">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="h-8 w-1 rounded-full bg-blue-500"></div>
|
|
||||||
<h2 className="font-onest text-dark-500 ms-3 text-[28px] font-bold">
|
|
||||||
Katalog Mapset
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<Link href="/maps?open-catalog=true">
|
|
||||||
<Button variant={"dangerOutlined"}>Lihat Semua</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<p className="text-md text-gray-600">
|
|
||||||
Koleksi lengkap peta dan data geospasial Jawa Timur
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{(() => {
|
|
||||||
if (showSkeleton) {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto mt-6 grid grid-cols-1 items-center justify-center gap-6 @xl:grid-cols-2 @6xl:grid-cols-4">
|
|
||||||
{[...Array(4)].map((_, i) => (
|
|
||||||
<MainMapsetCardSkeleton key={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return <ErrorState message={error?.message} onRetry={refetch} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSuccess && (!mapsets || mapsets.length === 0)) {
|
|
||||||
return (
|
|
||||||
<div className="mt-6 rounded-xl border border-zinc-200 bg-white p-6 text-zinc-600">
|
|
||||||
Belum ada mapset untuk ditampilkan.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSuccess && mapsets && mapsets.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto mt-6 grid grid-cols-1 items-center justify-center gap-6 @xl:grid-cols-2 @6xl:grid-cols-4">
|
|
||||||
{mapsets.slice(0, 4).map((mapset) => (
|
|
||||||
<MainMapsetCard key={mapset.id} mapset={mapset} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
import LoadingSpinner from "@/shared/components/loading-spinner";
|
|
||||||
import { CardContent, Card } from "@/shared/components/ui/card";
|
|
||||||
import { Mapset } from "@/shared/types/mapset";
|
|
||||||
import { Download, Eye } from "lucide-react";
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
|
|
||||||
const PreviewMap = dynamic(() => import("@/shared/components/preview-map"), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <LoadingSpinner />,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function MainMapsetCard({ mapset }: Readonly<{ mapset: Mapset }>) {
|
|
||||||
return (
|
|
||||||
<Card className="font-onest h-full w-full overflow-hidden p-0">
|
|
||||||
<CardContent className="relative flex h-full flex-col p-0">
|
|
||||||
<div className="relative aspect-video w-full">
|
|
||||||
<PreviewMap mapset={mapset} />
|
|
||||||
</div>
|
|
||||||
<span className="absolute top-2 left-2 z-[1000] rounded-lg bg-[#FFD600] px-2 py-1 text-xs font-medium text-black">
|
|
||||||
{mapset?.category?.name}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col space-y-3 p-5">
|
|
||||||
<a href={`/maps?mapset-id=${mapset.id}`} rel="noopener noreferrer">
|
|
||||||
<h3 className="hover:text-primary md:text-md mb-0 line-clamp-4 text-base font-medium sm:text-lg lg:line-clamp-2 lg:text-lg xl:line-clamp-3 xl:text-lg">
|
|
||||||
{mapset.name}
|
|
||||||
</h3>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div className="mt-auto pt-2">
|
|
||||||
<div className="flex w-full items-center justify-between gap-4 text-zinc-500">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
<span>{mapset.view_count}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
<span>{mapset.download_count}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* <a
|
|
||||||
href={`/maps?mapset-id=${mapset.id}`}
|
|
||||||
className="text-primary inline-flex items-center hover:underline"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<div className="flex w-full items-center justify-between gap-4 text-zinc-500">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
<span>2.450</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
<span>892</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { getFileThumbnailUrl } from "@/shared/utils/file";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default function CategoryCard({
|
|
||||||
name,
|
|
||||||
icon,
|
|
||||||
link, totalDataset,
|
|
||||||
}: Readonly<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
icon?: string | null;
|
|
||||||
link: string;
|
|
||||||
totalDataset: number;
|
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={link}
|
|
||||||
className="group flex h-full flex-col items-center justify-start gap-4 rounded-2xl border border-zinc-200 bg-white p-6 text-center shadow-sm transition-colors hover:border-primary"
|
|
||||||
>
|
|
||||||
<div className="flex w-full items-center justify-center">
|
|
||||||
<div className="relative h-28 w-28 shrink-0 rounded-full bg-white">
|
|
||||||
<Image
|
|
||||||
src={getFileThumbnailUrl(icon || "/landing/logo_pemprov_jatim.png")}
|
|
||||||
alt={name}
|
|
||||||
fill
|
|
||||||
sizes="112px"
|
|
||||||
className="object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 w-full">
|
|
||||||
<h3 className="mb-2 line-clamp-2 text-lg font-semibold text-zinc-800 min-h-[2.75rem]">{name}</h3>
|
|
||||||
|
|
||||||
<div className="text-md text-biru-300 font-semibold">
|
|
||||||
{totalDataset} Dataset
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
"use client";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import categoryApi from "@/shared/services/category";
|
|
||||||
import CategoryCard from "./category-card";
|
|
||||||
import { ErrorState } from "@/shared/components/error-state";
|
|
||||||
import { Button } from "@/shared/components/button/button";
|
|
||||||
|
|
||||||
export function CategorySection() {
|
|
||||||
const [showAll, setShowAll] = useState(false);
|
|
||||||
|
|
||||||
const { data: categories, isLoading, isFetching, isSuccess, isError, error, refetch } = useQuery({
|
|
||||||
queryKey: ["categories-list", showAll],
|
|
||||||
queryFn: () =>
|
|
||||||
categoryApi
|
|
||||||
.getCategories(
|
|
||||||
{
|
|
||||||
...(showAll ? {} : { limit: 6 }),
|
|
||||||
filter: ["is_active=true"],
|
|
||||||
},
|
|
||||||
{ skipAuth: true }
|
|
||||||
)
|
|
||||||
.then((res) => res.items),
|
|
||||||
staleTime: 5000,
|
|
||||||
retry: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const showSkeleton = isLoading || (isFetching && !isSuccess);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="category" className="font-onest @container container mt-24 sm:mt-32 lg:mt-48">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="h-8 w-1 rounded-full bg-blue-500" />
|
|
||||||
<h2 className="ms-3 text-[28px] font-bold text-dark-500">Topik</h2>
|
|
||||||
</div>
|
|
||||||
{!showAll ? (
|
|
||||||
<Button variant={"dangerOutlined"} onClick={() => setShowAll(true)} className="cursor-pointer">
|
|
||||||
Lihat Semua
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button variant={"dangerOutlined"} onClick={() => setShowAll(false)} className="cursor-pointer">
|
|
||||||
Lebih Sedikit
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-md text-gray-600">Telusuri ragam topik yang tersedia!</p>
|
|
||||||
|
|
||||||
{(() => {
|
|
||||||
if (showSkeleton) {
|
|
||||||
return (
|
|
||||||
<div className="mb-4 mt-8 grid grid-cols-1 gap-6 md:grid-cols-3 xl:grid-cols-6">
|
|
||||||
{[...Array(6)].map((_, i) => (
|
|
||||||
<div key={i} className="aspect-[4/3] w-full animate-pulse rounded-2xl bg-zinc-200" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isError) {
|
|
||||||
return <ErrorState message={error?.message} onRetry={refetch} />;
|
|
||||||
}
|
|
||||||
if (isSuccess && (!categories || categories.length === 0)) {
|
|
||||||
return (
|
|
||||||
<div className="mt-6 rounded-xl border border-zinc-200 bg-white p-6 text-zinc-600">Belum ada kategori untuk ditampilkan.</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isSuccess && categories && categories.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className="mb-4 mt-8 grid grid-cols-1 gap-6 md:grid-cols-3 xl:grid-cols-6">
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<CategoryCard
|
|
||||||
key={cat.id}
|
|
||||||
id={cat.id}
|
|
||||||
name={cat.name}
|
|
||||||
totalDataset={cat?.count_mapset ?? 0}
|
|
||||||
description={cat.description}
|
|
||||||
icon={cat.thumbnail}
|
|
||||||
link={`/maps?open-catalog=true&tab=category&category_id=${cat.id}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Label } from "@/shared/components/ui/label"; // atau pakai <label> native
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@radix-ui/react-radio-group";
|
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
type RatingOptions = {
|
|
||||||
value: 1 | 2 | 3 | 4 | 5;
|
|
||||||
label: string;
|
|
||||||
src: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ratingOptions: RatingOptions[] = [
|
|
||||||
{
|
|
||||||
value: 1,
|
|
||||||
label: "Sangat Tidak Puas",
|
|
||||||
src: "/icons/emoji-0.svg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 2,
|
|
||||||
label: "Tidak puas",
|
|
||||||
src: "/icons/emoji-1.svg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 3,
|
|
||||||
label: "Biasa saja",
|
|
||||||
src: "/icons/emoji-2.svg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 4,
|
|
||||||
label: "Puas",
|
|
||||||
src: "/icons/emoji-3.svg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 5,
|
|
||||||
label: "Sangat Puas",
|
|
||||||
src: "/icons/emoji-4.svg",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function EmojiRatingFeedback({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
size = 44,
|
|
||||||
name = "score",
|
|
||||||
}: {
|
|
||||||
value: number;
|
|
||||||
onChange: (v: number) => void;
|
|
||||||
showLabel?: boolean;
|
|
||||||
size?: number;
|
|
||||||
name?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<RadioGroup
|
|
||||||
name={name}
|
|
||||||
value={value != null ? String(value) : ""} // selalu controlled
|
|
||||||
onValueChange={(v) => onChange(Number(v))}
|
|
||||||
className="grid grid-cols-5"
|
|
||||||
aria-label="Rating kepuasan"
|
|
||||||
>
|
|
||||||
{ratingOptions.map((option) => {
|
|
||||||
const isActive = (value ?? 0) === option.value;
|
|
||||||
const id = `score-${option.value}`;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={option.value}
|
|
||||||
className="group grid place-items-center gap-1"
|
|
||||||
>
|
|
||||||
<RadioGroupItem
|
|
||||||
id={id}
|
|
||||||
value={String(option.value)}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Label
|
|
||||||
htmlFor={id}
|
|
||||||
className={[
|
|
||||||
"rounded-full p-1 transition transform cursor-pointer outline-none",
|
|
||||||
isActive
|
|
||||||
? "ring-2 ring-offset-2 ring-orange-500 scale-110"
|
|
||||||
: "opacity-80 hover:opacity-100 hover:scale-105 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-orange-500",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={option.src}
|
|
||||||
alt={option.label}
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
{/* Label kecil: muncul saat hover atau saat terpilih */}
|
|
||||||
<span
|
|
||||||
className={[
|
|
||||||
"text-xs text-gray-900 leading-3 text-center mt-2 opacity-0 transition-opacity",
|
|
||||||
"group-hover:opacity-100",
|
|
||||||
isActive ? "opacity-100" : "",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/shared/components/ui/select";
|
|
||||||
|
|
||||||
type PurposeOptions = {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const purposeOptions: PurposeOptions[] = [
|
|
||||||
{
|
|
||||||
value:
|
|
||||||
"Mencari data terbuka Pemerintah Daerah untuk kepentingan bisnis, perumusan kebijakan, atau referensi pribadi lainnya",
|
|
||||||
label:
|
|
||||||
"Mencari data terbuka Pemerintah Daerah untuk kepentingan bisnis, perumusan kebijakan, atau referensi pribadi lainnya",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value:
|
|
||||||
"Mencari data terbuka Pemerintah Daerah untuk kepentingan bahan ajar/kurikulum/tugas belajar",
|
|
||||||
label:
|
|
||||||
"Mencari data terbuka Pemerintah Daerah untuk kepentingan bahan ajar/kurikulum/tugas belajar",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "Mencari data untuk membuktikan kebenaran atas sebuah isu tertentu",
|
|
||||||
label: "Mencari data untuk membuktikan kebenaran atas sebuah isu tertentu",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value:
|
|
||||||
"Mempelajari lebih lanjut terkait transparansi data dan informasi yang dimiliki oleh Pemerintah Daerah",
|
|
||||||
label:
|
|
||||||
"Mempelajari lebih lanjut terkait transparansi data dan informasi yang dimiliki oleh Pemerintah Daerah",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SelectPurpose({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
name = "tujuan",
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
name: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
name={name}
|
|
||||||
value={value ?? undefined}
|
|
||||||
onValueChange={(v) => onChange(String(v))}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full max-w-[460px]">
|
|
||||||
<SelectValue aria-selected placeholder="Pilih Tujuan"></SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent className="z-[9999] w-full max-w-[460px] truncate">
|
|
||||||
{purposeOptions.map((option) => (
|
|
||||||
<SelectItem
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
className="max-w-full text-wrap"
|
|
||||||
>
|
|
||||||
<span className="block">{option.label}</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/shared/components/ui/select";
|
|
||||||
|
|
||||||
type SectorOptions = {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sectorOptions: SectorOptions[] = [
|
|
||||||
{
|
|
||||||
value: "Peneliti/Akademisi",
|
|
||||||
label: "Peneliti/Akademisi",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "Pemerintahan",
|
|
||||||
label: "Pemerintahan",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "Media",
|
|
||||||
label: "Media",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "Industri/Bisnis",
|
|
||||||
label: "Industri/Bisnis",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "Organisasi Non Profit/Sosial",
|
|
||||||
label: "Organisasi Non Profit/Sosial",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SelectSector({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
name = "sektor",
|
|
||||||
}: {
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
name: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
name={name}
|
|
||||||
value={value ?? undefined}
|
|
||||||
onValueChange={(v) => onChange(String(v))}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue aria-selected placeholder="Pilih Sektor"></SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="z-[9999]">
|
|
||||||
{sectorOptions.map((option) => (
|
|
||||||
<SelectItem
|
|
||||||
className="cursor-pointer"
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import { FeedbackFormValues } from "@/shared/schemas/feedback";
|
|
||||||
import feedbackService from "@/shared/services/feedback";
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
type UseFeedBackFormOptions = {
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useFeedBackForm(opts: UseFeedBackFormOptions) {
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmitFeedback = useCallback(
|
|
||||||
|
|
||||||
async (data: FeedbackFormValues) => {
|
|
||||||
console.log('submit', data);
|
|
||||||
try {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
await feedbackService.sendFeedback(data);
|
|
||||||
toast.success("Feedback Anda berhasil dikirim");
|
|
||||||
opts.onClose();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Gagal mengirim feedback");
|
|
||||||
console.log("gagallll", error);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[opts]
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetForm = useCallback(() => {
|
|
||||||
opts.onClose();
|
|
||||||
}, [opts]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
isSubmitting,
|
|
||||||
handleSubmitFeedback,
|
|
||||||
resetForm,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,299 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/shared/components/ui/form";
|
|
||||||
import { FeedbackFormValues, feedbackSchema } from "@/shared/schemas/feedback";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import React from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import EmojiRatingFeedback from "./_components/emoji-rating";
|
|
||||||
import { Feedback } from "@/shared/types/feedback";
|
|
||||||
import SelectSector from "./_components/select-sector";
|
|
||||||
import SelectPurpose from "./_components/select-purpose";
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group";
|
|
||||||
import { Textarea } from "@/shared/components/ui/textarea";
|
|
||||||
import { Input } from "@/shared/components/ui/input";
|
|
||||||
|
|
||||||
type FeedbackFormProps = {
|
|
||||||
defaultValues?: Partial<Feedback>;
|
|
||||||
onSubmitAction: (data: FeedbackFormValues) => void;
|
|
||||||
isSubmitting?: boolean;
|
|
||||||
onCancelAction?: () => void;
|
|
||||||
};
|
|
||||||
export function FeedbackForm({
|
|
||||||
defaultValues,
|
|
||||||
onSubmitAction,
|
|
||||||
isSubmitting,
|
|
||||||
onCancelAction,
|
|
||||||
}: FeedbackFormProps) {
|
|
||||||
const form = useForm<FeedbackFormValues>({
|
|
||||||
resolver: zodResolver(feedbackSchema),
|
|
||||||
defaultValues: {
|
|
||||||
id: defaultValues?.id || "",
|
|
||||||
score: defaultValues?.score || null,
|
|
||||||
tujuan_tercapai: defaultValues?.tujuan_tercapai || null,
|
|
||||||
tujuan_ditemukan: defaultValues?.tujuan_ditemukan || null,
|
|
||||||
tujuan: defaultValues?.tujuan || null,
|
|
||||||
sektor: defaultValues?.sektor || null,
|
|
||||||
email: defaultValues?.email || undefined,
|
|
||||||
saran: defaultValues?.saran || null,
|
|
||||||
source_url: "",
|
|
||||||
source_access: "Floating button",
|
|
||||||
notes: defaultValues?.notes || "",
|
|
||||||
gender: defaultValues?.gender || undefined,
|
|
||||||
datetime: new Date() || undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h1 className="text-center text-gray-700 text-xs mb-5">
|
|
||||||
Bantu kami meingkatkan layanan dengan feedback Anda
|
|
||||||
</h1>
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
onSubmit={form.handleSubmit(
|
|
||||||
async (values) => {
|
|
||||||
console.log("submit", values);
|
|
||||||
await onSubmitAction(values); // <- ini memanggil hook kamu
|
|
||||||
form.reset();
|
|
||||||
},
|
|
||||||
(errors) => {
|
|
||||||
console.log("[FeedbackForm] invalid submit", errors);
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
className="flex flex-col space-y-4"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="score"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="mb-2">
|
|
||||||
Seberapa puaskah Anda dengan layanan yang kami berikan?
|
|
||||||
<span className="text-red-500">*</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<EmojiRatingFeedback
|
|
||||||
value={Number(field.value)}
|
|
||||||
onChange={(v) => field.onChange(v)}
|
|
||||||
showLabel
|
|
||||||
size={44}
|
|
||||||
name={field.name}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="sektor"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
Pilih sektor/grup berikut yang mewakili posisi Anda saat ini{" "}
|
|
||||||
<span className="text-red-500">*</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<SelectSector
|
|
||||||
value={field.value ?? ""}
|
|
||||||
onChange={(v) => field.onChange(v)}
|
|
||||||
name={field.name}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
></FormField>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="tujuan"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
Apakah tujuan utama Anda mengunjungi laman Satu Peta Provinsi
|
|
||||||
Jawa Timur hari ini?<span className="text-red-500">*</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<SelectPurpose
|
|
||||||
value={field.value ?? ""}
|
|
||||||
onChange={(v) => field.onChange(v)}
|
|
||||||
name={field.name}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
></FormField>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="tujuan_tercapai"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
Apakah Anda berhasil menemukan data atau informasi yang Anda
|
|
||||||
cari? <span className="text-red-500">*</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroup
|
|
||||||
value={
|
|
||||||
field.value == null ? undefined : String(field.value)
|
|
||||||
}
|
|
||||||
onValueChange={(v) => field.onChange(v === "true")}
|
|
||||||
className="flex flex-col space-y-2"
|
|
||||||
>
|
|
||||||
<FormItem className="flex items-center space-x-1 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem value="true" />
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel className="font-normal">Ya</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem className="flex items-center space-x-1 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem value="false" />
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel className="font-normal">Tidak</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
</RadioGroup>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
></FormField>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="tujuan_ditemukan"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
Apakah informasi yang Anda cari mudah untuk didapatkan?{" "}
|
|
||||||
<span className="text-red-500">*</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroup
|
|
||||||
value={
|
|
||||||
field.value == null ? undefined : String(field.value)
|
|
||||||
}
|
|
||||||
onValueChange={(v) => field.onChange(v === "true")}
|
|
||||||
className="flex flex-col space-y-2"
|
|
||||||
>
|
|
||||||
<FormItem className="flex items-center space-x-1 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem value="true" />
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel className="font-normal">Ya</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem className="flex items-center space-x-1 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem value="false" />
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel className="font-normal">Tidak</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
</RadioGroup>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
></FormField>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="gender"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
Apa jenis kelamin Anda?
|
|
||||||
<span className="text-red-500">*</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroup
|
|
||||||
value={
|
|
||||||
field.value == null ? undefined : String(field.value)
|
|
||||||
}
|
|
||||||
onValueChange={(v) => field.onChange(Number(v))}
|
|
||||||
className="flex flex-col space-y-2"
|
|
||||||
>
|
|
||||||
<FormItem className="flex items-center space-x-1 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem value="1" />
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel className="font-normal">Laki-laki</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem className="flex items-center space-x-1 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem value="0" />
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel className="font-normal">Perempuan</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
</RadioGroup>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
></FormField>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="saran"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
Tuliskan saran atau kendala yang Anda alami dalam penggunaan
|
|
||||||
Satu Peta Provinsi Jawa Timur agar kami dapat memberikan
|
|
||||||
layanan lebih baik lagi
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
placeholder="Tuliskan saran Anda"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Email</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="Masukkan alamat email"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end space-x-4">
|
|
||||||
{onCancelAction && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCancelAction}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? "Mengirim..." : "Kirim"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/shared/components/ui/dialog";
|
|
||||||
import Image from "next/image";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { FeedbackForm } from "./feedback-form";
|
|
||||||
import { useFeedBackForm } from "./_hooks/use-feedback-form";
|
|
||||||
|
|
||||||
export function FeedbackButton() {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const { handleSubmitFeedback, isSubmitting, resetForm } = useFeedBackForm({
|
|
||||||
onClose: () => setIsOpen(false),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
{!isOpen && (
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="group fixed top-1/2 right-0 z-[1500] grid h-[112px] w-[36px] -translate-y-1/2 cursor-pointer place-items-center rounded-tl-md rounded-bl-md bg-[#fe7400]"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src="/feedback-button.svg"
|
|
||||||
alt="feedback-button"
|
|
||||||
width={36}
|
|
||||||
height={36}
|
|
||||||
className="pointer-events-none block transition-transform duration-300 ease-in-out group-hover:-translate-x-2" /* geser halus 8px */
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</DialogTrigger>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isOpen && <div className="bg-white/40 backdrop-blur-sm"></div>}
|
|
||||||
<DialogContent className="z-[1500] max-h-[550px] overflow-y-auto pr-2">
|
|
||||||
<DialogHeader className="">
|
|
||||||
<DialogTitle>
|
|
||||||
<Image
|
|
||||||
src={"/logo.svg"}
|
|
||||||
alt={"Logo Satu Peta"}
|
|
||||||
width={120}
|
|
||||||
height={30}
|
|
||||||
/>
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<FeedbackForm
|
|
||||||
onSubmitAction={handleSubmitFeedback}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
onCancelAction={resetForm}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { appConfig } from "@/shared/config/app-config";
|
|
||||||
import { TrendingUp, Download } from "lucide-react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
url: string;
|
|
||||||
icon: string;
|
|
||||||
title: string;
|
|
||||||
value: number | string;
|
|
||||||
loading?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function StatisticCardItem({ url, icon, title, value, loading }: Props) {
|
|
||||||
const isDisabled = title === "Total Pengunjung";
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-biru-300/50 h-[150px] animate-pulse rounded-3xl px-7 py-4">
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<div className="h-8 w-24 rounded bg-white/40" />
|
|
||||||
<div className="h-8 w-8 rounded bg-white/40" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 h-4 w-32 rounded bg-white/40" />
|
|
||||||
<div className="mt-8 h-4 w-24 rounded bg-white/40" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={url}
|
|
||||||
onClick={isDisabled ? (e) => e.preventDefault() : undefined}
|
|
||||||
aria-disabled={isDisabled}
|
|
||||||
className={`group border-primary bg-biru-300 font-onest relative h-[150px] h-full overflow-hidden rounded-3xl border border-3 px-7 py-5 transition-all duration-300 ease-in-out ${isDisabled ? "hover:cursor-default" : "hover:cursor-pointer"}`}
|
|
||||||
>
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<h5 className="text-[32px] font-bold text-white">{value}</h5>
|
|
||||||
<Image
|
|
||||||
src={icon}
|
|
||||||
alt="icon"
|
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
className="transition-transform duration-300 ease-in-out group-hover:top-0 group-hover:right-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col text-white">
|
|
||||||
<p className="text-sm font-normal">{title}</p>
|
|
||||||
<div className="mt-2 flex items-center gap-2 text-sm">
|
|
||||||
{title === "Total Pengunjung" ? (
|
|
||||||
<>
|
|
||||||
<TrendingUp /> Pengunjung dalam 1 Tahun
|
|
||||||
</>
|
|
||||||
) : title === "Total Unduhan" ? (
|
|
||||||
<>
|
|
||||||
<Download /> Unduhan dalam 1 Tahun
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="underline">Lihat Semua</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Image
|
|
||||||
src={appConfig.heroCardOrnament}
|
|
||||||
width={120}
|
|
||||||
height={120}
|
|
||||||
alt="ornament"
|
|
||||||
className="absolute top-0 right-0 mix-blend-overlay transition-transform duration-500 ease-out group-hover:scale-125"
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import { appConfig } from "@/shared/config/app-config";
|
|
||||||
import { StatisticCountResponse } from "@/shared/services/statistic-count";
|
|
||||||
|
|
||||||
export type StatKey = keyof StatisticCountResponse;
|
|
||||||
|
|
||||||
export type StatConfig = {
|
|
||||||
key: StatKey;
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
icon: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const STAT_CARDS: StatConfig[] = [
|
|
||||||
{
|
|
||||||
key: "mapset_count",
|
|
||||||
title: "Total Mapset",
|
|
||||||
url: "/maps?open-catalog=true",
|
|
||||||
icon: appConfig.heroCardMapsetIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "organization_count",
|
|
||||||
title: "Organisasi Perangkat Daerah",
|
|
||||||
url: "#organization",
|
|
||||||
icon: appConfig.heroCardOpdicon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "metadata_count",
|
|
||||||
title: "Metadata",
|
|
||||||
url: "https://geonetwork.jatimprov.go.id/",
|
|
||||||
icon: appConfig.heroCardMetadataIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "visitor_count",
|
|
||||||
title: "Total Pengunjung",
|
|
||||||
url: "/",
|
|
||||||
icon: appConfig.heroCardVisitorsIcon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "download_count",
|
|
||||||
title: "Total Unduhan",
|
|
||||||
url: "/",
|
|
||||||
icon: appConfig.heroCardDownloadsIcon,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { MapsetCard } from "../../mapset-card";
|
|
||||||
import mapsetApi from "@/shared/services/mapset";
|
|
||||||
|
|
||||||
export default function HighlightMapset() {
|
|
||||||
const { data: mapsets, isLoading } = useQuery({
|
|
||||||
queryKey: ["mapsets-highlight"],
|
|
||||||
queryFn: () =>
|
|
||||||
mapsetApi
|
|
||||||
.getMapsets(
|
|
||||||
{
|
|
||||||
limit: 3,
|
|
||||||
filter: JSON.stringify([
|
|
||||||
"is_active=true",
|
|
||||||
"is_popular=true",
|
|
||||||
"status_validation=approved",
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
{ skipAuth: true },
|
|
||||||
)
|
|
||||||
.then((res) => {
|
|
||||||
return res.items;
|
|
||||||
}),
|
|
||||||
staleTime: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="mt-20 grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:gap-9">
|
|
||||||
{[...Array(3)].map((_, index) => (
|
|
||||||
<div key={index} className="animate-pulse">
|
|
||||||
<div className="h-64 rounded-lg bg-gray-200"></div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mapsets || mapsets.length === 0) {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-20 grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:gap-9">
|
|
||||||
{mapsets.slice(0, 3).map((mapset) => (
|
|
||||||
<div key={mapset.id}>
|
|
||||||
<MapsetCard mapset={mapset} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/shared/components/button/button";
|
|
||||||
import { Search } from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export function HeroSearch() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [input, setInput] = useState("");
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setInput(e.target.value);
|
|
||||||
};
|
|
||||||
const handleSearch = () => {
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
|
|
||||||
if (input) searchParams.set("query", input);
|
|
||||||
searchParams.set("open-catalog", "true");
|
|
||||||
|
|
||||||
router.push(`/maps?${searchParams.toString()}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openMap = () => {
|
|
||||||
router.push(`/maps?open-catalog=true`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="@container/hero-search container pb-20">
|
|
||||||
<div className="mx-auto flex max-w-[786px] flex-col gap-4 rounded-2xl bg-white @xl/hero-search:flex-row @xl/hero-search:py-2 @xl/hero-search:pr-2 @xl/hero-search:pl-8 @3xl/hero-search:mb-[150px]">
|
|
||||||
<div className="flex grow items-center gap-x-3 rounded-2xl bg-white px-3.5 py-4 @xl/hero-search:p-0">
|
|
||||||
<Search className="shrink-0 text-gray-400" size={18} />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Eksplor data berdasarkan tema atau kata kunci"
|
|
||||||
className="w-full outline-0"
|
|
||||||
value={input}
|
|
||||||
onChange={handleChange}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
handleSearch();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 px-3 pb-2 @xl/hero-search:flex-row @xl/hero-search:items-center @xl/hero-search:p-0 @2xl/hero-search:grid @2xl/hero-search:grid-cols-[1fr_1px_1fr]">
|
|
||||||
<Button variant="danger" onClick={handleSearch}>
|
|
||||||
Cari
|
|
||||||
</Button>
|
|
||||||
<div className="hidden h-9 w-px bg-zinc-300 @xl/hero-search:block" />
|
|
||||||
<Button variant="primary" onClick={openMap}>
|
|
||||||
Buka peta
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { StatisticCardItem } from "../_components/statistic-card-item";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import countStatisticApi from "@/shared/services/count";
|
|
||||||
import { STAT_CARDS } from "../_components/statistic-config";
|
|
||||||
|
|
||||||
const formatNumber = (n?: number) =>
|
|
||||||
typeof n === "number" ? n.toLocaleString("id-ID") : "-";
|
|
||||||
export function StatisticCard() {
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ["count"],
|
|
||||||
queryFn: () =>
|
|
||||||
countStatisticApi.getCountStatistic({ skipAuth: true }).then((r) => r),
|
|
||||||
staleTime: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="@container/hero-statistic container">
|
|
||||||
<div className="@container/hero-statistic container px-0 px-4 @3xl/hero-statistic:absolute @3xl/hero-statistic:inset-x-0 @3xl/hero-statistic:-translate-y-1/2">
|
|
||||||
<div className="mx-auto grid grid-cols-1 items-center justify-center gap-5 @xl/hero-statistic:grid-cols-2 @6xl/hero-statistic:grid-cols-5">
|
|
||||||
{STAT_CARDS.map((cfg) => (
|
|
||||||
<StatisticCardItem
|
|
||||||
key={cfg.key}
|
|
||||||
url={cfg.url}
|
|
||||||
icon={cfg.icon}
|
|
||||||
title={cfg.title}
|
|
||||||
value={formatNumber(data?.[cfg.key])}
|
|
||||||
loading={isLoading}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
import { appConfig } from "@/shared/config/app-config";
|
|
||||||
import { HeroSearch } from "./_views/search";
|
|
||||||
import { StatisticCard } from "./_views/statistic-card";
|
|
||||||
|
|
||||||
export function HeroSection() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="@container/hero relative pt-36 lg:pt-48">
|
|
||||||
<div className="absolute inset-0 z-[-1] h-full w-full overflow-hidden">
|
|
||||||
<Image
|
|
||||||
src={appConfig.heroImage}
|
|
||||||
alt="Satu Peta Illustration"
|
|
||||||
fill
|
|
||||||
className="inset-0 h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="font-onest @container/hero container mx-auto w-full max-w-[62rem] text-center">
|
|
||||||
<h1 className="mb-5 text-4xl font-medium text-balance text-[#3188E5] @lg/hero:text-[64px]">
|
|
||||||
Platform Data Geospasial Jawa Timur
|
|
||||||
</h1>
|
|
||||||
<h2 className="mb-14 text-xl text-balance text-[#3F5368]">
|
|
||||||
Satu peta untuk pembangunan berkelanjutan
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<HeroSearch />
|
|
||||||
<StatisticCard />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
|
||||||
import { Mapset } from "@/shared/types/mapset";
|
|
||||||
import { ArrowRight } from "lucide-react";
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
const PreviewMap = dynamic(() => import("@/shared/components/preview-map"), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div className="w-full aspect-square bg-gray-200 animate-pulse" />
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export function MapsetCard({ mapset }: Readonly<{ mapset: Mapset }>) {
|
|
||||||
return (
|
|
||||||
<Card className="cursor-pointer group w-full h-full p-0 overflow-hidden border rounded-lg">
|
|
||||||
<div className="grid grid-cols-2 h-full">
|
|
||||||
<div className="w-full aspect-square">
|
|
||||||
<PreviewMap mapset={mapset} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CardContent className="flex flex-col justify-between py-3 px-4 h-full">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<span className="bg-yellow-400 text-black text-xs px-2 py-1 rounded">
|
|
||||||
{mapset?.category?.name}
|
|
||||||
</span>
|
|
||||||
<Link href={`/maps?mapset-id=${mapset.id}`}>
|
|
||||||
<div
|
|
||||||
className="line-clamp-4 hover:text-primary mt-2 font-medium lg:line-clamp-2 xl:line-clamp-3
|
|
||||||
text-base sm:text-lg md:text-lg lg:text-lg xl:text-2xl"
|
|
||||||
>
|
|
||||||
{mapset?.name}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href={`/maps?mapset-id=${mapset.id}`}
|
|
||||||
className="mt-4 self-start"
|
|
||||||
>
|
|
||||||
<ArrowRight className="text-primary" />
|
|
||||||
</Link>
|
|
||||||
</CardContent>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
export function NewsMainCardSketelon() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="animate-pulse overflow-hidden rounded-2xl border border-zinc-200"
|
|
||||||
>
|
|
||||||
{/* image */}
|
|
||||||
<div className="aspect-[21/9] w-full bg-zinc-200" />
|
|
||||||
{/* body */}
|
|
||||||
<div className="space-y-3 p-4">
|
|
||||||
<div className="h-5 w-28 rounded bg-zinc-200" /> {/* chip/date row */}
|
|
||||||
<div className="h-6 w-4/5 rounded bg-zinc-200" /> {/* title */}
|
|
||||||
<div className="h-4 w-full rounded bg-zinc-200" /> {/* desc line 1 */}
|
|
||||||
<div className="h-4 w-11/12 rounded bg-zinc-200" /> {/* desc line 2 */}
|
|
||||||
<div className="h-4 w-2/3 rounded bg-zinc-200" /> {/* desc line 3 */}
|
|
||||||
<div className="mt-2 h-5 w-40 rounded bg-zinc-200" />
|
|
||||||
{/* link */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NewsSecondaryCardSkeleton() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="animate-pulse overflow-hidden rounded-2xl border border-zinc-200 p-4"
|
|
||||||
>
|
|
||||||
<div className="h-5 w-24 rounded bg-zinc-200" /> {/* chip */}
|
|
||||||
<div className="mt-3 h-5 w-3/4 rounded bg-zinc-200" /> {/* title 1 */}
|
|
||||||
<div className="mt-2 h-5 w-5/6 rounded bg-zinc-200" /> {/* title 2 */}
|
|
||||||
<div className="mt-3 h-4 w-full rounded bg-zinc-200" />
|
|
||||||
{/* desc 1 */}
|
|
||||||
<div className="mt-2 h-4 w-10/12 rounded bg-zinc-200" />
|
|
||||||
{/* desc 2 */}
|
|
||||||
<div className="mt-4 h-5 w-36 rounded bg-zinc-200" /> {/* link */}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import newsApi from "@/shared/services/news";
|
|
||||||
import { Button } from "@/shared/components/button/button";
|
|
||||||
import { NewsMainCard } from "./news-main-card";
|
|
||||||
import { NewsSecondaryCard } from "./news-secondary-card";
|
|
||||||
import {
|
|
||||||
NewsMainCardSketelon,
|
|
||||||
NewsSecondaryCardSkeleton,
|
|
||||||
} from "./_components/news-card-skeleton";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { ErrorState } from "@/shared/components/error-state";
|
|
||||||
|
|
||||||
export function NewsSection() {
|
|
||||||
const {
|
|
||||||
data: news,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
error,
|
|
||||||
isSuccess,
|
|
||||||
refetch,
|
|
||||||
isFetching,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["news"],
|
|
||||||
queryFn: () =>
|
|
||||||
newsApi
|
|
||||||
.getNewsList(
|
|
||||||
{
|
|
||||||
limit: 5,
|
|
||||||
filter: ["is_active=true"],
|
|
||||||
},
|
|
||||||
{ skipAuth: true },
|
|
||||||
)
|
|
||||||
.then((res) => res.items),
|
|
||||||
staleTime: 5000,
|
|
||||||
retry: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const showSkeleton = isLoading || (isFetching && !isSuccess);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<section
|
|
||||||
id="news"
|
|
||||||
className="bg-biru-300 font-onest relative mt-40 min-h-[540px] py-18"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={"/landing/ornaments/ornament2.webp"}
|
|
||||||
alt="ornament"
|
|
||||||
width={340}
|
|
||||||
height={340}
|
|
||||||
className="absolute bottom-0 left-0 mix-blend-overlay"
|
|
||||||
/>
|
|
||||||
<div className="@container relative container">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex flex-col justify-start gap-y-2">
|
|
||||||
<div className="flex items-center text-white">
|
|
||||||
<div className="h-8 w-1 rounded-full bg-white"></div>
|
|
||||||
<h2 className="font-onest ms-3 text-3xl font-bold">
|
|
||||||
Berita & Pengumuman
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-md text-white">
|
|
||||||
Informasi terbaru seputar perkembangan data geospasial Jawa
|
|
||||||
Timur
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link href="/news">
|
|
||||||
<Button variant={"whiteOutlined"}>Lihat Semua</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
if (showSkeleton) {
|
|
||||||
return (
|
|
||||||
<div className="mt-8 grid gap-8 md:grid-cols-2 lg:grid-cols-3 lg:grid-rows-2">
|
|
||||||
<div className="lg:col-span-2 lg:row-span-2">
|
|
||||||
<NewsMainCardSketelon />
|
|
||||||
</div>
|
|
||||||
<NewsSecondaryCardSkeleton />
|
|
||||||
<NewsSecondaryCardSkeleton />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return <ErrorState message={error?.message} onRetry={refetch} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSuccess && (!news || news.length === 0)) {
|
|
||||||
return (
|
|
||||||
<div className="mt-6 rounded-xl border border-zinc-200 bg-white p-6 text-zinc-600">
|
|
||||||
Belum ada berita untuk ditampilkan.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSuccess && news && news.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className="mt-8 grid gap-8 md:grid-cols-2 lg:grid-cols-3 lg:grid-rows-2">
|
|
||||||
<NewsMainCard news={news[0]} />
|
|
||||||
<NewsSecondaryCard news={news[1]} />
|
|
||||||
<NewsSecondaryCard news={news[2]} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { getFileUrl } from "@/shared/utils/file";
|
|
||||||
import { ArrowRight } from "lucide-react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
import DOMPurify from "dompurify";
|
|
||||||
|
|
||||||
function truncateHtml(html: string, maxLength: number): string {
|
|
||||||
const tempEl = document.createElement("div");
|
|
||||||
tempEl.innerHTML = html;
|
|
||||||
const textContent = tempEl.textContent || "";
|
|
||||||
return textContent.length > maxLength
|
|
||||||
? textContent.slice(0, maxLength) + "..."
|
|
||||||
: textContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NewsCard({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
link,
|
|
||||||
image,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
link: string;
|
|
||||||
image?: string;
|
|
||||||
}) {
|
|
||||||
const safeHtml = DOMPurify.sanitize(truncateHtml(description, 300));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative flex flex-col gap-4 rounded-xl border border-slate-400 p-4 shadow-sm">
|
|
||||||
{image && (
|
|
||||||
<div className="relative w-full" style={{ height: 379 }}>
|
|
||||||
<Image
|
|
||||||
src={getFileUrl(image)}
|
|
||||||
alt="news"
|
|
||||||
fill
|
|
||||||
className="rounded-md object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="relative flex flex-col gap-4 pb-20">
|
|
||||||
<h2 className="text-2xl leading-snug font-semibold text-slate-700">
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="text-sm text-slate-500"
|
|
||||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href={link}
|
|
||||||
className="text-primary absolute bottom-4 left-4 flex items-center text-sm font-medium hover:underline"
|
|
||||||
>
|
|
||||||
Lihat selengkapnya
|
|
||||||
<ArrowRight size={16} className="ml-1" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
|
||||||
import { News } from "@/shared/types/news";
|
|
||||||
import { ArrowRight } from "lucide-react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
type Props = { news: News };
|
|
||||||
export function NewsMainCard({ news }: Props) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Card className="font-onest h-full w-full overflow-hidden p-0">
|
|
||||||
<CardContent className="flex h-full flex-col p-0">
|
|
||||||
<div className="relative aspect-[21/9] w-full">
|
|
||||||
<Image
|
|
||||||
src={news.thumbnail ?? "/landing/placeholder.webp"}
|
|
||||||
alt="thumbnail"
|
|
||||||
fill
|
|
||||||
className="rounded-t-md object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col space-y-3 p-5">
|
|
||||||
{/* <div className="mb-8 flex items-center justify-between">
|
|
||||||
<span className="bg-biru-150 text-biru-500 rounded-full px-4 py-1 text-xs font-medium">
|
|
||||||
{news.}
|
|
||||||
</span>
|
|
||||||
<div className="text-dark-300 flex items-center text-sm">
|
|
||||||
<Calendar size={14} />
|
|
||||||
<p className="ms-1 mb-0">{dateLabel}</p>
|
|
||||||
</div>
|
|
||||||
</div> */}
|
|
||||||
<h3 className="md:text-md mb-2 line-clamp-4 text-base font-medium sm:text-lg lg:line-clamp-2 lg:text-lg xl:line-clamp-3 xl:text-lg">
|
|
||||||
{news.name}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p className="text-dark-300 mb-8 text-sm">{news.description}</p>
|
|
||||||
|
|
||||||
<div className="mt-auto">
|
|
||||||
<Link
|
|
||||||
href={`/news/${news.id}`}
|
|
||||||
className="text-primary hover:text-biru-300 flex items-center text-sm"
|
|
||||||
>
|
|
||||||
Baca selengkapnya <ArrowRight size={14} className="ml-1" />
|
|
||||||
</Link>
|
|
||||||
{/* <a
|
|
||||||
href={`/maps?mapset-id=${mapset.id}`}
|
|
||||||
className="text-primary inline-flex items-center hover:underline"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<div className="flex w-full items-center justify-between gap-4 text-zinc-500">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
<span>2.450</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
<span>892</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
{/* <div className="font-onest relative h-full overflow-hidden rounded-2xl lg:col-span-2 lg:row-span-2">
|
|
||||||
<Image
|
|
||||||
src={"/landing/hero.webp"}
|
|
||||||
alt="thumbnail"
|
|
||||||
fill
|
|
||||||
className="rounded-t-md object-cover"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col p-0">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="bg-biru-150 text-biru-500 rounded-full">
|
|
||||||
Pengumuman
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div> */}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
|
||||||
import { News } from "@/shared/types/news";
|
|
||||||
import { ArrowRight } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import DOMPurify from "dompurify";
|
|
||||||
|
|
||||||
function truncateHtml(html: string, maxLength: number): string {
|
|
||||||
const tempEl = document.createElement("div");
|
|
||||||
tempEl.innerHTML = html;
|
|
||||||
const textContent = tempEl.textContent || "";
|
|
||||||
return textContent.length > maxLength
|
|
||||||
? textContent.slice(0, maxLength) + "..."
|
|
||||||
: textContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = { news: News };
|
|
||||||
export function NewsSecondaryCard({ news }: Props) {
|
|
||||||
const safeHtml = DOMPurify.sanitize(truncateHtml(news.description, 300));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Card className="font-onest h-full w-full overflow-hidden p-0">
|
|
||||||
<CardContent className="flex h-full flex-col p-0">
|
|
||||||
<div className="flex flex-1 flex-col space-y-3 p-5">
|
|
||||||
{/* <div className="mb-8 flex items-center justify-between">
|
|
||||||
<span className="bg-biru-150 text-biru-500 rounded-full px-4 py-1 text-xs font-medium">
|
|
||||||
Pengumuman
|
|
||||||
</span>
|
|
||||||
<div className="text-dark-300 flex items-center text-sm">
|
|
||||||
<Calendar size={14} />
|
|
||||||
<p className="ms-1 mb-0">12 Oktober 2025</p>
|
|
||||||
</div>
|
|
||||||
</div> */}
|
|
||||||
<h3 className="md:text-md mb-2 line-clamp-4 text-base font-medium sm:text-lg lg:line-clamp-2 lg:text-lg xl:line-clamp-3 xl:text-lg">
|
|
||||||
{news.name}
|
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
className="text-dark-300 mb-8 text-sm"
|
|
||||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
|
||||||
></p>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href={`/news/${news.id}`}
|
|
||||||
className="text-primary hover:text-biru-300 flex items-center text-sm"
|
|
||||||
>
|
|
||||||
Baca selengkapnya <ArrowRight size={14} className="ml-1" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
"use client";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import OrganizationCard from "./organization-card";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import organizationApi from "@/shared/services/organization";
|
|
||||||
import { Button } from "@/shared/components/button/button";
|
|
||||||
import { ErrorState } from "@/shared/components/error-state";
|
|
||||||
|
|
||||||
export function OrganizationSection() {
|
|
||||||
const [showAll, setShowAll] = useState(false);
|
|
||||||
const {
|
|
||||||
data: organizations,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
error,
|
|
||||||
isSuccess,
|
|
||||||
refetch,
|
|
||||||
isFetching,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["organizations-list", showAll],
|
|
||||||
queryFn: () =>
|
|
||||||
organizationApi
|
|
||||||
.getOrganizations(
|
|
||||||
{
|
|
||||||
...(showAll ? {} : { limit: 8 }),
|
|
||||||
filter: ["is_active=true"],
|
|
||||||
},
|
|
||||||
{ skipAuth: true },
|
|
||||||
)
|
|
||||||
.then((res) => res.items),
|
|
||||||
staleTime: 5000,
|
|
||||||
retry: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const showSkeleton = isLoading || (isFetching && !isSuccess);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<section
|
|
||||||
id="organization"
|
|
||||||
className="font-onest @container container mt-24"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="h-8 w-1 rounded-full bg-blue-500"></div>
|
|
||||||
<h2 className="font-onest text-dark-500 ms-3 text-[28px] font-bold">
|
|
||||||
Data Geospasial Lintas OPD
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
{!showAll ? (
|
|
||||||
<Button
|
|
||||||
variant={"dangerOutlined"}
|
|
||||||
onClick={() => setShowAll(true)}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
Lihat Semua
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant={"dangerOutlined"}
|
|
||||||
onClick={() => setShowAll(false)}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
Lebih Sedikit
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-md text-gray-600">
|
|
||||||
Organisasi Perangkat Daerah yang berkontribusi dalam penyediaan data
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{(() => {
|
|
||||||
if (showSkeleton) {
|
|
||||||
return (
|
|
||||||
<div className="mt-8 mb-4 grid grid-cols-1 gap-4 md:grid-cols-4">
|
|
||||||
{[...Array(8)].map((_, i) => (
|
|
||||||
<div
|
|
||||||
className="aspect-video w-full animate-pulse rounded-xl bg-zinc-200"
|
|
||||||
key={i}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return <ErrorState message={error?.message} onRetry={refetch} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleOrgs = (organizations ?? []).filter((o) => (o?.count_mapset ?? 0) > 0);
|
|
||||||
|
|
||||||
if (isSuccess && visibleOrgs.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="mt-6 rounded-xl border border-zinc-200 bg-white p-6 text-zinc-600">
|
|
||||||
Belum ada data geospasial lintas OPD untuk ditampilkan.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSuccess && visibleOrgs.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className="mt-8 mb-4 grid grid-cols-1 gap-4 md:grid-cols-4">
|
|
||||||
{visibleOrgs.map((org) => (
|
|
||||||
<OrganizationCard
|
|
||||||
name={org.name}
|
|
||||||
totalDataset={org?.count_mapset ?? 0}
|
|
||||||
link={`/maps?open-catalog=true&query=${org.name}&tab=organization&producer_id=${org.id}`}
|
|
||||||
icon={org.thumbnail ?? "/landing/logo_pemprov_jatim.png"}
|
|
||||||
key={org.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { getFileThumbnailUrl } from "@/shared/utils/file";
|
|
||||||
import { ArrowRight } from "lucide-react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default function OrganizationCard({
|
|
||||||
name,
|
|
||||||
link,
|
|
||||||
totalDataset,
|
|
||||||
icon,
|
|
||||||
}: Readonly<{
|
|
||||||
name: string;
|
|
||||||
link: string;
|
|
||||||
totalDataset: number;
|
|
||||||
icon: string;
|
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={link}
|
|
||||||
className="group font-onest transition-border hover:border-primary flex h-full flex-col justify-between gap-4 rounded-xl border border-[#94A3B8] p-6 ease-in-out hover:cursor-pointer hover:border-2"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="relative h-28 w-28 shrink-0">
|
|
||||||
<Image
|
|
||||||
src={getFileThumbnailUrl(icon)}
|
|
||||||
alt="icon"
|
|
||||||
fill
|
|
||||||
sizes="64px"
|
|
||||||
className="object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ArrowRight
|
|
||||||
size={24}
|
|
||||||
className="group-hover:text-biru-300 text-gray-400 transition-colors duration-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<h3 className="text-base sm:text-sm font-medium line-clamp-3 min-h-[4.5rem]">
|
|
||||||
{name}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="text-md text-biru-300 font-semibold">
|
|
||||||
{totalDataset} Dataset
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { ArrowRight } from "lucide-react";
|
|
||||||
import mapsetApi from "@/shared/services/mapset";
|
|
||||||
import { getTotalMetadata } from "@/shared/utils/geonetwork";
|
|
||||||
|
|
||||||
export function StatisticsSection() {
|
|
||||||
const [mapsetCount, setMapsetCount] = useState<number>(0);
|
|
||||||
const [metadataCount, setMetadataCount] = useState<number>(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchStats = async () => {
|
|
||||||
try {
|
|
||||||
const [mapsetResponse, metadataResponse] = await Promise.all([
|
|
||||||
mapsetApi.getMapsets(
|
|
||||||
{
|
|
||||||
filter: JSON.stringify(["is_active=true", "status_validation=approved", "is_deleted=false"]),
|
|
||||||
},
|
|
||||||
{ skipAuth: true }
|
|
||||||
),
|
|
||||||
getTotalMetadata(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
setMapsetCount(mapsetResponse.total);
|
|
||||||
if (metadataResponse !== null) {
|
|
||||||
setMetadataCount(metadataResponse);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching statistics:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchStats();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="py-12 bg-gray-50" id="statistic">
|
|
||||||
<div className="container max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="flex flex-col gap-20">
|
|
||||||
<div className="w-1/2">
|
|
||||||
<div className="flex items-center justify-between mb-10">
|
|
||||||
<h2 className="text-5xl text-slate-700">Statistik Konten</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-slate-500 text-xl mb-6 max-w-3xl">
|
|
||||||
Statistik Konten menampilkan jumlah total mapset dan metadata yang tersedia di dalam platform Satu Peta, memberikan gambaran terkini mengenai cakupan dan kelengkapan data geospasial yang terpublikasi
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 mb-4 border border-[#94A3B8]">
|
|
||||||
<div className="bg-primary-light p-4">
|
|
||||||
<div className="flex flex-col gap-4 mb-24">
|
|
||||||
<h3 className="text-8xl font-bold text-primary mb-2">{mapsetCount}</h3>
|
|
||||||
<p className="text-slate-600 mb-4 text-2xl">Mapset</p>
|
|
||||||
</div>
|
|
||||||
<Link href="/maps?open-catalog=true" className="flex items-center text-primary text-sm font-medium hover:underline">
|
|
||||||
Lihat selengkapnya
|
|
||||||
<ArrowRight size={16} className="ml-1" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-sky-950 p-4 text-slate-50">
|
|
||||||
<div className="flex flex-col gap-4 mb-24">
|
|
||||||
<h3 className="text-8xl font-bold mb-2">{metadataCount}</h3>
|
|
||||||
<p className="mb-4 text-2xl">Metadata</p>
|
|
||||||
</div>
|
|
||||||
<Link href="https://geonetwork.jatimprov.go.id/" className="flex items-center text-sm font-medium hover:underline">
|
|
||||||
Lihat selengkapnya
|
|
||||||
<ArrowRight size={16} className="ml-1" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
// app/(dashboard)/manajemen-peta/components/confirmation-dialog.tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ds/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/shared/components/ui/dialog";
|
|
||||||
|
|
||||||
interface ConfirmationDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
confirmText?: string;
|
|
||||||
cancelText?: string;
|
|
||||||
isLoading?: boolean;
|
|
||||||
onConfirm: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
variant?:
|
|
||||||
| "link"
|
|
||||||
| "destructive"
|
|
||||||
| "outline"
|
|
||||||
| "secondary"
|
|
||||||
| "ghost"
|
|
||||||
| "success"
|
|
||||||
| "primary"
|
|
||||||
| "tertiary"
|
|
||||||
| null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ConfirmationDialog = ({
|
|
||||||
open,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
confirmText = "Ya",
|
|
||||||
cancelText = "Batal",
|
|
||||||
isLoading = false,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
variant = "primary",
|
|
||||||
}: ConfirmationDialogProps) => {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onCancel()}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{title}</DialogTitle>
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter className="flex flex-row justify-end gap-2 sm:justify-end">
|
|
||||||
<Button variant="outline" onClick={onCancel} disabled={isLoading}>
|
|
||||||
{cancelText}
|
|
||||||
</Button>
|
|
||||||
<Button variant={variant} onClick={onConfirm} disabled={isLoading}>
|
|
||||||
{isLoading ? `${confirmText}...` : confirmText}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ColumnDef,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getSortedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
PaginationState,
|
|
||||||
SortingState,
|
|
||||||
Updater,
|
|
||||||
RowSelectionState,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/shared/components/ui/table";
|
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
|
||||||
import { Pagination } from "@/shared/components/ui/pagination";
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
|
||||||
|
|
||||||
interface DataTableProps<TData, TValue> {
|
|
||||||
columns: ColumnDef<TData, TValue>[];
|
|
||||||
data: TData[];
|
|
||||||
pageCount: number;
|
|
||||||
pageIndex: number;
|
|
||||||
pageSize: number;
|
|
||||||
onPaginationChangeAction: (pagination: PaginationState) => void;
|
|
||||||
manualPagination?: boolean;
|
|
||||||
rowCount: number;
|
|
||||||
sorting: SortingState;
|
|
||||||
onSortingChangeAction: (sorting: SortingState) => void;
|
|
||||||
onRowSelectionChange?: (selectedRows: TData[]) => void;
|
|
||||||
enableRowSelection?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataTable<TData, TValue>({
|
|
||||||
columns,
|
|
||||||
data,
|
|
||||||
pageCount,
|
|
||||||
pageIndex,
|
|
||||||
pageSize,
|
|
||||||
sorting,
|
|
||||||
onSortingChangeAction,
|
|
||||||
onPaginationChangeAction,
|
|
||||||
onRowSelectionChange,
|
|
||||||
enableRowSelection = false,
|
|
||||||
manualPagination = true,
|
|
||||||
rowCount,
|
|
||||||
}: DataTableProps<TData, TValue>) {
|
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
|
||||||
pageIndex,
|
|
||||||
pageSize,
|
|
||||||
});
|
|
||||||
|
|
||||||
const columnsWithSelection = useMemo<ColumnDef<TData, TValue>[]>(() => {
|
|
||||||
if (!enableRowSelection) return columns;
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "select",
|
|
||||||
header: ({ table }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={
|
|
||||||
table.getIsAllPageRowsSelected() ||
|
|
||||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
|
||||||
}
|
|
||||||
onCheckedChange={(value) =>
|
|
||||||
table.toggleAllPageRowsSelected(!!value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Checkbox
|
|
||||||
checked={row.getIsSelected()}
|
|
||||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
|
||||||
},
|
|
||||||
...columns,
|
|
||||||
];
|
|
||||||
}, [columns, enableRowSelection]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
pagination.pageIndex !== pageIndex ||
|
|
||||||
pagination.pageSize !== pageSize
|
|
||||||
) {
|
|
||||||
setPagination({ pageIndex, pageSize });
|
|
||||||
}
|
|
||||||
}, [pagination.pageIndex, pagination.pageSize, pageIndex, pageSize]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setRowSelection({});
|
|
||||||
}, [pageIndex]);
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data,
|
|
||||||
columns: columnsWithSelection,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getSortedRowModel: getSortedRowModel(),
|
|
||||||
manualPagination,
|
|
||||||
manualSorting: true,
|
|
||||||
pageCount,
|
|
||||||
rowCount,
|
|
||||||
state: {
|
|
||||||
pagination,
|
|
||||||
sorting,
|
|
||||||
rowSelection,
|
|
||||||
},
|
|
||||||
onPaginationChange: (updater: Updater<PaginationState>) => {
|
|
||||||
const newPagination =
|
|
||||||
typeof updater === "function" ? updater(pagination) : updater;
|
|
||||||
|
|
||||||
if (
|
|
||||||
pagination.pageIndex !== newPagination.pageIndex ||
|
|
||||||
pagination.pageSize !== newPagination.pageSize
|
|
||||||
) {
|
|
||||||
setPagination(newPagination);
|
|
||||||
onPaginationChangeAction(newPagination);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSortingChange: (updaterOrValue: Updater<SortingState>) => {
|
|
||||||
const newSorting =
|
|
||||||
typeof updaterOrValue === "function"
|
|
||||||
? updaterOrValue(sorting)
|
|
||||||
: updaterOrValue;
|
|
||||||
onSortingChangeAction(newSorting);
|
|
||||||
},
|
|
||||||
onRowSelectionChange: enableRowSelection
|
|
||||||
? (updater) => {
|
|
||||||
const newSelection =
|
|
||||||
typeof updater === "function" ? updater(rowSelection) : updater;
|
|
||||||
setRowSelection(newSelection);
|
|
||||||
|
|
||||||
if (onRowSelectionChange) {
|
|
||||||
const selectedRowIndices = Object.keys(newSelection).filter(
|
|
||||||
(key) => newSelection[key]
|
|
||||||
);
|
|
||||||
const selectedRows = selectedRowIndices
|
|
||||||
.map((index) => data[parseInt(index)])
|
|
||||||
.filter(Boolean);
|
|
||||||
onRowSelectionChange(selectedRows);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
enableRowSelection,
|
|
||||||
getRowId: (row, index) => index.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedRowCount = Object.keys(rowSelection).filter(
|
|
||||||
(key) => rowSelection[key]
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-md border-zinc-200 border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<TableRow key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<TableHead
|
|
||||||
key={header.id}
|
|
||||||
className="text-zinc-500 font-normal"
|
|
||||||
>
|
|
||||||
{flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{table.getRowModel().rows?.length ? (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell key={cell.id}>
|
|
||||||
{flexRender(
|
|
||||||
cell.column.columnDef.cell,
|
|
||||||
cell.getContext()
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={columnsWithSelection.length}
|
|
||||||
className="h-24 text-center"
|
|
||||||
>
|
|
||||||
Tidak ada data
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
{enableRowSelection ? (
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{selectedRowCount} dari {data.length} baris dipilih.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div></div>
|
|
||||||
)}
|
|
||||||
<Pagination
|
|
||||||
totalPages={pageCount}
|
|
||||||
currentPage={pageIndex + 1}
|
|
||||||
onPageChange={(page: number) => table.setPageIndex(page - 1)}
|
|
||||||
perPage={pageSize}
|
|
||||||
onPerPageChange={(perPage: number) => table.setPageSize(perPage)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
// app/(dashboard)/manajemen-peta/components/delete-mapset-dialog.tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ds/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/shared/components/ui/dialog";
|
|
||||||
|
|
||||||
interface DeleteDialogProps {
|
|
||||||
name: string;
|
|
||||||
isDeleting: boolean;
|
|
||||||
onDelete: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
open: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DeleteDialog = ({
|
|
||||||
name,
|
|
||||||
isDeleting,
|
|
||||||
onDelete,
|
|
||||||
onCancel,
|
|
||||||
open,
|
|
||||||
}: DeleteDialogProps) => {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onCancel()}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Hapus </DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Apakah Anda yakin ingin menghapus mapset "{name}"?
|
|
||||||
Tindakan ini tidak dapat dibatalkan.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter className="flex flex-row justify-end gap-2 sm:justify-end">
|
|
||||||
<Button variant="outline" onClick={onCancel} disabled={isDeleting}>
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={onDelete}
|
|
||||||
disabled={isDeleting}
|
|
||||||
>
|
|
||||||
{isDeleting ? "Menghapus..." : "Hapus"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { cn } from "@/shared/utils/utils";
|
|
||||||
import DOMPurify from "dompurify";
|
|
||||||
|
|
||||||
interface DetailItemProps {
|
|
||||||
label: string;
|
|
||||||
value: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
renderAsHtml?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DetailItem({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
className,
|
|
||||||
renderAsHtml = false,
|
|
||||||
}: DetailItemProps) {
|
|
||||||
const content =
|
|
||||||
renderAsHtml && typeof value === "string" ? (
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(value) }} />
|
|
||||||
) : (
|
|
||||||
value
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("px-4 py-2", className)}>
|
|
||||||
<div className="text-sm font-medium text-zinc-950">{label}</div>
|
|
||||||
<div className="text-sm text-zinc-800">{content}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { ReactNode } from "react";
|
|
||||||
|
|
||||||
interface EmptyStateProps {
|
|
||||||
icon?: ReactNode;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
action?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EmptyState({
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
action,
|
|
||||||
}: EmptyStateProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center p-8 text-center border rounded-lg bg-gray-50">
|
|
||||||
{icon}
|
|
||||||
<h3 className="mt-2 text-lg font-medium text-gray-900">{title}</h3>
|
|
||||||
{description && (
|
|
||||||
<p className="mt-1 text-sm text-gray-500">{description}</p>
|
|
||||||
)}
|
|
||||||
{action && <div className="mt-6">{action}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { cn } from "@/shared/utils/utils";
|
|
||||||
|
|
||||||
interface PageHeaderProps {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PageHeader({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
className,
|
|
||||||
}: PageHeaderProps) {
|
|
||||||
return (
|
|
||||||
<div className={cn("p-6 pb-2", className)}>
|
|
||||||
<div className="text-2xl font-bold">{title}</div>
|
|
||||||
{description && (
|
|
||||||
<div className="text-zinc-500 text-[16px]">{description}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import categoryApi from "@/shared/services/category";
|
|
||||||
import classificationApi from "@/shared/services/classification";
|
|
||||||
import mapProjectionSystemApi from "@/shared/services/map-projection-system";
|
|
||||||
import organizationApi from "@/shared/services/organization";
|
|
||||||
import mapSourceApi from "@/shared/services/map-source";
|
|
||||||
import { useAuthSession } from "@/shared/hooks/use-session";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prefetches admin reference data (options) once the user session is ready.
|
|
||||||
* This ensures select options are available before opening edit/add forms,
|
|
||||||
* so forms can immediately display selected values.
|
|
||||||
*/
|
|
||||||
export default function RefDataBootstrap() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const { isAuthenticated } = useAuthSession();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAuthenticated) return;
|
|
||||||
|
|
||||||
const staleTime = 5 * 60 * 1000; // 5 minutes
|
|
||||||
|
|
||||||
qc.prefetchQuery({
|
|
||||||
queryKey: ["projectionSystems"],
|
|
||||||
queryFn: () => mapProjectionSystemApi.getMapProjectionSystems(),
|
|
||||||
staleTime,
|
|
||||||
});
|
|
||||||
qc.prefetchQuery({
|
|
||||||
queryKey: ["categories"],
|
|
||||||
queryFn: () => categoryApi.getCategories(),
|
|
||||||
staleTime,
|
|
||||||
});
|
|
||||||
qc.prefetchQuery({
|
|
||||||
queryKey: ["classifications"],
|
|
||||||
queryFn: () => classificationApi.getClassifications(),
|
|
||||||
staleTime,
|
|
||||||
});
|
|
||||||
qc.prefetchQuery({
|
|
||||||
queryKey: ["organizations"],
|
|
||||||
queryFn: () => organizationApi.getOrganizations(),
|
|
||||||
staleTime,
|
|
||||||
});
|
|
||||||
qc.prefetchQuery({
|
|
||||||
queryKey: ["map-sources"],
|
|
||||||
queryFn: () => mapSourceApi.getMapSources(),
|
|
||||||
staleTime,
|
|
||||||
});
|
|
||||||
}, [qc, isAuthenticated]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { RefreshCwIcon } from "lucide-react";
|
|
||||||
import { Button } from "@/shared/components/ui/button";
|
|
||||||
import { DataTable } from "./data-table";
|
|
||||||
import { EmptyState } from "./empty-state";
|
|
||||||
import LoadingSpinner from "@/shared/components/loading-spinner";
|
|
||||||
import SearchAndActionBar from "./search-action-bar";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { ColumnDef, SortingState } from "@tanstack/react-table";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface FilterOption {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
group: string;
|
|
||||||
groupLabel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResourceTableProps<T> {
|
|
||||||
data: T[];
|
|
||||||
columns: ColumnDef<T, unknown>[];
|
|
||||||
total: number;
|
|
||||||
isLoading: boolean;
|
|
||||||
isError: boolean;
|
|
||||||
refetchAction: () => void;
|
|
||||||
searchValue: string;
|
|
||||||
onSearchChangeAction: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
||||||
sorting: SortingState;
|
|
||||||
onSortingChangeAction: (sorting: SortingState) => void;
|
|
||||||
pageIndex: number;
|
|
||||||
pageCount: number;
|
|
||||||
pageSize: number;
|
|
||||||
onPaginationChangeAction: (params: {
|
|
||||||
pageIndex: number;
|
|
||||||
pageSize: number;
|
|
||||||
}) => void;
|
|
||||||
emptyStateProps: {
|
|
||||||
title: string;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
description?: string;
|
|
||||||
};
|
|
||||||
actionBarProps: {
|
|
||||||
buttonLabel: string;
|
|
||||||
buttonLink: string;
|
|
||||||
bulkLabel?: string;
|
|
||||||
showBulkAction?: boolean;
|
|
||||||
onBulkAction?: (selectedRows: T[]) => void;
|
|
||||||
};
|
|
||||||
enableRowSelection?: boolean;
|
|
||||||
filterOptions?: FilterOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ResourceTable<T>({
|
|
||||||
data,
|
|
||||||
columns,
|
|
||||||
total,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
refetchAction,
|
|
||||||
searchValue,
|
|
||||||
onSearchChangeAction,
|
|
||||||
sorting,
|
|
||||||
onSortingChangeAction,
|
|
||||||
pageIndex,
|
|
||||||
pageCount,
|
|
||||||
pageSize,
|
|
||||||
onPaginationChangeAction,
|
|
||||||
emptyStateProps,
|
|
||||||
actionBarProps,
|
|
||||||
enableRowSelection,
|
|
||||||
filterOptions = [],
|
|
||||||
}: ResourceTableProps<T>) {
|
|
||||||
const [selectedRows, setSelectedRows] = useState<T[]>([]);
|
|
||||||
|
|
||||||
const defaultEmptyIcon = (
|
|
||||||
<div className="text-gray-400 mx-auto mb-4">
|
|
||||||
<Image
|
|
||||||
src="/empty-box.png"
|
|
||||||
alt="Data tidak ditemukan"
|
|
||||||
width={64}
|
|
||||||
height={64}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBulkAction = () => {
|
|
||||||
if (actionBarProps.onBulkAction) {
|
|
||||||
actionBarProps.onBulkAction(selectedRows);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRowSelectionChange = (rows: T[]) => {
|
|
||||||
setSelectedRows(rows);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<SearchAndActionBar
|
|
||||||
buttonLabel={actionBarProps.buttonLabel}
|
|
||||||
buttonLink={actionBarProps.buttonLink}
|
|
||||||
searchValue={searchValue}
|
|
||||||
onChange={onSearchChangeAction}
|
|
||||||
selectedCount={selectedRows.length}
|
|
||||||
onBulkAction={handleBulkAction}
|
|
||||||
bulkLabel={actionBarProps.bulkLabel}
|
|
||||||
showBulkAction={actionBarProps.showBulkAction}
|
|
||||||
filterOptions={filterOptions}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(() => {
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="mt-8 flex justify-center">
|
|
||||||
<LoadingSpinner />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (isError) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
icon={<RefreshCwIcon className="h-10 w-10 text-gray-400" />}
|
|
||||||
title="Gagal memuat data"
|
|
||||||
description="Terjadi kesalahan saat memuat data. Silakan coba lagi."
|
|
||||||
action={
|
|
||||||
<Button onClick={() => refetchAction()}>Coba Lagi</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (data.length === 0) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
icon={emptyStateProps.icon || defaultEmptyIcon}
|
|
||||||
title={emptyStateProps.title}
|
|
||||||
description={emptyStateProps.description || ""}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<DataTable<T, unknown>
|
|
||||||
data={data}
|
|
||||||
columns={columns}
|
|
||||||
pageCount={pageCount}
|
|
||||||
pageIndex={pageIndex}
|
|
||||||
pageSize={pageSize}
|
|
||||||
onPaginationChangeAction={onPaginationChangeAction}
|
|
||||||
onRowSelectionChange={handleRowSelectionChange}
|
|
||||||
manualPagination
|
|
||||||
rowCount={total}
|
|
||||||
sorting={sorting}
|
|
||||||
onSortingChangeAction={onSortingChangeAction}
|
|
||||||
enableRowSelection={enableRowSelection}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,226 +0,0 @@
|
||||||
import { Button } from "@/shared/components/ds/button";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { CirclePlusIcon, ListFilter, UnlinkIcon, X } from "lucide-react";
|
|
||||||
import { SearchInput } from "./search-input";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/components/ui/popover";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select";
|
|
||||||
import { useAuthSession } from "@/shared/hooks/use-session";
|
|
||||||
|
|
||||||
interface FilterOption {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
group: string;
|
|
||||||
groupLabel: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SearchAndActionBarProps {
|
|
||||||
searchValue: string;
|
|
||||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
||||||
buttonLabel: string;
|
|
||||||
buttonLink: string;
|
|
||||||
placeholder?: string;
|
|
||||||
selectedCount?: number;
|
|
||||||
onBulkAction?: () => void;
|
|
||||||
bulkLabel?: string;
|
|
||||||
showBulkAction?: boolean;
|
|
||||||
filterOptions?: FilterOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const SearchAndActionBar = ({
|
|
||||||
searchValue,
|
|
||||||
onChange,
|
|
||||||
buttonLabel,
|
|
||||||
buttonLink,
|
|
||||||
placeholder = "Masukkan kata kunci",
|
|
||||||
selectedCount = 0,
|
|
||||||
onBulkAction,
|
|
||||||
bulkLabel = "Nonaktifkan",
|
|
||||||
showBulkAction = false,
|
|
||||||
filterOptions = [],
|
|
||||||
}: SearchAndActionBarProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
|
||||||
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
|
|
||||||
const { session } = useAuthSession();
|
|
||||||
const userRole = session?.user?.role;
|
|
||||||
|
|
||||||
// Initialize filters from URL params
|
|
||||||
useEffect(() => {
|
|
||||||
const filterParam = searchParams.get("filter");
|
|
||||||
if (filterParam) {
|
|
||||||
try {
|
|
||||||
const filters = JSON.parse(filterParam);
|
|
||||||
// Convert nested array format to flat array for internal state
|
|
||||||
const flatFilters = Array.isArray(filters) ? filters.flatMap((f) => (Array.isArray(f) ? f : [f])) : [];
|
|
||||||
setSelectedFilters(flatFilters);
|
|
||||||
} catch {
|
|
||||||
setSelectedFilters([]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
const handleFilterChange = (option: FilterOption) => {
|
|
||||||
const filterValue = `${option.group}=${option.value}`;
|
|
||||||
// If the filter is already selected, remove it
|
|
||||||
if (selectedFilters.includes(filterValue)) {
|
|
||||||
const newFilters = selectedFilters.filter((f) => f !== filterValue);
|
|
||||||
setSelectedFilters(newFilters);
|
|
||||||
updateUrlFilters(newFilters);
|
|
||||||
} else {
|
|
||||||
// If the filter is not selected, add it
|
|
||||||
const newFilters = [...selectedFilters, filterValue];
|
|
||||||
setSelectedFilters(newFilters);
|
|
||||||
updateUrlFilters(newFilters);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeFilter = (value: string) => {
|
|
||||||
const newFilters = selectedFilters.filter((f) => f !== value);
|
|
||||||
setSelectedFilters(newFilters);
|
|
||||||
updateUrlFilters(newFilters);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateUrlFilters = (filters: string[]) => {
|
|
||||||
const newParams = new URLSearchParams(searchParams.toString());
|
|
||||||
|
|
||||||
if (filters.length > 0) {
|
|
||||||
// Group filters by their group property
|
|
||||||
const groupedFilters = filters.reduce((acc, filter) => {
|
|
||||||
const [group] = filter.split("=");
|
|
||||||
if (!acc[group]) {
|
|
||||||
acc[group] = [];
|
|
||||||
}
|
|
||||||
acc[group].push(filter);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, string[]>);
|
|
||||||
|
|
||||||
// Convert grouped filters to array format
|
|
||||||
const filterString = JSON.stringify(Object.values(groupedFilters));
|
|
||||||
newParams.set("filter", filterString);
|
|
||||||
} else {
|
|
||||||
newParams.delete("filter");
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push(`?${newParams.toString()}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Group filter options by their group property
|
|
||||||
const groupedOptions = filterOptions.reduce((acc, option) => {
|
|
||||||
const group = option.group;
|
|
||||||
if (!acc[group]) {
|
|
||||||
acc[group] = {
|
|
||||||
label: option.groupLabel,
|
|
||||||
options: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
acc[group].options.push(option);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, { label: string; options: FilterOption[] }>);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<SearchInput placeholder={placeholder} value={searchValue} onChange={onChange} className="w-full max-w-sm" />
|
|
||||||
{filterOptions.length > 0 && (
|
|
||||||
<Popover open={isFilterOpen} onOpenChange={setIsFilterOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button className="bg-white text-zinc-950 text-sm gap-3 border border-zinc-200 px-4 py-2">
|
|
||||||
<ListFilter className="w-4 h-4" />
|
|
||||||
Filter
|
|
||||||
{filterOptions.length > 0 && selectedFilters.length > 0 && (
|
|
||||||
<Badge className="ml-2">
|
|
||||||
{
|
|
||||||
selectedFilters.filter((filter) => {
|
|
||||||
const [key, value] = filter.split("=");
|
|
||||||
return filterOptions.some((opt) => opt.group === key && opt.value === value);
|
|
||||||
}).length
|
|
||||||
}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-80">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h4 className="font-medium">Filter</h4>
|
|
||||||
|
|
||||||
<div className="flex flex-col space-y-2 max-h-[300px] overflow-y-auto">
|
|
||||||
{Object.entries(groupedOptions).map(([group, { label, options }]) => (
|
|
||||||
<div key={group} className="space-y-2">
|
|
||||||
<h5 className="text-sm font-medium text-muted-foreground">{label}</h5>
|
|
||||||
<Select
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const option = options.find((opt) => opt.value === value);
|
|
||||||
if (option) handleFilterChange(option);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue placeholder={`Select ${label}`} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{options.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{showBulkAction && (
|
|
||||||
<Button disabled={selectedCount === 0} onClick={onBulkAction} className="bg-white text-zinc-950 border border-zinc-200 border-dashed rounded-lg">
|
|
||||||
<UnlinkIcon className="h-4 w-4 mr-2 text-zinc-950" />
|
|
||||||
<span className="text-zinc-950">{bulkLabel}</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{userRole.name !== "data_viewer" && (
|
|
||||||
<Link href={buttonLink}>
|
|
||||||
<Button size="sm">
|
|
||||||
<CirclePlusIcon className="h-4 w-4 mr-2" />
|
|
||||||
{buttonLabel}
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active Filters */}
|
|
||||||
{selectedFilters.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{selectedFilters
|
|
||||||
.filter((filter) => {
|
|
||||||
const [key, value] = filter.split("=");
|
|
||||||
return filterOptions.some((opt) => opt.group === key && opt.value === value);
|
|
||||||
})
|
|
||||||
.map((filter) => {
|
|
||||||
const [key, value] = filter.split("=");
|
|
||||||
const option = filterOptions.find((opt) => opt.group === key && opt.value === value);
|
|
||||||
return (
|
|
||||||
<Badge key={filter} className="flex items-center gap-1 px-3 py-1">
|
|
||||||
{option?.label || filter}
|
|
||||||
<button onClick={() => removeFilter(filter)} className="ml-1 rounded-full outline-none focus:ring-2 focus:ring-ring">
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SearchAndActionBar;
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
// components/ui/search-input.tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Search } from "lucide-react";
|
|
||||||
import { cn } from "@/shared/utils/utils";
|
|
||||||
import { Input, InputProps } from "@/shared/components/ds/input";
|
|
||||||
|
|
||||||
export function SearchInput({ className, ...props }: InputProps) {
|
|
||||||
return (
|
|
||||||
<div className={cn("relative", className)}>
|
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
|
|
||||||
<Input type="search" className="pl-9" {...props} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,253 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useCallback } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import {
|
|
||||||
Users,
|
|
||||||
LogOut,
|
|
||||||
ChevronsRight,
|
|
||||||
ChevronsLeft,
|
|
||||||
FileText,
|
|
||||||
UserCog,
|
|
||||||
Key,
|
|
||||||
ChartBarIncreasing,
|
|
||||||
BookOpen,
|
|
||||||
FolderUp,
|
|
||||||
WashingMachine,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@/shared/components/ui/button";
|
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area";
|
|
||||||
import { cn } from "@/shared/utils/utils";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { useAuthSession } from "@/shared/hooks/use-session";
|
|
||||||
import { getRoleLabelById, hasPermission } from "@/shared/config/role";
|
|
||||||
import { handleLogout } from "@/shared/hooks/use-auth-api";
|
|
||||||
import { appConfig } from "@/shared/config/app-config";
|
|
||||||
|
|
||||||
interface MenuItem {
|
|
||||||
name: string;
|
|
||||||
href: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
module: string; // default ke 'read' jika tidak diisi
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
|
||||||
{
|
|
||||||
name: "Manajemen Peta",
|
|
||||||
href: "/admin/mapset",
|
|
||||||
module: "mapset",
|
|
||||||
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",
|
|
||||||
href: "/admin/user",
|
|
||||||
icon: <Users className="h-5 w-5" />,
|
|
||||||
module: "user",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Manajemen Konten",
|
|
||||||
href: "/admin/news",
|
|
||||||
icon: <FileText className="h-5 w-5" />,
|
|
||||||
module: "news",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const settingsItems: MenuItem[] = [
|
|
||||||
{
|
|
||||||
name: "Perangkat Daerah",
|
|
||||||
href: "/admin/organization",
|
|
||||||
icon: <UserCog className="h-5 w-5" />,
|
|
||||||
module: "organization",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Kategori",
|
|
||||||
href: "/admin/category",
|
|
||||||
icon: <ChartBarIncreasing className="h-5 w-5" />,
|
|
||||||
module: "category",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Mapserver & Metadata",
|
|
||||||
href: "/admin/map-source",
|
|
||||||
icon: <WashingMachine className="h-5 w-5" />,
|
|
||||||
module: "map-source",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Kredensial",
|
|
||||||
href: "/admin/credential",
|
|
||||||
icon: <Key className="h-5 w-5" />,
|
|
||||||
module: "credential",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const Sidebar = () => {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
|
||||||
const { session } = useAuthSession();
|
|
||||||
const userRole = session?.user?.role;
|
|
||||||
|
|
||||||
const isActive = useCallback(
|
|
||||||
(href: string) => {
|
|
||||||
if (pathname === href) return true;
|
|
||||||
if (href !== "/" && pathname.startsWith(href)) {
|
|
||||||
const nextChar = pathname.charAt(href.length);
|
|
||||||
return nextChar === "" || nextChar === "/";
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
[pathname]
|
|
||||||
);
|
|
||||||
const filteredMenuItems = menuItems.filter(
|
|
||||||
(item) => userRole && hasPermission(userRole, item.module, "read")
|
|
||||||
);
|
|
||||||
|
|
||||||
const filteredSettingsItems = settingsItems.filter(
|
|
||||||
(item) => userRole && hasPermission(userRole, item.module, "read")
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative flex flex-col h-full bg-zinc-50 border text-zinc-700 border-zinc-200 transition-all rounded-lg duration-300",
|
|
||||||
collapsed ? "w-16" : "p-2 w-64"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`flex items-center ${
|
|
||||||
collapsed ? "justify-center" : "justify-between"
|
|
||||||
} h-16 p-2 space-x-2`}
|
|
||||||
>
|
|
||||||
{!collapsed && (
|
|
||||||
<div>
|
|
||||||
<Image
|
|
||||||
src={appConfig.logo}
|
|
||||||
alt="admin-logo"
|
|
||||||
width={140}
|
|
||||||
height={120}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
{collapsed ? (
|
|
||||||
<ChevronsRight className="h-5 w-5" strokeWidth="1" />
|
|
||||||
) : (
|
|
||||||
<ChevronsLeft className="h-5 w-5" strokeWidth="1" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
|
||||||
<div className="px-2 py-2">
|
|
||||||
{/* Features Section */}
|
|
||||||
{filteredMenuItems.length > 0 && !collapsed && (
|
|
||||||
<p className="text-xs text-zinc-700 font-medium px-2 mb-2">
|
|
||||||
Features
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{filteredMenuItems.map((item) => (
|
|
||||||
<li key={item.name}>
|
|
||||||
<Link href={item.href} passHref>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center px-3 py-2 rounded-lg transition-colors",
|
|
||||||
isActive(item.href)
|
|
||||||
? "bg-blue-50 text-blue-600"
|
|
||||||
: "text-zinc-700 hover:bg-zinc-100"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"flex-shrink-0 text-gray-500",
|
|
||||||
isActive(item.href) && "text-blue-600"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
</span>
|
|
||||||
{!collapsed && (
|
|
||||||
<span className="ml-3 text-sm">{item.name}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{filteredSettingsItems.length > 0 && !collapsed && (
|
|
||||||
<p className="text-xs text-zinc-700 font-medium px-2 mt-6 mb-2">
|
|
||||||
Settings
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{filteredSettingsItems.map((item) => (
|
|
||||||
<li key={item.name}>
|
|
||||||
<Link href={item.href} passHref>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center px-3 py-2 rounded-lg transition-colors",
|
|
||||||
isActive(item.href)
|
|
||||||
? "bg-blue-50 text-blue-600"
|
|
||||||
: "text-zinc-700 hover:bg-zinc-100"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"flex-shrink-0 text-gray-500",
|
|
||||||
isActive(item.href) && "text-blue-600"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
</span>
|
|
||||||
{!collapsed && (
|
|
||||||
<span className="ml-3 text-sm">{item.name}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<div className="p-2 border-t border-zinc-200">
|
|
||||||
<div className="flex items-center px-2 py-3">
|
|
||||||
<div className="h-8 w-8 rounded-full bg-gray-300 flex items-center justify-center text-zinc-700">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{session?.user?.name?.[0] ?? "U"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{!collapsed && (
|
|
||||||
<div className="ml-3">
|
|
||||||
<p className="text-sm font-medium">{session?.user?.name}</p>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{getRoleLabelById(userRole?.name ?? "")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
"w-full flex items-center justify-start text-zinc-700 hover:bg-gray-200 mt-1",
|
|
||||||
collapsed ? "justify-center px-2" : "justify-start"
|
|
||||||
)}
|
|
||||||
onClick={() => handleLogout()}
|
|
||||||
>
|
|
||||||
<LogOut className="h-5 w-5" />
|
|
||||||
{!collapsed && <span className="ml-3 text-sm">Logout</span>}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Sidebar;
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
|
|
||||||
export function useTabState() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const currentTab = searchParams.get("tab") || "all";
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentTab,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { SortingState } from "@tanstack/react-table";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
interface TableStateOptions<T> {
|
|
||||||
defaultLimit?: number;
|
|
||||||
defaultSort?: { id: string; desc: boolean };
|
|
||||||
resourceName: string;
|
|
||||||
fetchAction: (params: QueryParams) => Promise<{
|
|
||||||
items: T[];
|
|
||||||
total: number;
|
|
||||||
has_more: boolean;
|
|
||||||
}>;
|
|
||||||
staleTime?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QueryParams {
|
|
||||||
limit: number;
|
|
||||||
offset: number;
|
|
||||||
search?: string;
|
|
||||||
filter?: string[];
|
|
||||||
sort: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTableState<T>({
|
|
||||||
defaultLimit = 10,
|
|
||||||
defaultSort = { id: "name", desc: false },
|
|
||||||
resourceName,
|
|
||||||
fetchAction,
|
|
||||||
staleTime = 30000,
|
|
||||||
}: TableStateOptions<T>) {
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const [searchValue, setSearchValue] = useState("");
|
|
||||||
const [debouncedSearchValue, setDebouncedSearchValue] = useState("");
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([defaultSort]);
|
|
||||||
|
|
||||||
const { limit, offset, search, sortBy, sortOrder, filterParams } = useMemo(
|
|
||||||
() => ({
|
|
||||||
limit: Number(searchParams.get("limit") || defaultLimit),
|
|
||||||
offset: Number(searchParams.get("offset") || 0),
|
|
||||||
search: searchParams.get("search") || "",
|
|
||||||
sortBy: searchParams.get("sortBy") || defaultSort.id,
|
|
||||||
sortOrder:
|
|
||||||
searchParams.get("sortOrder") || (defaultSort.desc ? "desc" : "asc"),
|
|
||||||
filterParams: searchParams.getAll("filter").filter(Boolean),
|
|
||||||
}),
|
|
||||||
[searchParams, defaultLimit, defaultSort.id, defaultSort.desc]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSearchValue(search);
|
|
||||||
}, [search]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = setTimeout(() => {
|
|
||||||
setDebouncedSearchValue(searchValue);
|
|
||||||
}, 500);
|
|
||||||
return () => clearTimeout(handler);
|
|
||||||
}, [searchValue]);
|
|
||||||
|
|
||||||
const queryParams = useMemo<QueryParams>(
|
|
||||||
() => ({
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
search: debouncedSearchValue || undefined,
|
|
||||||
filter: filterParams.length > 0 ? filterParams : undefined,
|
|
||||||
sort: `${sortBy}:${sortOrder}`,
|
|
||||||
}),
|
|
||||||
[limit, offset, debouncedSearchValue, filterParams, sortBy, sortOrder]
|
|
||||||
);
|
|
||||||
|
|
||||||
const queryKey = [
|
|
||||||
resourceName,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
debouncedSearchValue,
|
|
||||||
filterParams.join("|"),
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
];
|
|
||||||
|
|
||||||
const {
|
|
||||||
data = { items: [], total: 0, has_more: false },
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
refetch,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey,
|
|
||||||
queryFn: () => fetchAction(queryParams),
|
|
||||||
staleTime,
|
|
||||||
retry: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data.has_more) {
|
|
||||||
const nextOffset = offset + limit;
|
|
||||||
queryClient.prefetchQuery({
|
|
||||||
queryKey: [
|
|
||||||
resourceName,
|
|
||||||
limit,
|
|
||||||
nextOffset,
|
|
||||||
debouncedSearchValue,
|
|
||||||
filterParams.join("|"),
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
],
|
|
||||||
queryFn: () =>
|
|
||||||
fetchAction({
|
|
||||||
...queryParams,
|
|
||||||
offset: nextOffset,
|
|
||||||
}),
|
|
||||||
staleTime,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
data.has_more,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
debouncedSearchValue,
|
|
||||||
filterParams,
|
|
||||||
queryClient,
|
|
||||||
queryParams,
|
|
||||||
resourceName,
|
|
||||||
sortBy,
|
|
||||||
sortOrder,
|
|
||||||
fetchAction,
|
|
||||||
staleTime,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const updateSearchParams = useCallback(
|
|
||||||
(params: Record<string, string>) => {
|
|
||||||
const newParams = new URLSearchParams(searchParams.toString());
|
|
||||||
|
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
|
||||||
if (value) {
|
|
||||||
newParams.set(key, value);
|
|
||||||
} else {
|
|
||||||
newParams.delete(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.push(`?${newParams.toString()}`);
|
|
||||||
},
|
|
||||||
[router, searchParams]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateSortingParams = useCallback(
|
|
||||||
(sorting: SortingState) => {
|
|
||||||
const newParams = new URLSearchParams(searchParams.toString());
|
|
||||||
|
|
||||||
if (sorting.length === 0) {
|
|
||||||
newParams.delete("sortBy");
|
|
||||||
newParams.delete("sortOrder");
|
|
||||||
} else {
|
|
||||||
newParams.set("sortBy", sorting[0].id);
|
|
||||||
newParams.set("sortOrder", sorting[0].desc ? "desc" : "asc");
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push(`?${newParams.toString()}`);
|
|
||||||
},
|
|
||||||
[router, searchParams]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSearchInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setSearchValue(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePaginationChange = useCallback(
|
|
||||||
({ pageIndex, pageSize }: { pageIndex: number; pageSize: number }) => {
|
|
||||||
const newOffset = pageIndex * pageSize;
|
|
||||||
const params = {
|
|
||||||
offset: pageSize !== limit ? "0" : newOffset.toString(),
|
|
||||||
limit: pageSize.toString(),
|
|
||||||
};
|
|
||||||
updateSearchParams(params);
|
|
||||||
},
|
|
||||||
[updateSearchParams, limit]
|
|
||||||
);
|
|
||||||
|
|
||||||
const pageIndex = Math.floor(offset / limit);
|
|
||||||
const pageCount = Math.ceil(data.total / limit);
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: data.items,
|
|
||||||
total: data.total,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
refetch,
|
|
||||||
searchValue,
|
|
||||||
sorting,
|
|
||||||
handleSearchInputChange,
|
|
||||||
handlePaginationChange,
|
|
||||||
updateSortingParams,
|
|
||||||
pageIndex,
|
|
||||||
pageCount,
|
|
||||||
limit,
|
|
||||||
setSorting,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,224 +0,0 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
|
||||||
import { Badge } from "@/shared/components/ds/badge";
|
|
||||||
import { Button } from "@/shared/components/ui/button";
|
|
||||||
import {
|
|
||||||
MoreHorizontal,
|
|
||||||
ChevronsUpDown,
|
|
||||||
ChevronUp,
|
|
||||||
ChevronDown,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/shared/components/ui/dropdown-menu";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { Category } from "@/shared/types/category";
|
|
||||||
import { getFileThumbnailUrl } from "@/shared/utils/file";
|
|
||||||
import categoryApi from "@/shared/services/category";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { hasPermission } from "@/shared/config/role";
|
|
||||||
import { useAuthSession } from "@/shared/hooks/use-session";
|
|
||||||
import { DeleteDialog } from "../../_components/delete-dialog";
|
|
||||||
|
|
||||||
interface ColumnConfig {
|
|
||||||
id: string;
|
|
||||||
header: string;
|
|
||||||
accessor?: keyof Category;
|
|
||||||
accessorFn?: (row: Category) => any;
|
|
||||||
sortable?: boolean;
|
|
||||||
cell?: (value: any) => React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLUMN_CONFIGS: ColumnConfig[] = [
|
|
||||||
{
|
|
||||||
id: "name",
|
|
||||||
header: "Nama Kategori",
|
|
||||||
accessor: "name",
|
|
||||||
sortable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "description",
|
|
||||||
header: "Deskripsi",
|
|
||||||
accessor: "description",
|
|
||||||
sortable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "thumbnail",
|
|
||||||
header: "Thumbnail",
|
|
||||||
accessor: "thumbnail",
|
|
||||||
sortable: false,
|
|
||||||
cell: (value) =>
|
|
||||||
value ? (
|
|
||||||
<div className="relative h-10 w-10">
|
|
||||||
<Image
|
|
||||||
src={getFileThumbnailUrl(value)}
|
|
||||||
alt="Thumbnail"
|
|
||||||
fill
|
|
||||||
className="object-cover rounded-md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "is_active",
|
|
||||||
header: "Status",
|
|
||||||
accessor: "is_active",
|
|
||||||
sortable: true,
|
|
||||||
cell: (value) => (
|
|
||||||
<Badge variant={value ? "success" : "secondary"}>
|
|
||||||
{value ? "Aktif" : "Tidak Aktif"}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const useCategoryColumns = (): ColumnDef<Category>[] => {
|
|
||||||
const router = useRouter();
|
|
||||||
const [categoryToDelete, setCategoryToDelete] = useState<Category | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { session } = useAuthSession();
|
|
||||||
|
|
||||||
const userRole = session?.user?.role;
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: async (id: string) => {
|
|
||||||
return await categoryApi.deleteCategory(id);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Berhasil menghapus kategori");
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
|
||||||
setCategoryToDelete(null);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error("Gagal menghapus kategori");
|
|
||||||
console.error("Error deleting category:", error);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderSortableHeader = (column: any, label: string) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
className="p-0 hover:bg-transparent"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
{column.getIsSorted() === "asc" ? (
|
|
||||||
<ChevronUp className="ml-2 h-4 w-4" />
|
|
||||||
) : column.getIsSorted() === "desc" ? (
|
|
||||||
<ChevronDown className="ml-2 h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
const baseColumns = COLUMN_CONFIGS.map((config) => {
|
|
||||||
const column: ColumnDef<Category> = {
|
|
||||||
id: config.id,
|
|
||||||
header: ({ column }) =>
|
|
||||||
config.sortable
|
|
||||||
? renderSortableHeader(column, config.header)
|
|
||||||
: config.header,
|
|
||||||
...(config.accessor && { accessorKey: config.accessor }),
|
|
||||||
...(config.accessorFn && { accessorFn: config.accessorFn }),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const value = row.getValue(config.id);
|
|
||||||
return config.cell ? (
|
|
||||||
config.cell(value)
|
|
||||||
) : (
|
|
||||||
<div>{value as React.ReactNode}</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
enableSorting: config.sortable !== false,
|
|
||||||
enableHiding: config.id !== "select" && config.id !== "actions",
|
|
||||||
};
|
|
||||||
|
|
||||||
return column;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
userRole &&
|
|
||||||
(hasPermission(userRole, "category", "read") ||
|
|
||||||
hasPermission(userRole, "category", "update") ||
|
|
||||||
hasPermission(userRole, "category", "delete"))
|
|
||||||
) {
|
|
||||||
baseColumns.push({
|
|
||||||
id: "actions",
|
|
||||||
enableHiding: false,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const category = row.original;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
|
||||||
<span className="sr-only">Open menu</span>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuLabel>Aksi</DropdownMenuLabel>
|
|
||||||
{hasPermission(userRole, "category", "read") && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
router.push(`/admin/category/detail/${category.id}`)
|
|
||||||
}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
Lihat Detail
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{hasPermission(userRole, "category", "update") && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
router.push(`/admin/category/edit/${category.id}`)
|
|
||||||
}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
Edit Kategori
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{hasPermission(userRole, "category", "delete") && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setCategoryToDelete(category)}
|
|
||||||
className="flex items-center gap-2 text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
Hapus Kategori
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
{categoryToDelete?.id === category.id && (
|
|
||||||
<DeleteDialog
|
|
||||||
name={categoryToDelete.name}
|
|
||||||
isDeleting={deleteMutation.isPending}
|
|
||||||
onDelete={() => deleteMutation.mutate(categoryToDelete.id)}
|
|
||||||
onCancel={() => setCategoryToDelete(null)}
|
|
||||||
open={true}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseColumns;
|
|
||||||
};
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
"use client";
|
|
||||||
import categoryApi from "@/shared/services/category";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import Image from "next/image";
|
|
||||||
import DetailItem from "../../_components/detail-item";
|
|
||||||
import { getFileThumbnailUrl } from "@/shared/utils/file";
|
|
||||||
|
|
||||||
export default function CategoryDetail({ id }: { id: string }) {
|
|
||||||
const { data: category } = useQuery({
|
|
||||||
queryKey: ["category", id],
|
|
||||||
queryFn: () => categoryApi.getCategoryById(id),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mx-6 p-2 border rounded-[6px] border-zinc-200">
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<span className="text-lg font-semibold text-zinc-950">
|
|
||||||
Informasi Kategori
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DetailItem label="Nama" value={category?.name} />
|
|
||||||
<DetailItem label="Deskripsi" value={category?.description} />
|
|
||||||
<DetailItem
|
|
||||||
label="Status"
|
|
||||||
value={category?.is_active ? "Aktif" : "Tidak Aktif"}
|
|
||||||
/>
|
|
||||||
<DetailItem
|
|
||||||
label="Thumbnail"
|
|
||||||
value={
|
|
||||||
category?.thumbnail ? (
|
|
||||||
<Image
|
|
||||||
src={getFileThumbnailUrl(category.thumbnail)}
|
|
||||||
alt="Thumbnail Kategori"
|
|
||||||
className="w-32 h-32 object-cover rounded"
|
|
||||||
width={128}
|
|
||||||
height={128}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
"Tidak ada thumbnail"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/shared/components/ui/form";
|
|
||||||
import { Input } from "@/shared/components/ui/input";
|
|
||||||
import { Switch } from "@/shared/components/ui/switch";
|
|
||||||
import { ImageUpload } from "@/shared/components/image-upload";
|
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
|
|
||||||
import { z } from "zod";
|
|
||||||
import { categorySchema } from "@/shared/schemas/category";
|
|
||||||
import { Textarea } from "@/shared/components/ui/textarea";
|
|
||||||
|
|
||||||
type CategoryFormValues = z.infer<typeof categorySchema>;
|
|
||||||
|
|
||||||
interface CategoryFormProps {
|
|
||||||
defaultValues?: Partial<CategoryFormValues>;
|
|
||||||
onSubmitAction: (data: CategoryFormValues) => void;
|
|
||||||
isSubmitting?: boolean;
|
|
||||||
onCancelAction?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CategoryForm({
|
|
||||||
defaultValues,
|
|
||||||
onSubmitAction,
|
|
||||||
isSubmitting,
|
|
||||||
onCancelAction,
|
|
||||||
}: CategoryFormProps) {
|
|
||||||
const form = useForm<CategoryFormValues>({
|
|
||||||
resolver: zodResolver(categorySchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: defaultValues?.name || "",
|
|
||||||
description: defaultValues?.description || "",
|
|
||||||
thumbnail: defaultValues?.thumbnail || "",
|
|
||||||
is_active: defaultValues?.is_active ?? true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmitAction)} className="space-y-6 ">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Nama Kategori</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Masukkan nama kategori" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="description"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Deskripsi</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Masukkan deskripsi kategori"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="thumbnail"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Thumbnail</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<ImageUpload
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
onRemove={() => field.onChange("")}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="is_active"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel className="text-base">Status</FormLabel>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Aktifkan atau nonaktifkan kategori
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-4">
|
|
||||||
{onCancelAction && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCancelAction}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? "Menyimpan..." : "Simpan"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import categoryApi from "@/shared/services/category";
|
|
||||||
import { Category } from "@/shared/types/category";
|
|
||||||
import { CategoryFormValues } from "@/shared/schemas/category";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { queryClient } from "@/shared/utils/query-client";
|
|
||||||
import { getChangedFields } from "@/shared/utils/form";
|
|
||||||
|
|
||||||
export function useCategoryForm(defaultValues?: Partial<Category>) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const isEdit = !!defaultValues?.id;
|
|
||||||
|
|
||||||
const handleSubmitCategory = async (data: CategoryFormValues) => {
|
|
||||||
try {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
if (isEdit) {
|
|
||||||
const changedFields = getChangedFields(defaultValues || {}, data);
|
|
||||||
|
|
||||||
if (Object.keys(changedFields).length === 0) {
|
|
||||||
toast.info("Tidak ada perubahan untuk disimpan");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await categoryApi.updateCategory(defaultValues.id!, changedFields);
|
|
||||||
toast.success("Kategori berhasil diperbarui");
|
|
||||||
} else {
|
|
||||||
await categoryApi.createCategory(data);
|
|
||||||
toast.success("Kategori berhasil ditambahkan");
|
|
||||||
}
|
|
||||||
router.push("/admin/category");
|
|
||||||
router.refresh();
|
|
||||||
queryClient.invalidateQueries();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
isEdit ? "Gagal memperbarui kategori" : "Gagal menambahkan kategori"
|
|
||||||
);
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
router.back();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
isLoading: false,
|
|
||||||
handleSubmitCategory,
|
|
||||||
resetForm,
|
|
||||||
isSubmitting,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { useCategoryForm } from "../_hooks/use-category-form";
|
|
||||||
import { CategoryForm } from "../_components/form";
|
|
||||||
|
|
||||||
export default function AddCategoryPageClient() {
|
|
||||||
const { isLoading, handleSubmitCategory, resetForm, isSubmitting } =
|
|
||||||
useCategoryForm();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="flex flex-col items-center space-y-2">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
|
||||||
<p className="text-sm text-gray-500">Memuat data...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
<CategoryForm
|
|
||||||
onSubmitAction={handleSubmitCategory}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
onCancelAction={resetForm}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { Suspense } from "react";
|
|
||||||
import AddCategoryPageClient from "./page.client";
|
|
||||||
import PageHeader from "../../_components/page-header";
|
|
||||||
|
|
||||||
export default function AddCategoryPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader title="Tambah Kategori" className="bg-zinc-50" />
|
|
||||||
<div className="px-6">
|
|
||||||
<Suspense fallback={<div>Memuat form...</div>}>
|
|
||||||
<AddCategoryPageClient />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import CategoryDetail from "../../_components/detail";
|
|
||||||
import PageHeader from "../../../_components/page-header";
|
|
||||||
|
|
||||||
export default function CategoryDetailPage() {
|
|
||||||
const params = useParams();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader title="Detail Kategori" className="bg-zinc-50" />
|
|
||||||
<CategoryDetail id={params.id?.toString() ?? ""} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import categoryApi from "@/shared/services/category";
|
|
||||||
import { CategoryForm } from "../../_components/form";
|
|
||||||
import { useCategoryForm } from "../../_hooks/use-category-form";
|
|
||||||
import { CategoryFormValues } from "@/shared/schemas/category";
|
|
||||||
|
|
||||||
export default function CategoryEditPageClient() {
|
|
||||||
const params = useParams();
|
|
||||||
const id = params.id as string;
|
|
||||||
|
|
||||||
const { data: category, isLoading } = useQuery({
|
|
||||||
queryKey: ["category", id],
|
|
||||||
queryFn: () => categoryApi.getCategoryById(id),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { handleSubmitCategory, resetForm, isSubmitting } =
|
|
||||||
useCategoryForm(category);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-[50vh] items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
<CategoryForm
|
|
||||||
defaultValues={category as Partial<CategoryFormValues>}
|
|
||||||
onSubmitAction={handleSubmitCategory}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
onCancelAction={resetForm}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { Suspense } from "react";
|
|
||||||
import CategoryEditPageClient from "./page.client";
|
|
||||||
import PageHeader from "../../../_components/page-header";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "Edit Kategori",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CategoryEditPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader title="Ubah Kategori" className="bg-zinc-50" />
|
|
||||||
<div className="px-6">
|
|
||||||
<Suspense fallback={<div>Memuat form...</div>}>
|
|
||||||
<CategoryEditPageClient />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Category } from "@/shared/types/category";
|
|
||||||
import { useCategoryColumns } from "./_components/column";
|
|
||||||
import categoryApi from "@/shared/services/category";
|
|
||||||
import { useTableState } from "../_hooks/use-table-state";
|
|
||||||
import { ResourceTable } from "../_components/resource-table";
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
|
||||||
|
|
||||||
export default function CategoryPageClient() {
|
|
||||||
const columns = useCategoryColumns();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: categories,
|
|
||||||
total,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
refetch,
|
|
||||||
searchValue,
|
|
||||||
sorting,
|
|
||||||
handleSearchInputChange,
|
|
||||||
handlePaginationChange,
|
|
||||||
updateSortingParams,
|
|
||||||
pageIndex,
|
|
||||||
pageCount,
|
|
||||||
limit,
|
|
||||||
setSorting,
|
|
||||||
} = useTableState<Category>({
|
|
||||||
resourceName: "categories",
|
|
||||||
fetchAction: categoryApi.getCategories,
|
|
||||||
defaultLimit: 10,
|
|
||||||
defaultSort: { id: "name", desc: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResourceTable
|
|
||||||
data={categories}
|
|
||||||
columns={columns as ColumnDef<unknown, unknown>[]}
|
|
||||||
total={total}
|
|
||||||
isLoading={isLoading}
|
|
||||||
isError={isError}
|
|
||||||
searchValue={searchValue}
|
|
||||||
onSearchChangeAction={handleSearchInputChange}
|
|
||||||
sorting={sorting}
|
|
||||||
onSortingChangeAction={(newSorting) => {
|
|
||||||
setSorting(newSorting);
|
|
||||||
updateSortingParams(newSorting);
|
|
||||||
}}
|
|
||||||
pageIndex={pageIndex}
|
|
||||||
pageCount={pageCount}
|
|
||||||
pageSize={limit}
|
|
||||||
onPaginationChangeAction={handlePaginationChange}
|
|
||||||
emptyStateProps={{
|
|
||||||
title: "Kategori tidak ditemukan",
|
|
||||||
}}
|
|
||||||
actionBarProps={{
|
|
||||||
buttonLabel: "Tambah Kategori",
|
|
||||||
buttonLink: "/admin/category/add",
|
|
||||||
}}
|
|
||||||
refetchAction={refetch}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { Suspense } from "react";
|
|
||||||
import CategoryPageClient from "./page.client";
|
|
||||||
import PageHeader from "../_components/page-header";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "Kategori",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CategoryPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader title="Kategori" />
|
|
||||||
<div className="px-6">
|
|
||||||
<Suspense>
|
|
||||||
<Suspense fallback={<div>Memuat Data...</div>}>
|
|
||||||
<CategoryPageClient />
|
|
||||||
</Suspense>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,221 +0,0 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
|
||||||
import { Badge } from "@/shared/components/ds/badge";
|
|
||||||
import { Button } from "@/shared/components/ui/button";
|
|
||||||
import {
|
|
||||||
MoreHorizontal,
|
|
||||||
Edit,
|
|
||||||
Trash,
|
|
||||||
ChevronsUpDown,
|
|
||||||
ChevronUp,
|
|
||||||
ChevronDown,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/shared/components/ui/dropdown-menu";
|
|
||||||
import { Credential } from "@/shared/types/credential";
|
|
||||||
import credentialApi from "@/shared/services/credential";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { hasPermission } from "@/shared/config/role";
|
|
||||||
import { useAuthSession } from "@/shared/hooks/use-session";
|
|
||||||
import { DeleteDialog } from "../../_components/delete-dialog";
|
|
||||||
|
|
||||||
interface ColumnConfig {
|
|
||||||
id: string;
|
|
||||||
header: string;
|
|
||||||
accessor?: keyof Credential;
|
|
||||||
accessorFn?: (row: Credential) => any;
|
|
||||||
sortable?: boolean;
|
|
||||||
cell?: (value: any) => React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLUMN_CONFIGS: ColumnConfig[] = [
|
|
||||||
{
|
|
||||||
id: "name",
|
|
||||||
header: "Nama Kredensial",
|
|
||||||
accessor: "name",
|
|
||||||
sortable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "credential_type",
|
|
||||||
header: "Tipe Kredensial",
|
|
||||||
accessor: "credential_type",
|
|
||||||
sortable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "environment",
|
|
||||||
header: "Environment",
|
|
||||||
accessorFn: (row) => row.credential_metadata.environment,
|
|
||||||
sortable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "version",
|
|
||||||
header: "Versi",
|
|
||||||
accessorFn: (row) => row.credential_metadata.version,
|
|
||||||
sortable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "is_default",
|
|
||||||
header: "Default",
|
|
||||||
accessor: "is_default",
|
|
||||||
sortable: true,
|
|
||||||
cell: (value) => (
|
|
||||||
<Badge variant={value ? "success" : "secondary"}>
|
|
||||||
{value ? "Ya" : "Tidak"}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "is_active",
|
|
||||||
header: "Status",
|
|
||||||
accessor: "is_active",
|
|
||||||
sortable: true,
|
|
||||||
cell: (value) => (
|
|
||||||
<Badge variant={value ? "success" : "secondary"}>
|
|
||||||
{value ? "Aktif" : "Tidak Aktif"}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const useCredentialColumns = (): ColumnDef<Credential>[] => {
|
|
||||||
const router = useRouter();
|
|
||||||
const [credentialToDelete, setCredentialToDelete] =
|
|
||||||
useState<Credential | null>(null);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { session } = useAuthSession();
|
|
||||||
|
|
||||||
const userRole = session?.user?.role;
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: async (id: string) => {
|
|
||||||
return await credentialApi.deleteCredential(id);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Berhasil menghapus credential");
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["credentials"] });
|
|
||||||
setCredentialToDelete(null);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error("Gagal menghapus credential");
|
|
||||||
console.error("Error deleting credential:", error);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderSortableHeader = (column: any, label: string) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
className="p-0 hover:bg-transparent"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
{column.getIsSorted() === "asc" ? (
|
|
||||||
<ChevronUp className="ml-2 h-4 w-4" />
|
|
||||||
) : column.getIsSorted() === "desc" ? (
|
|
||||||
<ChevronDown className="ml-2 h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
const baseColumns = COLUMN_CONFIGS.map((config) => {
|
|
||||||
const column: ColumnDef<Credential> = {
|
|
||||||
id: config.id,
|
|
||||||
header: ({ column }) =>
|
|
||||||
config.sortable
|
|
||||||
? renderSortableHeader(column, config.header)
|
|
||||||
: config.header,
|
|
||||||
...(config.accessor && { accessorKey: config.accessor }),
|
|
||||||
...(config.accessorFn && { accessorFn: config.accessorFn }),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const value = row.getValue(config.id);
|
|
||||||
return config.cell ? (
|
|
||||||
config.cell(value)
|
|
||||||
) : (
|
|
||||||
<div>{value as React.ReactNode}</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
enableSorting: config.sortable !== false,
|
|
||||||
enableHiding: config.id !== "select" && config.id !== "actions",
|
|
||||||
};
|
|
||||||
|
|
||||||
return column;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
userRole &&
|
|
||||||
(hasPermission(userRole, "credential", "read") ||
|
|
||||||
hasPermission(userRole, "credential", "update") ||
|
|
||||||
hasPermission(userRole, "credential", "delete"))
|
|
||||||
) {
|
|
||||||
baseColumns.push({
|
|
||||||
id: "actions",
|
|
||||||
enableHiding: false,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const credential = row.original;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
|
||||||
<span className="sr-only">Open menu</span>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuLabel>Aksi</DropdownMenuLabel>
|
|
||||||
{hasPermission(userRole, "credential", "update") && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
router.push(`/admin/credential/edit/${credential.id}`)
|
|
||||||
}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
Edit Credential
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{hasPermission(userRole, "credential", "delete") && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setCredentialToDelete(credential)}
|
|
||||||
className="flex items-center gap-2 text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash className="h-4 w-4" />
|
|
||||||
Hapus Credential
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
{credentialToDelete?.id === credential.id && (
|
|
||||||
<DeleteDialog
|
|
||||||
name={credentialToDelete.name}
|
|
||||||
isDeleting={deleteMutation.isPending}
|
|
||||||
onDelete={() => deleteMutation.mutate(credentialToDelete.id)}
|
|
||||||
onCancel={() => setCredentialToDelete(null)}
|
|
||||||
open={true}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseColumns;
|
|
||||||
};
|
|
||||||
|
|
@ -1,262 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/shared/components/ui/form";
|
|
||||||
import { Input } from "@/shared/components/ui/input";
|
|
||||||
import { Textarea } from "@/shared/components/ui/textarea";
|
|
||||||
import { Switch } from "@/shared/components/ui/switch";
|
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import {
|
|
||||||
CredentialFormValues,
|
|
||||||
credentialSchema,
|
|
||||||
} from "../../../../../shared/schemas/credential";
|
|
||||||
import { Credential } from "@/shared/types/credential";
|
|
||||||
import { PlusCircle, X } from "lucide-react";
|
|
||||||
|
|
||||||
interface CredentialFormProps {
|
|
||||||
defaultValues?: Partial<Credential>;
|
|
||||||
onSubmitAction: (data: CredentialFormValues) => void;
|
|
||||||
isSubmitting?: boolean;
|
|
||||||
onCancelAction?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CredentialForm({
|
|
||||||
defaultValues,
|
|
||||||
onSubmitAction,
|
|
||||||
isSubmitting,
|
|
||||||
onCancelAction,
|
|
||||||
}: CredentialFormProps) {
|
|
||||||
const form = useForm<CredentialFormValues>({
|
|
||||||
resolver: zodResolver(credentialSchema),
|
|
||||||
defaultValues: {
|
|
||||||
id: defaultValues?.id || "",
|
|
||||||
name: defaultValues?.name || "",
|
|
||||||
description: defaultValues?.description || "",
|
|
||||||
credential_type: defaultValues?.credential_type || "",
|
|
||||||
credential_metadata: defaultValues?.credential_metadata || {
|
|
||||||
environment: "",
|
|
||||||
version: "",
|
|
||||||
},
|
|
||||||
is_default: defaultValues?.is_default ?? false,
|
|
||||||
is_active: defaultValues?.is_active ?? true,
|
|
||||||
created_by: defaultValues?.created_by || "",
|
|
||||||
updated_by: defaultValues?.updated_by || "",
|
|
||||||
created_at: defaultValues?.created_at || "",
|
|
||||||
updated_at: defaultValues?.updated_at || "",
|
|
||||||
last_used_at: defaultValues?.last_used_at || "",
|
|
||||||
last_used_by: defaultValues?.last_used_by || "",
|
|
||||||
sensitive_data: defaultValues?.decrypted_data || {}, // 🆕 This line
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmitAction)} className="space-y-6">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Nama Kredensial</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Nama credential" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="description"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Deskripsi</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea placeholder="Deskripsi credential" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="credential_type"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Tipe Kredensial</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Jenis credential" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{/* Credential Metadata Fields */}
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="credential_metadata.environment"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Environment</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Environment" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="credential_metadata.version"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Versi</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Versi Credential" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{/* Switches */}
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="is_default"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between border p-4 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<FormLabel>Default</FormLabel>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Jadikan credential ini default
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="is_active"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between border p-4 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<FormLabel>Status</FormLabel>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Aktifkan atau nonaktifkan credential
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormLabel className="text-base">Data Sensitif</FormLabel>
|
|
||||||
<div className="space-y-4 border p-4 rounded-lg">
|
|
||||||
{(
|
|
||||||
Object.entries(form.watch("sensitive_data") || {}) as [
|
|
||||||
string,
|
|
||||||
string
|
|
||||||
][]
|
|
||||||
).map(([key, value]) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="grid grid-cols-[1fr_1fr_auto] gap-2 items-center"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Key"
|
|
||||||
defaultValue={key}
|
|
||||||
onBlur={(e) => {
|
|
||||||
const newKey = e.target.value;
|
|
||||||
const data = { ...form.getValues("sensitive_data") };
|
|
||||||
|
|
||||||
if (newKey && newKey !== key) {
|
|
||||||
data[newKey] = data[key];
|
|
||||||
delete data[key];
|
|
||||||
form.setValue("sensitive_data", data);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="Value"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => {
|
|
||||||
const data = { ...form.getValues("sensitive_data") };
|
|
||||||
data[key] = e.target.value;
|
|
||||||
form.setValue("sensitive_data", data);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
const data = { ...form.getValues("sensitive_data") };
|
|
||||||
delete data[key];
|
|
||||||
form.setValue("sensitive_data", data);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
const current = form.getValues("sensitive_data") || {};
|
|
||||||
const newKey = "key";
|
|
||||||
let counter = 1;
|
|
||||||
|
|
||||||
while (current.hasOwnProperty(`${newKey}_${counter}`)) {
|
|
||||||
counter++;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.setValue("sensitive_data", {
|
|
||||||
...current,
|
|
||||||
[`${newKey}_${counter}`]: "",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PlusCircle className="mr-2 h-4 w-4" />
|
|
||||||
Tambah Field
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-4">
|
|
||||||
{onCancelAction && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCancelAction}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? "Menyimpan..." : "Simpan"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import credentialApi from "@/shared/services/credential";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { queryClient } from "@/shared/utils/query-client";
|
|
||||||
import { getChangedFields } from "@/shared/utils/form";
|
|
||||||
import { CredentialFormValues } from "../../../../../shared/schemas/credential";
|
|
||||||
import { AxiosError } from "axios";
|
|
||||||
|
|
||||||
export function useCredentialForm(defaultValues?: Partial<Credential>) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const isEdit = !!defaultValues?.id;
|
|
||||||
|
|
||||||
const handleSubmitCredential = async (data: CredentialFormValues) => {
|
|
||||||
try {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
if (isEdit) {
|
|
||||||
const changedFields = getChangedFields(defaultValues || {}, data);
|
|
||||||
|
|
||||||
if (Object.keys(changedFields).length === 0) {
|
|
||||||
toast.info("Tidak ada perubahan untuk disimpan");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await credentialApi.updateCredential(defaultValues.id!, changedFields);
|
|
||||||
toast.success("Kredensial berhasil diperbarui");
|
|
||||||
} else {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const { id, ...dataWithoutId } = data;
|
|
||||||
await credentialApi.createCredential(dataWithoutId);
|
|
||||||
toast.success("Kredensial berhasil ditambahkan");
|
|
||||||
}
|
|
||||||
router.push("/admin/credential");
|
|
||||||
router.refresh();
|
|
||||||
queryClient.invalidateQueries();
|
|
||||||
} catch (error) {
|
|
||||||
const axiosError = error as AxiosError<{ detail?: string }>;
|
|
||||||
const apiDetail = axiosError.response?.data?.detail;
|
|
||||||
toast.error(apiDetail || (isEdit ? "Gagal memperbarui kredensial" : "Gagal menambahkan kredensial"));
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
router.back();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
isLoading: false,
|
|
||||||
handleSubmitCredential,
|
|
||||||
resetForm,
|
|
||||||
isSubmitting,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { useCredentialForm } from "../_components/use-credential-form";
|
|
||||||
import { CredentialForm } from "../_components/form";
|
|
||||||
|
|
||||||
export default function AddCredentialPageClient() {
|
|
||||||
const { isLoading, handleSubmitCredential, resetForm, isSubmitting } =
|
|
||||||
useCredentialForm();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="flex flex-col items-center space-y-2">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
|
||||||
<p className="text-sm text-gray-500">Memuat data...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
<CredentialForm
|
|
||||||
onSubmitAction={handleSubmitCredential}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
onCancelAction={resetForm}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { Suspense } from "react";
|
|
||||||
import AddCredentialPageClient from "./page.client";
|
|
||||||
import PageHeader from "../../_components/page-header";
|
|
||||||
|
|
||||||
export default function AddCredentialPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader title="Tambah Kredensial" className="bg-zinc-50" />
|
|
||||||
<div className="px-6">
|
|
||||||
<Suspense fallback={<div>Memuat form...</div>}>
|
|
||||||
<AddCredentialPageClient />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import credentialApi from "@/shared/services/credential";
|
|
||||||
import { CredentialForm } from "../../_components/form";
|
|
||||||
import { useCredentialForm } from "../../_components/use-credential-form";
|
|
||||||
import { CredentialFormValues } from "@/shared/schemas/credential";
|
|
||||||
|
|
||||||
export default function CredentialEditPageClient() {
|
|
||||||
const params = useParams();
|
|
||||||
const id = params.id as string;
|
|
||||||
|
|
||||||
const { data: credential, isLoading } = useQuery({
|
|
||||||
queryKey: ["credential", id],
|
|
||||||
queryFn: () => credentialApi.getCredentialDecrypted(id),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { handleSubmitCredential, resetForm, isSubmitting } =
|
|
||||||
useCredentialForm(credential);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-[50vh] items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
<CredentialForm
|
|
||||||
defaultValues={credential as Partial<CredentialFormValues>}
|
|
||||||
onSubmitAction={handleSubmitCredential}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
onCancelAction={resetForm}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { Suspense } from "react";
|
|
||||||
import CredentialEditPageClient from "./page.client";
|
|
||||||
import PageHeader from "../../../_components/page-header";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "Edit Kredensial",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CredentialEditPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader title="Ubah Kredensial" className="bg-zinc-50" />
|
|
||||||
<div className="px-6">
|
|
||||||
<Suspense fallback={<div>Memuat form...</div>}>
|
|
||||||
<CredentialEditPageClient />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Credential } from "@/shared/types/credential";
|
|
||||||
import { useCredentialColumns } from "./_components/column";
|
|
||||||
import credentialApi from "@/shared/services/credential";
|
|
||||||
import { useTableState } from "../_hooks/use-table-state";
|
|
||||||
import { ResourceTable } from "../_components/resource-table";
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
|
||||||
|
|
||||||
export default function CredentialPageClient() {
|
|
||||||
const columns = useCredentialColumns();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: credentials,
|
|
||||||
total,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
refetch,
|
|
||||||
searchValue,
|
|
||||||
sorting,
|
|
||||||
handleSearchInputChange,
|
|
||||||
handlePaginationChange,
|
|
||||||
updateSortingParams,
|
|
||||||
pageIndex,
|
|
||||||
pageCount,
|
|
||||||
limit,
|
|
||||||
setSorting,
|
|
||||||
} = useTableState<Credential>({
|
|
||||||
resourceName: "credentials",
|
|
||||||
fetchAction: credentialApi.getCredentials,
|
|
||||||
defaultLimit: 10,
|
|
||||||
defaultSort: { id: "name", desc: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResourceTable
|
|
||||||
data={credentials}
|
|
||||||
columns={columns as ColumnDef<unknown, unknown>[]}
|
|
||||||
total={total}
|
|
||||||
isLoading={isLoading}
|
|
||||||
isError={isError}
|
|
||||||
searchValue={searchValue}
|
|
||||||
onSearchChangeAction={handleSearchInputChange}
|
|
||||||
sorting={sorting}
|
|
||||||
onSortingChangeAction={(newSorting) => {
|
|
||||||
setSorting(newSorting);
|
|
||||||
updateSortingParams(newSorting);
|
|
||||||
}}
|
|
||||||
pageIndex={pageIndex}
|
|
||||||
pageCount={pageCount}
|
|
||||||
pageSize={limit}
|
|
||||||
onPaginationChangeAction={handlePaginationChange}
|
|
||||||
emptyStateProps={{
|
|
||||||
title: "Kredensial tidak ditemukan",
|
|
||||||
}}
|
|
||||||
actionBarProps={{
|
|
||||||
buttonLabel: "Tambah Kredensial",
|
|
||||||
buttonLink: "/admin/credential/add",
|
|
||||||
}}
|
|
||||||
refetchAction={refetch}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { Suspense } from "react";
|
|
||||||
import CredentialPageClient from "./page.client";
|
|
||||||
import PageHeader from "../_components/page-header";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "Kredensial",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CredentialPage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader title="Kredensial" />
|
|
||||||
<div className="px-6">
|
|
||||||
<Suspense>
|
|
||||||
<Suspense fallback={<div>Memuat Data...</div>}>
|
|
||||||
<CredentialPageClient />
|
|
||||||
</Suspense>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,232 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { appConfig } from "@/shared/config/app-config";
|
|
||||||
import { useAuthSession } from "@/shared/hooks/use-session";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { roles } from "@/shared/config/role";
|
|
||||||
|
|
||||||
const DashboardPage = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { isLoading, isAuthenticated, session } = useAuthSession();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
router.replace("/auth/admin/login");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the user's role and redirect to the appropriate path
|
|
||||||
const userRole = session?.user?.role;
|
|
||||||
if (userRole && roles[userRole.name]) {
|
|
||||||
const redirectPath = roles[userRole.name].redirectTo;
|
|
||||||
if (redirectPath !== "/admin") {
|
|
||||||
router.replace(redirectPath);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 space-y-8">
|
|
||||||
<div className="text-center space-y-4">
|
|
||||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-600 to-green-600 bg-clip-text text-transparent">
|
|
||||||
Selamat Datang di Satu Peta {appConfig.wilayah}
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-muted-foreground">
|
|
||||||
Portal Informasi Geospasial {appConfig.wilayah}
|
|
||||||
</p>
|
|
||||||
<div className="max-w-2xl mx-auto">
|
|
||||||
<p className="text-lg">
|
|
||||||
Platform terpadu untuk mengelola dan memvisualisasikan data
|
|
||||||
geografis {appConfig.wilayah}. Bersama kita wujudkan{" "}
|
|
||||||
{appConfig.wilayah} yang lebih terintegrasi dan informatif.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-8 md:grid-cols-3">
|
|
||||||
<div className="p-6 rounded-xl bg-gradient-to-br from-blue-50 to-white shadow-lg hover:shadow-xl transition-shadow duration-300">
|
|
||||||
<div className="h-12 w-12 bg-blue-100 rounded-lg flex items-center justify-center mb-4">
|
|
||||||
<svg
|
|
||||||
className="w-6 h-6 text-blue-600"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold mb-2 text-blue-800">
|
|
||||||
Statistik
|
|
||||||
</h3>
|
|
||||||
<p className="text-zinc-700">
|
|
||||||
Pantau perkembangan dan analisis data geografis {appConfig.wilayah}{" "}
|
|
||||||
secara real-time
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 rounded-xl bg-gradient-to-br from-green-50 to-white shadow-lg hover:shadow-xl transition-shadow duration-300">
|
|
||||||
<div className="h-12 w-12 bg-green-100 rounded-lg flex items-center justify-center mb-4">
|
|
||||||
<svg
|
|
||||||
className="w-6 h-6 text-green-600"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold mb-2 text-green-800">
|
|
||||||
Kelola Data
|
|
||||||
</h3>
|
|
||||||
<p className="text-zinc-700">
|
|
||||||
Kelola dan perbarui data geografis dengan mudah dan efisien
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 rounded-xl bg-gradient-to-br from-purple-50 to-white shadow-lg hover:shadow-xl transition-shadow duration-300">
|
|
||||||
<div className="h-12 w-12 bg-purple-100 rounded-lg flex items-center justify-center mb-4">
|
|
||||||
<svg
|
|
||||||
className="w-6 h-6 text-purple-600"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold mb-2 text-purple-800">
|
|
||||||
Keamanan
|
|
||||||
</h3>
|
|
||||||
<p className="text-zinc-700">
|
|
||||||
Sistem keamanan terpadu untuk melindungi data geografis{" "}
|
|
||||||
{appConfig.wilayah}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 p-6 rounded-xl bg-gradient-to-r from-blue-50 to-green-50 shadow-lg">
|
|
||||||
<h2 className="text-2xl font-semibold mb-4 text-gray-800">
|
|
||||||
Fitur Unggulan
|
|
||||||
</h2>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="h-6 w-6 rounded-full bg-blue-600 flex items-center justify-center flex-shrink-0 mt-1">
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 text-white"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-gray-800">
|
|
||||||
Visualisasi Data Real-time
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-zinc-700">
|
|
||||||
Pantau perubahan data geografis secara langsung
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="h-6 w-6 rounded-full bg-green-600 flex items-center justify-center flex-shrink-0 mt-1">
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 text-white"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-gray-800">Analisis Terpadu</h4>
|
|
||||||
<p className="text-sm text-zinc-700">
|
|
||||||
Analisis data geografis dengan berbagai parameter
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="h-6 w-6 rounded-full bg-blue-600 flex items-center justify-center flex-shrink-0 mt-1">
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 text-white"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-gray-800">Kolaborasi Tim</h4>
|
|
||||||
<p className="text-sm text-zinc-700">
|
|
||||||
Kerja sama tim yang efisien dalam pengelolaan data
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="h-6 w-6 rounded-full bg-green-600 flex items-center justify-center flex-shrink-0 mt-1">
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4 text-white"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-gray-800">
|
|
||||||
Layanan Peta Terintegrasi
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-zinc-700">
|
|
||||||
Kelola peta dari berbagai sumber data
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DashboardPage;
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import Sidebar from "./_components/sidebar";
|
|
||||||
import AdminRouteGuard from "@/shared/components/auth/admin-route-guard";
|
|
||||||
import { useAuthSession } from "@/shared/hooks/use-session";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import RefDataBootstrap from "./_components/refdata-bootstrap";
|
|
||||||
|
|
||||||
interface AdminLayoutProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AdminLayout: React.FC<AdminLayoutProps> = ({ children }) => {
|
|
||||||
const { isLoading } = useAuthSession();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<div className="flex flex-col items-center space-y-2">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
|
||||||
<p className="text-sm text-gray-500">Memuat sesi...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AdminRouteGuard>
|
|
||||||
<div className="flex h-screen p-4 space-x-4 overflow-hidden">
|
|
||||||
<Sidebar />
|
|
||||||
|
|
||||||
<div className="flex flex-col flex-1 overflow-hidden">
|
|
||||||
{/* Prefetch reference data for selects across admin */}
|
|
||||||
<RefDataBootstrap />
|
|
||||||
<main className="flex-1 overflow-y-auto rounded-lg border border-zinc-200 relative pb-6">{children}</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AdminRouteGuard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminLayout;
|
|
||||||
|
|
@ -1,229 +0,0 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
import { Badge } from "@/shared/components/ds/badge";
|
|
||||||
import { Button } from "@/shared/components/ds/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/shared/components/ui/dropdown-menu";
|
|
||||||
import { MapSource } from "@/shared/types/map-source";
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
|
||||||
import {
|
|
||||||
ChevronsUpDown,
|
|
||||||
MoreHorizontal,
|
|
||||||
ChevronUp,
|
|
||||||
ChevronDown,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import mapSourceApi from "@/shared/services/map-source";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { hasPermission } from "@/shared/config/role";
|
|
||||||
import { DeleteDialog } from "../../_components/delete-dialog";
|
|
||||||
import { useAuthSession } from "@/shared/hooks/use-session";
|
|
||||||
|
|
||||||
interface ColumnConfig {
|
|
||||||
id: string;
|
|
||||||
header: string;
|
|
||||||
accessor?: keyof MapSource;
|
|
||||||
accessorFn?: (row: MapSource) => any;
|
|
||||||
sortable?: boolean;
|
|
||||||
cell?: (value: any) => React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLUMN_CONFIGS: ColumnConfig[] = [
|
|
||||||
{
|
|
||||||
id: "name",
|
|
||||||
header: "Nama Mapserver & Metadata",
|
|
||||||
accessor: "name",
|
|
||||||
sortable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "credential_type",
|
|
||||||
header: "Tipe",
|
|
||||||
accessorFn: (row) => row.credential.credential_type,
|
|
||||||
sortable: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "is_active",
|
|
||||||
header: "Status",
|
|
||||||
accessor: "is_active",
|
|
||||||
sortable: true,
|
|
||||||
cell: (value) => (
|
|
||||||
<Badge variant={value ? "success" : "secondary"}>
|
|
||||||
{value ? "Aktif" : "Tidak Aktif"}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "created_at",
|
|
||||||
header: "Dibuat Pada",
|
|
||||||
accessor: "created_at",
|
|
||||||
sortable: true,
|
|
||||||
cell: (value) =>
|
|
||||||
new Date(value).toLocaleDateString("id-ID", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "updated_at",
|
|
||||||
header: "Diperbarui Pada",
|
|
||||||
accessor: "updated_at",
|
|
||||||
sortable: true,
|
|
||||||
cell: (value) =>
|
|
||||||
new Date(value).toLocaleDateString("id-ID", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const useMapSourceColumns = (): ColumnDef<MapSource>[] => {
|
|
||||||
const router = useRouter();
|
|
||||||
const [mapSourceToDelete, setMapSourceToDelete] = useState<MapSource | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { session } = useAuthSession();
|
|
||||||
|
|
||||||
const userRole = session?.user?.role;
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: async (id: string) => {
|
|
||||||
return await mapSourceApi.deleteMapSource(id);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Berhasil menghapus data");
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["mapSources"] });
|
|
||||||
setMapSourceToDelete(null);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error("Gagal menghapus data");
|
|
||||||
console.error("Error deleting mapSource:", error);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderSortableHeader = (column: any, label: string) => (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
||||||
className="p-0 hover:bg-transparent"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
{(() => {
|
|
||||||
if (column.getIsSorted() === "asc") {
|
|
||||||
return <ChevronUp className="ml-2 h-4 w-4" />;
|
|
||||||
} else if (column.getIsSorted() === "desc") {
|
|
||||||
return <ChevronDown className="ml-2 h-4 w-4" />;
|
|
||||||
} else {
|
|
||||||
return <ChevronsUpDown className="ml-2 h-4 w-4" />;
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
const baseColumns = COLUMN_CONFIGS.map((config) => {
|
|
||||||
const column: ColumnDef<MapSource> = {
|
|
||||||
id: config.id,
|
|
||||||
header: ({ column }) =>
|
|
||||||
config.sortable
|
|
||||||
? renderSortableHeader(column, config.header)
|
|
||||||
: config.header,
|
|
||||||
...(config.accessor && { accessorKey: config.accessor }),
|
|
||||||
...(config.accessorFn && { accessorFn: config.accessorFn }),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const value = row.getValue(config.id);
|
|
||||||
return config.cell ? (
|
|
||||||
config.cell(value)
|
|
||||||
) : (
|
|
||||||
<div>{value as React.ReactNode}</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
enableSorting: config.sortable !== false,
|
|
||||||
enableHiding: config.id !== "select" && config.id !== "actions",
|
|
||||||
};
|
|
||||||
|
|
||||||
return column;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
userRole &&
|
|
||||||
(hasPermission(userRole, "map-source", "read") ||
|
|
||||||
hasPermission(userRole, "map-source", "update") ||
|
|
||||||
hasPermission(userRole, "map-source", "delete"))
|
|
||||||
) {
|
|
||||||
baseColumns.push({
|
|
||||||
id: "actions",
|
|
||||||
enableHiding: false,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const mapSource = row.original;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
|
||||||
<span className="sr-only">Open menu</span>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuLabel>Aksi</DropdownMenuLabel>
|
|
||||||
{hasPermission(userRole, "map-source", "read") && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
router.push(`/admin/map-source/detail/${mapSource.id}`)
|
|
||||||
}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
Lihat Detail
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{hasPermission(userRole, "map-source", "update") && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
router.push(`/admin/map-source/edit/${mapSource.id}`)
|
|
||||||
}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
Edit Mapserver & Metadata
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{hasPermission(userRole, "map-source", "delete") && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setMapSourceToDelete(mapSource)}
|
|
||||||
className="flex items-center gap-2 text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
Hapus Mapserver & Metadata
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
{mapSourceToDelete?.id === mapSource.id && (
|
|
||||||
<DeleteDialog
|
|
||||||
name={mapSourceToDelete.name}
|
|
||||||
isDeleting={deleteMutation.isPending}
|
|
||||||
onDelete={() => deleteMutation.mutate(mapSourceToDelete.id)}
|
|
||||||
onCancel={() => setMapSourceToDelete(null)}
|
|
||||||
open={true}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseColumns;
|
|
||||||
};
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import mapSourceApi from "@/shared/services/map-source";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import DetailItem from "../../_components/detail-item";
|
|
||||||
|
|
||||||
export default function MapSourceDetail({ id }: { id: string }) {
|
|
||||||
const { data: mapSource } = useQuery({
|
|
||||||
queryKey: ["mapSource", id],
|
|
||||||
queryFn: () => mapSourceApi.getMapSourceById(id),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!mapSource) return <div>Loading...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="mx-6 p-2 border rounded-[6px] border-zinc-200">
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<span className="text-lg font-semibold text-zinc-950">
|
|
||||||
Informasi Mapserver & Metadata
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DetailItem label="Nama" value={mapSource.name} />
|
|
||||||
<DetailItem label="Deskripsi" value={mapSource.description} />
|
|
||||||
|
|
||||||
{/* Credential Info */}
|
|
||||||
<DetailItem
|
|
||||||
label="Tipe"
|
|
||||||
value={mapSource.credential?.credential_type || "Tidak tersedia"}
|
|
||||||
/>
|
|
||||||
{/* Status & Metadata */}
|
|
||||||
<DetailItem
|
|
||||||
label="Status"
|
|
||||||
value={mapSource.is_active ? "Aktif" : "Tidak Aktif"}
|
|
||||||
/>
|
|
||||||
<DetailItem
|
|
||||||
label="Dibuat Pada"
|
|
||||||
value={new Date(mapSource.created_at).toLocaleString("id-ID")}
|
|
||||||
/>
|
|
||||||
<DetailItem
|
|
||||||
label="Diperbarui Pada"
|
|
||||||
value={new Date(mapSource.updated_at).toLocaleString("id-ID")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Button } from "@/shared/components/ui/button";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/shared/components/ui/form";
|
|
||||||
import { Input } from "@/shared/components/ui/input";
|
|
||||||
import { Switch } from "@/shared/components/ui/switch";
|
|
||||||
import { Textarea } from "@/shared/components/ui/textarea";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/shared/components/ui/select";
|
|
||||||
import {
|
|
||||||
MapSourceFormValues,
|
|
||||||
mapSourceSchema,
|
|
||||||
} from "@/shared/schemas/map-source";
|
|
||||||
import credentialApi from "@/shared/services/credential";
|
|
||||||
import { Credential } from "@/shared/types/credential";
|
|
||||||
import { MapSource } from "@/shared/types/map-source";
|
|
||||||
|
|
||||||
interface MapSourceFormProps {
|
|
||||||
defaultValues?: Partial<MapSource>;
|
|
||||||
onSubmitAction: (data: MapSourceFormValues) => void;
|
|
||||||
isSubmitting?: boolean;
|
|
||||||
onCancelAction?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MapSourceForm({
|
|
||||||
defaultValues,
|
|
||||||
onSubmitAction,
|
|
||||||
isSubmitting,
|
|
||||||
onCancelAction,
|
|
||||||
}: MapSourceFormProps) {
|
|
||||||
const form = useForm<MapSourceFormValues>({
|
|
||||||
resolver: zodResolver(mapSourceSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: defaultValues?.name || "",
|
|
||||||
description: defaultValues?.description || "",
|
|
||||||
credential_id: defaultValues?.credential?.id || "",
|
|
||||||
url: defaultValues?.url || "",
|
|
||||||
is_active: defaultValues?.is_active ?? true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: credentials, isLoading: isCredLoading } = useQuery<
|
|
||||||
Credential[]
|
|
||||||
>({
|
|
||||||
queryKey: ["credentials"],
|
|
||||||
queryFn: () =>
|
|
||||||
credentialApi.getCredentials().then((response) => response.items),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmitAction)} className="space-y-6">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Nama</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Masukkan nama" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="description"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Deskripsi</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea placeholder="Masukkan deskripsi" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="credential_id"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Credential</FormLabel>
|
|
||||||
<Select
|
|
||||||
disabled={isCredLoading}
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
value={field.value}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue placeholder="Pilih credential" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
{credentials?.map((credential: Credential) => (
|
|
||||||
<SelectItem key={credential.id} value={credential.id}>
|
|
||||||
{credential.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="url"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>URL</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Masukkan URL" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="is_active"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel className="text-base">Status</FormLabel>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Aktifkan atau nonaktifkan
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-4">
|
|
||||||
{onCancelAction && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCancelAction}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="submit" disabled={isSubmitting || isCredLoading}>
|
|
||||||
{isSubmitting ? "Menyimpan..." : "Simpan"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import mapSourceApi from "@/shared/services/map-source";
|
|
||||||
import { MapSourceFormValues } from "@/shared/schemas/map-source";
|
|
||||||
import { queryClient } from "@/shared/utils/query-client";
|
|
||||||
import { MapSource } from "@/shared/types/map-source";
|
|
||||||
import { getChangedFields } from "@/shared/utils/form";
|
|
||||||
|
|
||||||
export function useMapSourceForm(defaultValues?: Partial<MapSource>) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const isEdit = !!defaultValues?.id;
|
|
||||||
|
|
||||||
const handleSubmitMapSource = async (data: MapSourceFormValues) => {
|
|
||||||
try {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
if (isEdit && defaultValues?.id) {
|
|
||||||
const changedFields = getChangedFields(defaultValues || {}, data);
|
|
||||||
|
|
||||||
if (Object.keys(changedFields).length === 0) {
|
|
||||||
toast.info("Tidak ada perubahan untuk disimpan");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await mapSourceApi.updateMapSource(defaultValues.id, changedFields);
|
|
||||||
toast.success("Mapserver & Metadata berhasil diperbarui");
|
|
||||||
} else {
|
|
||||||
await mapSourceApi.createMapSource(data);
|
|
||||||
toast.success("Mapserver & Metadata berhasil disimpan");
|
|
||||||
}
|
|
||||||
router.push("/admin/map-source");
|
|
||||||
router.refresh();
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["mapSources"] });
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
isEdit
|
|
||||||
? "Gagal memperbarui Mapserver & Metadata"
|
|
||||||
: "Gagal menyimpan mapSource"
|
|
||||||
);
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
router.back();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
isLoading: false,
|
|
||||||
handleSubmitMapSource,
|
|
||||||
resetForm,
|
|
||||||
isSubmitting,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { useMapSourceForm } from "../_hooks/use-form";
|
|
||||||
import { MapSourceForm } from "../_components/form";
|
|
||||||
|
|
||||||
export default function AddMapSourcePageClient() {
|
|
||||||
const { isLoading, handleSubmitMapSource, resetForm, isSubmitting } =
|
|
||||||
useMapSourceForm();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
|
||||||
<div className="flex flex-col items-center space-y-2">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
|
||||||
<p className="text-sm text-gray-500">Memuat data...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
<MapSourceForm
|
|
||||||
onSubmitAction={handleSubmitMapSource}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
onCancelAction={resetForm}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { Suspense } from "react";
|
|
||||||
import AddMapSourcePageClient from "./page.client";
|
|
||||||
import PageHeader from "../../_components/page-header";
|
|
||||||
|
|
||||||
export default function AddMapSourcePage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader title="Tambah Mapserver & Metadata" className="bg-zinc-50" />
|
|
||||||
<div className="px-6">
|
|
||||||
<Suspense fallback={<div>Memuat form...</div>}>
|
|
||||||
<AddMapSourcePageClient />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import MapSourceDetail from "../../_components/detail";
|
|
||||||
import PageHeader from "../../../_components/page-header";
|
|
||||||
|
|
||||||
export default function MapSourceDetailPage() {
|
|
||||||
const params = useParams();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader title="Detail Mapserver & Metadata" className="bg-zinc-50" />
|
|
||||||
<MapSourceDetail id={params.id?.toString() ?? ""} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { MapSourceForm } from "../../_components/form";
|
|
||||||
import mapSourceApi from "@/shared/services/map-source";
|
|
||||||
import { useMapSourceForm } from "../../_hooks/use-form";
|
|
||||||
|
|
||||||
export default function MapSourceEditPageClient() {
|
|
||||||
const params = useParams();
|
|
||||||
const id = params.id as string;
|
|
||||||
|
|
||||||
const { data: mapSource, isLoading } = useQuery({
|
|
||||||
queryKey: ["mapSource", id],
|
|
||||||
queryFn: () => mapSourceApi.getMapSourceById(id),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { handleSubmitMapSource, resetForm, isSubmitting } =
|
|
||||||
useMapSourceForm(mapSource);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-[50vh] items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl">
|
|
||||||
<MapSourceForm
|
|
||||||
defaultValues={mapSource}
|
|
||||||
onSubmitAction={handleSubmitMapSource}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
onCancelAction={resetForm}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { Suspense } from "react";
|
|
||||||
import PageHeader from "../../../_components/page-header";
|
|
||||||
import MapSourceEditPageClient from "./page.client";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "Ubah Mapserver & Metadata",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function MapSourcePage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader title="Ubah Mapserver & Metadata" />
|
|
||||||
<div className="px-6">
|
|
||||||
<Suspense fallback={<div>Memuat Data...</div>}>
|
|
||||||
<MapSourceEditPageClient />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { MapSource } from "@/shared/types/map-source";
|
|
||||||
import mapSourceApi from "@/shared/services/map-source";
|
|
||||||
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
|
||||||
|
|
||||||
import { ResourceTable } from "../_components/resource-table";
|
|
||||||
import { useTableState } from "../_hooks/use-table-state";
|
|
||||||
|
|
||||||
import { useMapSourceColumns } from "./_components/column";
|
|
||||||
|
|
||||||
export default function MapSourcePageClient() {
|
|
||||||
const columns = useMapSourceColumns();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: mapSources,
|
|
||||||
total,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
refetch,
|
|
||||||
searchValue,
|
|
||||||
sorting,
|
|
||||||
handleSearchInputChange,
|
|
||||||
handlePaginationChange,
|
|
||||||
updateSortingParams,
|
|
||||||
pageIndex,
|
|
||||||
pageCount,
|
|
||||||
limit,
|
|
||||||
setSorting,
|
|
||||||
} = useTableState<MapSource>({
|
|
||||||
resourceName: "mapSources",
|
|
||||||
fetchAction: mapSourceApi.getMapSources,
|
|
||||||
defaultLimit: 10,
|
|
||||||
defaultSort: { id: "name", desc: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResourceTable
|
|
||||||
data={mapSources}
|
|
||||||
columns={columns as ColumnDef<unknown, unknown>[]}
|
|
||||||
total={total}
|
|
||||||
isLoading={isLoading}
|
|
||||||
isError={isError}
|
|
||||||
searchValue={searchValue}
|
|
||||||
onSearchChangeAction={handleSearchInputChange}
|
|
||||||
sorting={sorting}
|
|
||||||
onSortingChangeAction={(newSorting) => {
|
|
||||||
setSorting(newSorting);
|
|
||||||
updateSortingParams(newSorting);
|
|
||||||
}}
|
|
||||||
pageIndex={pageIndex}
|
|
||||||
pageCount={pageCount}
|
|
||||||
pageSize={limit}
|
|
||||||
onPaginationChangeAction={handlePaginationChange}
|
|
||||||
emptyStateProps={{
|
|
||||||
title: "Mapserver & Metadata tidak ditemukan",
|
|
||||||
}}
|
|
||||||
actionBarProps={{
|
|
||||||
buttonLabel: "Tambah Mapserver & Metadata",
|
|
||||||
buttonLink: "/admin/map-source/add",
|
|
||||||
}}
|
|
||||||
refetchAction={refetch}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { Suspense } from "react";
|
|
||||||
import MapSourcePageClient from "./page.client";
|
|
||||||
import PageHeader from "../_components/page-header";
|
|
||||||
|
|
||||||
export default function MapSourcePage() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader title="Mapserver & Metadata" />
|
|
||||||
<div className="px-6">
|
|
||||||
<Suspense>
|
|
||||||
<MapSourcePageClient />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import { MapSourceFormValues } from "@/shared/schemas/map-source";
|
|
||||||
|
|
||||||
import { atom } from "jotai";
|
|
||||||
|
|
||||||
const initialFormState: MapSourceFormValues = {
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
credential_id: "",
|
|
||||||
is_active: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapSourceFormAtom = atom<MapSourceFormValues>(initialFormState);
|
|
||||||
|
|
||||||
export { mapSourceFormAtom, initialFormState };
|
|
||||||
|
|
@ -1,305 +0,0 @@
|
||||||
// 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;
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
// 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;
|
|
||||||
|
|
@ -1,242 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
import { Tabs, TabsContent } 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 {
|
|
||||||
title: initialValues.title || "",
|
|
||||||
abstract: initialValues.abstract || "",
|
|
||||||
keywords: initialValues.keyword ? (Array.isArray(initialValues.keyword) ? initialValues.keyword.join(', ') : initialValues.keyword) : "",
|
|
||||||
topicCategory: initialValues.metadata_category || "",
|
|
||||||
|
|
||||||
mapsetCategory: satupetaCategoryId[getIndexCategoryId(initialValues.mapset_category)] || "",
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
// 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;
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
// 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;
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
// app/admin/upload/_components/step-2-pdf-viewer.tsx
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef } from "react";
|
|
||||||
import { usePdfViewer } from "../_hooks/use-pdf-viewer";
|
|
||||||
import { Button } from "@/shared/components/ui/button";
|
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox";
|
|
||||||
import { Loader2, CheckSquare, Square } from "lucide-react";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
export default function StepPdfViewer() {
|
|
||||||
const {
|
|
||||||
pages,
|
|
||||||
loading,
|
|
||||||
localSelectedPages,
|
|
||||||
toggleSelectPage,
|
|
||||||
handleProcessPdf,
|
|
||||||
// Fitur Baru dari Hook
|
|
||||||
handleSelectAll,
|
|
||||||
isAllSelected,
|
|
||||||
MAX_SELECT
|
|
||||||
} = usePdfViewer();
|
|
||||||
|
|
||||||
// 🔥 REF UNTUK SCROLLING
|
|
||||||
const pageRefs = useRef<{ [key: number]: HTMLDivElement | null }>({});
|
|
||||||
|
|
||||||
// Fungsi Helper Scroll
|
|
||||||
const scrollToPage = (pageNum: number) => {
|
|
||||||
const element = pageRefs.current[pageNum];
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSidebarClick = (pageNum: number) => {
|
|
||||||
toggleSelectPage(pageNum);
|
|
||||||
// Auto scroll hanya jika kita mencentang (opsional: bisa juga scroll saat uncheck jika mau)
|
|
||||||
// Disini saya buat scroll terjadi setiap kali klik baris
|
|
||||||
scrollToPage(pageNum);
|
|
||||||
};
|
|
||||||
|
|
||||||
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="mb-3 pb-3 border-b">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full flex justify-between"
|
|
||||||
onClick={handleSelectAll}
|
|
||||||
disabled={loading || pages.length === 0}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
{isAllSelected ? <CheckSquare className="w-4 h-4"/> : <Square className="w-4 h-4"/>}
|
|
||||||
{isAllSelected ? "Batalkan Semua" : "Pilih Semua"}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
<p className="text-xs text-slate-400 mt-2 text-center">
|
|
||||||
Maksimal {MAX_SELECT} halaman.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 overflow-y-auto flex-1 pr-2">
|
|
||||||
{pages.map((p) => (
|
|
||||||
<div
|
|
||||||
key={p.pageNum}
|
|
||||||
className={`flex items-center space-x-3 p-2 rounded cursor-pointer border transition-colors ${
|
|
||||||
localSelectedPages.includes(p.pageNum)
|
|
||||||
? "bg-blue-50 border-blue-200"
|
|
||||||
: "hover:bg-slate-50 border-transparent"
|
|
||||||
}`}
|
|
||||||
onClick={() => handleSidebarClick(p.pageNum)}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={localSelectedPages.includes(p.pageNum)}
|
|
||||||
// onCheckedChange sudah dihandle oleh onClick parent div agar area klik lebih luas
|
|
||||||
className="pointer-events-none"
|
|
||||||
/>
|
|
||||||
<span className={`text-sm ${localSelectedPages.includes(p.pageNum) ? "text-blue-700 font-medium" : "text-slate-600"}`}>
|
|
||||||
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-8">
|
|
||||||
{pages.map((p) => (
|
|
||||||
<motion.div
|
|
||||||
key={p.pageNum}
|
|
||||||
// 🔥 SET REF DI SINI
|
|
||||||
ref={(el) => { pageRefs.current[p.pageNum] = el; }}
|
|
||||||
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.05 * (p.pageNum > 10 ? 10 : p.pageNum) }} // Optimasi delay list panjang
|
|
||||||
|
|
||||||
// Styling highlight jika terpilih
|
|
||||||
className={`bg-white p-2 shadow-sm rounded-lg border-2 transition-all duration-300 ${
|
|
||||||
localSelectedPages.includes(p.pageNum)
|
|
||||||
? "border-blue-500 ring-4 ring-blue-500/10"
|
|
||||||
: "border-transparent hover:border-slate-300"
|
|
||||||
}`}
|
|
||||||
onClick={() => toggleSelectPage(p.pageNum)} // Klik gambar juga bisa toggle select
|
|
||||||
>
|
|
||||||
<div className="relative">
|
|
||||||
{/* Badge Nomor Halaman di atas gambar */}
|
|
||||||
<div className="absolute top-2 left-2 bg-slate-800/80 text-white text-xs px-2 py-1 rounded backdrop-blur-sm z-10">
|
|
||||||
Halaman. {p.pageNum}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<img
|
|
||||||
src={p.imageUrl}
|
|
||||||
alt={`Page ${p.pageNum}`}
|
|
||||||
className="w-full h-auto rounded border border-slate-100"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
"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,
|
|
||||||
path: state.result?.data.tmp_path,
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,274 +0,0 @@
|
||||||
"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;}else{console.log("val", validatedData);} // 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.data.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.data.table_name}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{validatedData.data.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.data.total_rows.toLocaleString()} data
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{validatedData.data.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.data.geometry_type)
|
|
||||||
? validatedData.data.geometry_type.map((g: string) => `${geomIcons[g] || "❓"} ${g}`).join(", ")
|
|
||||||
: validatedData.data.geometry_type
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{validatedData.data.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.data.upload_time).toLocaleString("id-ID", {
|
|
||||||
dateStyle: "full",
|
|
||||||
timeStyle: "short",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* PROGRESS STEPS (WS LIVE) */}
|
|
||||||
{(validatedData.data.message && validatedData.data.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.data.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.data.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.data.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/mapset">
|
|
||||||
<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 cursor-pointer"
|
|
||||||
>
|
|
||||||
Upload data lagi
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
"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 (state.step === "SUCCESS" && stepParam !== "SUCCESS") {
|
|
||||||
// reset();
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
// 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 }));
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
setState(initialState);
|
|
||||||
router.push(pathname);
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
@ -1,327 +0,0 @@
|
||||||
// "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[]>([]);
|
|
||||||
|
|
||||||
const MAX_SELECT = 20;
|
|
||||||
|
|
||||||
// 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([]);
|
|
||||||
try {
|
|
||||||
const pdfjsLib = await loadPdfJs();
|
|
||||||
const arrayBuffer = await pdfFile.arrayBuffer();
|
|
||||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
|
||||||
const totalPages = pdf.numPages;
|
|
||||||
const pageImages = [];
|
|
||||||
|
|
||||||
for (let pageNum = 1; pageNum <= totalPages; pageNum++) {
|
|
||||||
const page = await pdf.getPage(pageNum);
|
|
||||||
const viewport = page.getViewport({ scale: 1 });
|
|
||||||
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 }).promise;
|
|
||||||
pageImages.push({
|
|
||||||
pageNum,
|
|
||||||
imageUrl: canvas.toDataURL("image/jpeg", 0.7),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 >= MAX_SELECT) {
|
|
||||||
toast.warning(`Maksimal ${MAX_SELECT} halaman yang dapat dipilih.`);
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return [...prev, pageNum];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 🔥 SELECT ALL
|
|
||||||
const isAllSelected = pages.length > 0 && localSelectedPages.length === Math.min(pages.length, MAX_SELECT);
|
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
|
||||||
if (isAllSelected) {
|
|
||||||
// Jika sudah terpilih semua (atau max), batalkan semua
|
|
||||||
setLocalSelectedPages([]);
|
|
||||||
} else {
|
|
||||||
// Pilih semua (tapi batasi sesuai MAX_SELECT)
|
|
||||||
const allPageNums = pages.map(p => p.pageNum).slice(0, MAX_SELECT);
|
|
||||||
setLocalSelectedPages(allPageNums);
|
|
||||||
|
|
||||||
if (pages.length > MAX_SELECT) {
|
|
||||||
toast.info(`Otomatis memilih ${MAX_SELECT} halaman pertama (Batas Maksimum).`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- 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 {
|
|
||||||
const res = await uploadApi.uploadFile(
|
|
||||||
state.file!,
|
|
||||||
localSelectedPages, // Kirim array halaman: [1, 2, 5]
|
|
||||||
null, // sheet null karena ini PDF
|
|
||||||
state.fileDesc
|
|
||||||
);
|
|
||||||
|
|
||||||
setState(prev => ({ ...prev, result: res }));
|
|
||||||
|
|
||||||
if (res.data.tables && res.data.tables.length > 0) {
|
|
||||||
goToStep("TABLE_PICKER");
|
|
||||||
toast.success(`Ditemukan ${res.data.tables.length} tabel. Silakan pilih tabel.`);
|
|
||||||
} else if (!res.data.tables) {
|
|
||||||
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,
|
|
||||||
handleSelectAll,
|
|
||||||
isAllSelected,
|
|
||||||
MAX_SELECT
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
"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})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { Mapset } from "@/shared/types/mapset";
|
|
||||||
|
|
||||||
export default function MapsetClassificationSection({
|
|
||||||
mapset,
|
|
||||||
}: {
|
|
||||||
mapset: Mapset;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="p-2 border rounded-[6px] border-zinc-200">
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<span className="text-lg font-semibold text-zinc-950">
|
|
||||||
Klasifikasi Wilayah
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950">
|
|
||||||
Tingkat Penyajian Wilayah
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-zinc-800">
|
|
||||||
{mapset.coverage_level ?? "-"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950">Cakupan Wilayah</div>
|
|
||||||
<div className="text-sm text-zinc-800">
|
|
||||||
{mapset.coverage_area ?? "-"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,288 +0,0 @@
|
||||||
"use client";
|
|
||||||
import { useAuthSession } from "@/shared/hooks/use-session";
|
|
||||||
import { DeleteDialog } from "@/app/(modules)/admin/_components/delete-dialog";
|
|
||||||
import { formatIndonesianDate } from "@/shared/utils/date";
|
|
||||||
import mapsetApi from "@/shared/services/mapset";
|
|
||||||
import { Button } from "@/shared/components/ds/button";
|
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Check,
|
|
||||||
PencilLineIcon,
|
|
||||||
DownloadIcon,
|
|
||||||
UnlinkIcon,
|
|
||||||
Trash2Icon,
|
|
||||||
ChevronLeft,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { VerifyMapsetDialog } from "../verify-mapset-dialog";
|
|
||||||
|
|
||||||
import MapsetClassificationSection from "./mapset-classification-section";
|
|
||||||
import MapsetInfoSection from "./mapset-info-section";
|
|
||||||
import MapsetMetadataSection from "./mapset-metadata-section";
|
|
||||||
import MapsetOrganizationSection from "./mapset-organization-section";
|
|
||||||
import { MapsetStatus } from "./mapset-status";
|
|
||||||
import MapsetVersionSection from "./mapset-version-section";
|
|
||||||
import PreviewMap from "@/shared/components/preview-map";
|
|
||||||
import MapsetHistory from "./mapset-history";
|
|
||||||
import { featureFlags } from "@/shared/config/feature-flag";
|
|
||||||
|
|
||||||
interface MapsetDetailProps {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MapsetDetail({ id }: MapsetDetailProps) {
|
|
||||||
const [isVerifyDialogOpen, setIsVerifyDialogOpen] = useState(false);
|
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const router = useRouter();
|
|
||||||
const { checkPermission } = useAuthSession();
|
|
||||||
|
|
||||||
const { data: mapset } = useQuery({
|
|
||||||
queryKey: ["mapset", id],
|
|
||||||
queryFn: () => mapsetApi.getMapsetById(id),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateStatusMutation = useMutation({
|
|
||||||
mutationFn: (updateData: { status: string; notes: string }) =>
|
|
||||||
mapsetApi.updateMapsetStatus(
|
|
||||||
id,
|
|
||||||
updateData.status,
|
|
||||||
updateData.notes,
|
|
||||||
mapset?.layer_url ?? ""
|
|
||||||
),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["mapset", id] });
|
|
||||||
toast.success("Status mapset berhasil diperbarui");
|
|
||||||
setIsVerifyDialogOpen(false);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error(error);
|
|
||||||
toast.error("Gagal memperbarui status mapset");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: () => mapsetApi.deleteMapset(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["mapsets"] });
|
|
||||||
toast.success("Mapset berhasil dihapus");
|
|
||||||
router.push("/admin/mapset");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
console.error(error);
|
|
||||||
toast.error("Gagal menghapus mapset");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleEdit = () => {
|
|
||||||
router.push(`/admin/mapset/edit/${id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = async () => {
|
|
||||||
if (!mapset?.layer_url) {
|
|
||||||
toast.error("URL layer tidak tersedia");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
toast.loading("Mempersiapkan download...");
|
|
||||||
const response = await fetch(`/fe-api/mapset/download/geojson/${id}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.message || "Gagal mengunduh data");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the blob from the response
|
|
||||||
const blob = await response.blob();
|
|
||||||
|
|
||||||
// Create download link
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.download = `${mapset.name}.geojson`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(link);
|
|
||||||
|
|
||||||
toast.success("Download GeoJSON dimulai");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Download error:", error);
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error ? error.message : "Gagal mengunduh data"
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
toast.dismiss();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnlink = () => {
|
|
||||||
if (!mapset?.layer_url) {
|
|
||||||
toast.error("URL layer tidak tersedia");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigator.clipboard.writeText(mapset.layer_url);
|
|
||||||
toast.success("URL layer berhasil disalin");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
setIsDeleteDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteConfirm = () => {
|
|
||||||
deleteMutation.mutate();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!mapset) return null;
|
|
||||||
|
|
||||||
const handleVerifyClick = () => {
|
|
||||||
setIsVerifyDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVerificationAction = (
|
|
||||||
action: "approve" | "reject",
|
|
||||||
notes: string
|
|
||||||
) => {
|
|
||||||
const newStatus = action === "approve" ? "approved" : "rejected";
|
|
||||||
updateStatusMutation.mutate({ status: newStatus, notes });
|
|
||||||
};
|
|
||||||
|
|
||||||
const canVerify =
|
|
||||||
mapset?.status_validation === "on_verification" &&
|
|
||||||
checkPermission("mapset", "verify");
|
|
||||||
|
|
||||||
const canEdit = checkPermission("mapset", "update");
|
|
||||||
const canDownload = checkPermission("mapset", "read");
|
|
||||||
const canUnlink = checkPermission("mapset", "update");
|
|
||||||
const canDelete = checkPermission("mapset", "delete");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={() => router.back()}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-8 w-8 text-zinc-700 cursor-pointer" />
|
|
||||||
</Button>
|
|
||||||
<h1 className="text-2xl font-bold">Mapset Detail</h1>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
Diperbarui {formatIndonesianDate(mapset.updated_at)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mb-6 text-sm">
|
|
||||||
<MapsetStatus mapset={mapset} />
|
|
||||||
<div className="border-l h-8 border-gray-200" />
|
|
||||||
{canVerify && (
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
className="px-2.5 h-8 text-xs"
|
|
||||||
onClick={handleVerifyClick}
|
|
||||||
>
|
|
||||||
<Check className="w-5 h-4 mr-2" /> Validasi Mapset
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="ml-auto flex gap-2 justify-between items-center">
|
|
||||||
{mapset.is_popular && (
|
|
||||||
<div className="bg-green-100 text-green-700 px-3 py-2 rounded-full">
|
|
||||||
Mapset Populer
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="border-l h-8 border-gray-200" />
|
|
||||||
<div className="flex">
|
|
||||||
{canEdit && (
|
|
||||||
<button
|
|
||||||
className="cursor-pointer w-9 h-9 flex items-center justify-center"
|
|
||||||
onClick={handleEdit}
|
|
||||||
>
|
|
||||||
<PencilLineIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canDownload && (
|
|
||||||
<button
|
|
||||||
className="cursor-pointer w-9 h-9 flex items-center justify-center"
|
|
||||||
onClick={handleDownload}
|
|
||||||
>
|
|
||||||
<DownloadIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canUnlink && (
|
|
||||||
<button
|
|
||||||
className="cursor-pointer w-9 h-9 flex items-center justify-center"
|
|
||||||
onClick={handleUnlink}
|
|
||||||
>
|
|
||||||
<UnlinkIcon className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{canDelete && (
|
|
||||||
<>
|
|
||||||
<div className="border-l h-8 border-gray-200" />
|
|
||||||
<button
|
|
||||||
className="cursor-pointer w-9 h-9 flex items-center justify-center"
|
|
||||||
onClick={handleDelete}
|
|
||||||
>
|
|
||||||
<Trash2Icon className="w-4 h-4 text-red-600" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<MapsetInfoSection mapset={mapset} />
|
|
||||||
<MapsetOrganizationSection mapset={mapset} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<MapsetMetadataSection mapset={mapset} />
|
|
||||||
<MapsetClassificationSection mapset={mapset} />
|
|
||||||
<MapsetVersionSection mapset={mapset} />
|
|
||||||
<div className="relative h-66">
|
|
||||||
<PreviewMap isActiveControl={true} mapset={mapset} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{canEdit && featureFlags.mapsetHistory.isActive && (
|
|
||||||
<div className="mt-6 border border-zinc-200 rounded-lg px-6 p-4">
|
|
||||||
<MapsetHistory id={id} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mapset && (
|
|
||||||
<VerifyMapsetDialog
|
|
||||||
mapset={mapset}
|
|
||||||
isLoading={updateStatusMutation.isPending}
|
|
||||||
onAction={handleVerificationAction}
|
|
||||||
onCancel={() => setIsVerifyDialogOpen(false)}
|
|
||||||
open={isVerifyDialogOpen}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DeleteDialog
|
|
||||||
name={mapset.name}
|
|
||||||
isDeleting={deleteMutation.isPending}
|
|
||||||
onDelete={handleDeleteConfirm}
|
|
||||||
onCancel={() => setIsDeleteDialogOpen(false)}
|
|
||||||
open={isDeleteDialogOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import historyApi from "@/shared/services/history";
|
|
||||||
import { History } from "@/shared/types/history";
|
|
||||||
import { StatusValidationBadge } from "@/shared/components/status-validation-badge";
|
|
||||||
import { formatIndonesianDate } from "@/shared/utils/date";
|
|
||||||
|
|
||||||
export default function MapsetHistory({ id }: { id: string }) {
|
|
||||||
const { data: historyData, isLoading } = useQuery({
|
|
||||||
queryKey: ["histories"],
|
|
||||||
queryFn: () => historyApi.getHistories({ filter: [`mapset_id=${id}`] }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<h2 className="text-lg font-semibold mb-4">
|
|
||||||
Catatan Verifikasi/Validasi Mapset
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<table className="w-full border-separate border-spacing-0 border border-zinc-200 rounded-lg text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2 text-left font-medium border-b border-zinc-200 w-[200px]">
|
|
||||||
Tanggal
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-2 text-left font-medium border-b border-zinc-200 w-[200px]">
|
|
||||||
User
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-2 text-left font-medium border-b border-zinc-200 w-[200px]">
|
|
||||||
Aksi
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-2 text-left font-medium border-b border-zinc-200 min-w-[300px]">
|
|
||||||
Catatan
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{historyData?.items.map((history: History) => (
|
|
||||||
<tr key={history.id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-4 py-2 border-b border-zinc-200 whitespace-nowrap">
|
|
||||||
{formatIndonesianDate(history.timestamp)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 border-b border-zinc-200 whitespace-nowrap">
|
|
||||||
{history.user.name}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 border-b border-zinc-200 whitespace-nowrap">
|
|
||||||
<StatusValidationBadge
|
|
||||||
status={
|
|
||||||
history.validation_type as
|
|
||||||
| "approved"
|
|
||||||
| "on_verification"
|
|
||||||
| "rejected"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 border-b border-zinc-200">
|
|
||||||
{history.notes || "-"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { layerTypeLabel } from "@/shared/config/layer-type";
|
|
||||||
import { Mapset } from "@/shared/types/mapset";
|
|
||||||
|
|
||||||
export default function MapsetInfoSection({ mapset }: { mapset: Mapset }) {
|
|
||||||
return (
|
|
||||||
<div className="p-2 border rounded-[6px] border-zinc-200">
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<span className="text-lg font-semibold text-zinc-950">
|
|
||||||
Informasi Mapset
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950">Nama Mapset</div>
|
|
||||||
<div className="text-sm text-zinc-800">{mapset.name}</div>
|
|
||||||
</div>
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950">Deskripsi</div>
|
|
||||||
<div className="text-sm text-zinc-800">{mapset.description}</div>
|
|
||||||
</div>
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950">Skala</div>
|
|
||||||
<div className="text-sm text-zinc-800">{mapset.scale}</div>
|
|
||||||
</div>
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950">Sistem Proyeksi</div>
|
|
||||||
<div className="text-sm text-zinc-800">
|
|
||||||
{mapset.projection_system.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{mapset.layer_type && (
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950">Tipe Layer</div>
|
|
||||||
<div className="text-sm text-zinc-800">
|
|
||||||
{layerTypeLabel[mapset.layer_type]}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950">Kategori</div>
|
|
||||||
<div className="text-sm text-zinc-800">{mapset.category.name}</div>
|
|
||||||
</div>
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950">Klasifikasi</div>
|
|
||||||
<div className="text-sm text-zinc-800">
|
|
||||||
{mapset.classification.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950">Status Data</div>
|
|
||||||
<div className="text-sm text-zinc-800">{mapset.data_status}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
import { Mapset } from "@/shared/types/mapset";
|
|
||||||
import { ExternalLink } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default function MapsetMetadataSection({ mapset }: { mapset: Mapset }) {
|
|
||||||
const geoserverSource = mapset.sources.find(
|
|
||||||
(source) => source.credential.credential_type === "geoserver"
|
|
||||||
);
|
|
||||||
|
|
||||||
const geonetworkSource = mapset.sources.find(
|
|
||||||
(source) => source.credential.credential_type === "geonetwork"
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-2 border rounded-[6px] border-zinc-200">
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<span className="text-lg font-semibold text-zinc-950">Metadata</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Map Server Section */}
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950">
|
|
||||||
Map Server Terkait
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-zinc-800 flex items-center gap-2">
|
|
||||||
{geoserverSource ? geoserverSource.name : "Lainnya"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Layer URL */}
|
|
||||||
{mapset.layer_url && (
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950 mb-1">
|
|
||||||
Tautan Layer
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-zinc-800">
|
|
||||||
<Link
|
|
||||||
href={mapset.layer_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
<div className="inline-flex text-sm items-center space-x-1 bg-slate-100 p-1 rounded">
|
|
||||||
<div>View Layer</div>
|
|
||||||
<ExternalLink width={14} height={14} />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Metadata Server Section */}
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950">
|
|
||||||
Metadata Server Terkait
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-zinc-800 flex items-center gap-2">
|
|
||||||
{geonetworkSource ? geonetworkSource.name : "Lainnya"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Metadata URL */}
|
|
||||||
{mapset.metadata_url && (
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950 mb-1">
|
|
||||||
Tautan Metadata
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-zinc-800">
|
|
||||||
<Link
|
|
||||||
href={mapset.metadata_url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
<div className="inline-flex text-sm items-center space-x-1 bg-slate-100 p-1 rounded">
|
|
||||||
<div>View Metadata</div>
|
|
||||||
<ExternalLink width={14} height={14} />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import { Mapset } from "@/shared/types/mapset";
|
|
||||||
|
|
||||||
export default function MapsetOrganizationSection({
|
|
||||||
mapset,
|
|
||||||
}: {
|
|
||||||
mapset: Mapset;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="p-2 border rounded-[6px] border-zinc-200">
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<span className="text-lg font-semibold text-zinc-950">
|
|
||||||
Informasi Organisasi
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950">Nama Organisasi</div>
|
|
||||||
<div className="text-sm text-zinc-800">{mapset.producer.name}</div>
|
|
||||||
</div>
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950">
|
|
||||||
No. Telp Organisasi
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-zinc-800">
|
|
||||||
{mapset.producer.phone_number}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950">Skala</div>
|
|
||||||
<div className="text-sm text-zinc-800">{mapset.scale}</div>
|
|
||||||
</div>
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950">Sistem Proyeksi</div>
|
|
||||||
<div className="text-sm text-zinc-800">
|
|
||||||
{mapset.projection_system.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950">Kategori</div>
|
|
||||||
<div className="text-sm text-zinc-800">{mapset.category.name}</div>
|
|
||||||
</div>
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950">Klasifikasi</div>
|
|
||||||
<div className="text-sm text-zinc-800">
|
|
||||||
{mapset.classification.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="py-2 px-4">
|
|
||||||
<div className="text-sm font-medium text-zinc-950">Status Data</div>
|
|
||||||
<div className="text-sm text-zinc-800">{mapset.data_status}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user