Initial commit on FE
This commit is contained in:
commit
1528c9fc20
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# 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
.prettierrc
Normal file
1
.prettierrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{ "plugins": ["prettier-plugin-tailwindcss"] }
|
||||||
57
Dockerfile
Normal file
57
Dockerfile
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# ------------------------------------
|
||||||
|
# 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"]
|
||||||
36
README.md
Normal file
36
README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
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).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
app/(modules)/(landing)/components/catalog-section/index.tsx
Normal file
98
app/(modules)/(landing)/components/catalog-section/index.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
299
app/(modules)/(landing)/components/feedback/feedback-form.tsx
Normal file
299
app/(modules)/(landing)/components/feedback/feedback-form.tsx
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
"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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
app/(modules)/(landing)/components/feedback/index.tsx
Normal file
61
app/(modules)/(landing)/components/feedback/index.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
app/(modules)/(landing)/components/hero-section/index.tsx
Normal file
35
app/(modules)/(landing)/components/hero-section/index.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
app/(modules)/(landing)/components/mapset-card.tsx
Normal file
46
app/(modules)/(landing)/components/mapset-card.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
app/(modules)/(landing)/components/news-section/index.tsx
Normal file
114
app/(modules)/(landing)/components/news-section/index.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
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> */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
"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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
"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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
app/(modules)/(landing)/components/statistic-section.tsx
Normal file
79
app/(modules)/(landing)/components/statistic-section.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
app/(modules)/admin/_components/confirmation-dialog.tsx
Normal file
64
app/(modules)/admin/_components/confirmation-dialog.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
// 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
232
app/(modules)/admin/_components/data-table.tsx
Normal file
232
app/(modules)/admin/_components/data-table.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
app/(modules)/admin/_components/delete-dialog.tsx
Normal file
54
app/(modules)/admin/_components/delete-dialog.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
32
app/(modules)/admin/_components/detail-item.tsx
Normal file
32
app/(modules)/admin/_components/detail-item.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
app/(modules)/admin/_components/empty-state.tsx
Normal file
26
app/(modules)/admin/_components/empty-state.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
app/(modules)/admin/_components/page-header.tsx
Normal file
22
app/(modules)/admin/_components/page-header.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
app/(modules)/admin/_components/refdata-bootstrap.tsx
Normal file
55
app/(modules)/admin/_components/refdata-bootstrap.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"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;
|
||||||
|
}
|
||||||
158
app/(modules)/admin/_components/resource-table.tsx
Normal file
158
app/(modules)/admin/_components/resource-table.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
app/(modules)/admin/_components/search-action-bar.tsx
Normal file
226
app/(modules)/admin/_components/search-action-bar.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
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;
|
||||||
15
app/(modules)/admin/_components/search-input.tsx
Normal file
15
app/(modules)/admin/_components/search-input.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
app/(modules)/admin/_components/sidebar.tsx
Normal file
246
app/(modules)/admin/_components/sidebar.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
"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,
|
||||||
|
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: "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;
|
||||||
10
app/(modules)/admin/_hooks/use-tab.tsx
Normal file
10
app/(modules)/admin/_hooks/use-tab.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
export function useTabState() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const currentTab = searchParams.get("tab") || "all";
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentTab,
|
||||||
|
};
|
||||||
|
}
|
||||||
205
app/(modules)/admin/_hooks/use-table-state.tsx
Normal file
205
app/(modules)/admin/_hooks/use-table-state.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
"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,
|
||||||
|
};
|
||||||
|
}
|
||||||
224
app/(modules)/admin/category/_components/column.tsx
Normal file
224
app/(modules)/admin/category/_components/column.tsx
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
/* 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;
|
||||||
|
};
|
||||||
48
app/(modules)/admin/category/_components/detail.tsx
Normal file
48
app/(modules)/admin/category/_components/detail.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
app/(modules)/admin/category/_components/form.tsx
Normal file
140
app/(modules)/admin/category/_components/form.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
app/(modules)/admin/category/_hooks/use-category-form.tsx
Normal file
60
app/(modules)/admin/category/_hooks/use-category-form.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"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,
|
||||||
|
};
|
||||||
|
}
|
||||||
31
app/(modules)/admin/category/add/page.client.tsx
Normal file
31
app/(modules)/admin/category/add/page.client.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
app/(modules)/admin/category/add/page.tsx
Normal file
16
app/(modules)/admin/category/add/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
app/(modules)/admin/category/detail/[id]/page.tsx
Normal file
16
app/(modules)/admin/category/detail/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
app/(modules)/admin/category/edit/[id]/page.client.tsx
Normal file
41
app/(modules)/admin/category/edit/[id]/page.client.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
app/(modules)/admin/category/edit/[id]/page.tsx
Normal file
20
app/(modules)/admin/category/edit/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
app/(modules)/admin/category/page.client.tsx
Normal file
63
app/(modules)/admin/category/page.client.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
"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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
app/(modules)/admin/category/page.tsx
Normal file
22
app/(modules)/admin/category/page.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
app/(modules)/admin/credential/_components/column.tsx
Normal file
221
app/(modules)/admin/credential/_components/column.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
/* 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;
|
||||||
|
};
|
||||||
262
app/(modules)/admin/credential/_components/form.tsx
Normal file
262
app/(modules)/admin/credential/_components/form.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
"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,
|
||||||
|
};
|
||||||
|
}
|
||||||
31
app/(modules)/admin/credential/add/page.client.tsx
Normal file
31
app/(modules)/admin/credential/add/page.client.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
app/(modules)/admin/credential/add/page.tsx
Normal file
16
app/(modules)/admin/credential/add/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
app/(modules)/admin/credential/edit/[id]/page.client.tsx
Normal file
41
app/(modules)/admin/credential/edit/[id]/page.client.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
app/(modules)/admin/credential/edit/[id]/page.tsx
Normal file
20
app/(modules)/admin/credential/edit/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
app/(modules)/admin/credential/page.client.tsx
Normal file
63
app/(modules)/admin/credential/page.client.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
"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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
app/(modules)/admin/credential/page.tsx
Normal file
22
app/(modules)/admin/credential/page.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
app/(modules)/admin/dashboard/page.tsx
Normal file
232
app/(modules)/admin/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
"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;
|
||||||
43
app/(modules)/admin/layout.tsx
Normal file
43
app/(modules)/admin/layout.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"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;
|
||||||
229
app/(modules)/admin/map-source/_components/column.tsx
Normal file
229
app/(modules)/admin/map-source/_components/column.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
/* 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;
|
||||||
|
};
|
||||||
48
app/(modules)/admin/map-source/_components/detail.tsx
Normal file
48
app/(modules)/admin/map-source/_components/detail.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
app/(modules)/admin/map-source/_components/form.tsx
Normal file
178
app/(modules)/admin/map-source/_components/form.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
app/(modules)/admin/map-source/_hooks/use-form.tsx
Normal file
59
app/(modules)/admin/map-source/_hooks/use-form.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"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,
|
||||||
|
};
|
||||||
|
}
|
||||||
31
app/(modules)/admin/map-source/add/page.client.tsx
Normal file
31
app/(modules)/admin/map-source/add/page.client.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
app/(modules)/admin/map-source/add/page.tsx
Normal file
16
app/(modules)/admin/map-source/add/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
app/(modules)/admin/map-source/detail/[id]/page.tsx
Normal file
16
app/(modules)/admin/map-source/detail/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
app/(modules)/admin/map-source/edit/[id]/page.client.tsx
Normal file
40
app/(modules)/admin/map-source/edit/[id]/page.client.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
app/(modules)/admin/map-source/edit/[id]/page.tsx
Normal file
20
app/(modules)/admin/map-source/edit/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
app/(modules)/admin/map-source/page.client.tsx
Normal file
66
app/(modules)/admin/map-source/page.client.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
app/(modules)/admin/map-source/page.tsx
Normal file
16
app/(modules)/admin/map-source/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
app/(modules)/admin/map-source/state.ts
Normal file
14
app/(modules)/admin/map-source/state.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
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 };
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
288
app/(modules)/admin/mapset/_components/detail/mapset-detail.tsx
Normal file
288
app/(modules)/admin/mapset/_components/detail/mapset-detail.tsx
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import StatusValidation from "@/shared/config/status-validation";
|
||||||
|
import { Mapset } from "@/shared/types/mapset";
|
||||||
|
import { Check, LoaderCircle, XIcon } from "lucide-react";
|
||||||
|
import { JSX } from "react";
|
||||||
|
|
||||||
|
export function MapsetStatus({ mapset }: { mapset: Mapset }) {
|
||||||
|
const statusConfig: Record<
|
||||||
|
StatusValidation,
|
||||||
|
{ name: string; color: string; icon: JSX.Element }
|
||||||
|
> = {
|
||||||
|
[StatusValidation.APPROVED]: {
|
||||||
|
name: "Tervalidasi",
|
||||||
|
color: "text-green-800",
|
||||||
|
icon: <Check />,
|
||||||
|
},
|
||||||
|
[StatusValidation.ON_VERIFICATION]: {
|
||||||
|
name: "Menunggu Validasi",
|
||||||
|
color: "text-yellow-700",
|
||||||
|
icon: <LoaderCircle className="h-4" />,
|
||||||
|
},
|
||||||
|
[StatusValidation.REJECTED]: {
|
||||||
|
name: "Mapset Ditolak",
|
||||||
|
color: "text-red-800",
|
||||||
|
icon: <XIcon />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config =
|
||||||
|
statusConfig[
|
||||||
|
(mapset.status_validation ??
|
||||||
|
StatusValidation.ON_VERIFICATION) as StatusValidation
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1 ${config.color} px-2 py-1 rounded-md`}
|
||||||
|
>
|
||||||
|
<span>{config.icon}</span>
|
||||||
|
<span>{config.name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { Mapset } from "@/shared/types/mapset";
|
||||||
|
|
||||||
|
export default function MapsetVersionSection({ 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">Versi</span>
|
||||||
|
</div>
|
||||||
|
<div className="py-2 px-4">
|
||||||
|
<div className="text-sm font-medium text-zinc-950">
|
||||||
|
Periode Update Data
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-zinc-800">
|
||||||
|
{mapset.data_update_period ?? "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="py-2 px-4">
|
||||||
|
<div className="text-sm font-medium text-zinc-950">
|
||||||
|
Edisi/Versi Data
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-zinc-800">
|
||||||
|
{mapset.data_version ?? "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
// components/mapset-classification-form.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { coverageOptions } from "@/shared/config/coverage";
|
||||||
|
|
||||||
|
const classificationSchema = z.object({
|
||||||
|
coverage_level: z.string().optional(),
|
||||||
|
coverage_area: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ClassificationFormValues = z.infer<typeof classificationSchema>;
|
||||||
|
|
||||||
|
interface MapsetClassificationFormProps {
|
||||||
|
initialData: Partial<ClassificationFormValues>;
|
||||||
|
onSubmit: (data: ClassificationFormValues) => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapsetClassificationForm({
|
||||||
|
initialData,
|
||||||
|
onSubmit,
|
||||||
|
onPrevious,
|
||||||
|
}: MapsetClassificationFormProps) {
|
||||||
|
const form = useForm<ClassificationFormValues>({
|
||||||
|
resolver: zodResolver(classificationSchema),
|
||||||
|
defaultValues: {
|
||||||
|
coverage_level: initialData?.coverage_level || "",
|
||||||
|
coverage_area: initialData?.coverage_area || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
coverage_level: initialData?.coverage_level || "",
|
||||||
|
coverage_area: initialData?.coverage_area || "",
|
||||||
|
});
|
||||||
|
}, [initialData, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 p-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="coverage_level"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Tingkat Penyajian Wilayah</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Pilih tingkat wilayah" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{coverageOptions.map((option) => (
|
||||||
|
<SelectItem key={option.id} value={option.id}>
|
||||||
|
{option.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Pilih tingkat detail penyajian data wilayah.
|
||||||
|
</p>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="coverage_area"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Cakupan Wilayah</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Pilih cakupan wilayah" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{coverageOptions.map((option) => (
|
||||||
|
<SelectItem key={option.id} value={option.id}>
|
||||||
|
{option.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Pilih cakupan wilayah data secara administratif.
|
||||||
|
</p>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex space-x-4 pt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-zinc-200 text-zinc-950"
|
||||||
|
onClick={onPrevious}
|
||||||
|
>
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{form.formState.isSubmitting ? "Menyimpan..." : "Lanjutkan"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
310
app/(modules)/admin/mapset/_components/form/mapset-info-form.tsx
Normal file
310
app/(modules)/admin/mapset/_components/form/mapset-info-form.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
|
||||||
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { Switch } from "@/shared/components/ui/switch";
|
||||||
|
import { layerTypeOptions } from "@/shared/config/layer-type";
|
||||||
|
|
||||||
|
// Validation schema
|
||||||
|
const mapsetInfoSchema = z.object({
|
||||||
|
name: z.string().min(3, "Judul mapset minimal 3 karakter"),
|
||||||
|
description: z.string().min(10, "Deskripsi terlalu pendek"),
|
||||||
|
is_popular: z.boolean(),
|
||||||
|
scale: z.string().min(1, "Skala harus diisi"),
|
||||||
|
projection_system_id: z.string().min(1, "Sistem proyeksi harus dipilih"),
|
||||||
|
category_id: z.string().min(1, "Kategori harus dipilih"),
|
||||||
|
layer_type: z.string().optional().nullable(),
|
||||||
|
classification_id: z.string().min(1, "Klasifikasi harus dipilih"),
|
||||||
|
organization_id: z.string().min(1, "Organisasi harus dipilih"),
|
||||||
|
data_status: z.enum(["sementara", "tetap"], {
|
||||||
|
required_error: "Status data harus dipilih",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type MapsetInfoFormValues = z.infer<typeof mapsetInfoSchema>;
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapsetInfoFormProps {
|
||||||
|
initialData: Partial<MapsetInfoFormValues>;
|
||||||
|
projectionSystems: SelectOption[];
|
||||||
|
categories: SelectOption[];
|
||||||
|
classifications: SelectOption[];
|
||||||
|
organizations: SelectOption[];
|
||||||
|
isOrgFieldDisabled?: boolean;
|
||||||
|
onSubmit: (data: MapsetInfoFormValues) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapsetInfoForm({ initialData, projectionSystems, categories, classifications, organizations, isOrgFieldDisabled, onSubmit }: MapsetInfoFormProps) {
|
||||||
|
const form = useForm<MapsetInfoFormValues>({
|
||||||
|
resolver: zodResolver(mapsetInfoSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: initialData.name || "",
|
||||||
|
description: initialData.description || "",
|
||||||
|
is_popular: initialData.is_popular || false,
|
||||||
|
scale: initialData.scale || "",
|
||||||
|
projection_system_id: initialData.projection_system_id || "",
|
||||||
|
category_id: initialData.category_id || "",
|
||||||
|
classification_id: initialData.classification_id || "",
|
||||||
|
organization_id: initialData.organization_id || "",
|
||||||
|
data_status: initialData.data_status || "sementara",
|
||||||
|
layer_type: initialData.layer_type || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset when data or option lists change so Selects can render the correct labels
|
||||||
|
form.reset(initialData);
|
||||||
|
}, [initialData, projectionSystems, categories, classifications, organizations, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 p-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Judul Mapset <span className="text-red-500">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Masukkan judul mapset" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Deskripsi<span className="text-red-500">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea placeholder="Tuliskan penjelasan lengkap mengenai isi dan tujuan mapset." {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="scale"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Skala<span className="text-red-500">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Contoh: 1:25.000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sistem Proyeksi */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="projection_system_id"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Sistem Proyeksi<span className="text-red-500">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select key={`ps-${projectionSystems.length}-${field.value ?? ""}`} value={field.value || undefined} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Pilih sistem proyeksi" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{projectionSystems.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Kategori */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="category_id"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Kategori<span className="text-red-500">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select key={`cat-${categories.length}-${field.value ?? ""}`} value={field.value || undefined} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Pilih kategori" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Klasifikasi */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="classification_id"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Klasifikasi<span className="text-red-500">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select key={`cls-${classifications.length}-${field.value ?? ""}`} value={field.value || undefined} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Pilih klasifikasi" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{classifications?.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="layer_type"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Tipe Layer</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select key={`lt-${layerTypeOptions.length}-${field.value ?? ""}`} value={field.value || undefined} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Pilih tipe layer" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{layerTypeOptions.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Organisasi */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="organization_id"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Organisasi</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select key={`org-${organizations.length}-${field.value ?? ""}`} value={field.value || undefined} onValueChange={field.onChange} disabled={isOrgFieldDisabled}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Pilih organisasi" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{organizations.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Status Data */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="data_status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Status Data</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroup onValueChange={field.onChange} defaultValue={field.value} className="flex flex-col space-y-2">
|
||||||
|
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroupItem value="sementara" />
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="font-normal">Sementara</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroupItem value="tetap" />
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="font-normal">Tetap</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="is_popular"
|
||||||
|
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">Mapset Populer</FormLabel>
|
||||||
|
<div className="text-sm text-muted-foreground">Aktifkan untuk menandai mapset ini sebagai populer.</div>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex">
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{form.formState.isSubmitting ? "Menyimpan..." : "Lanjutkan"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,290 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/shared/components/ui/form";
|
||||||
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { fetchWMSLayersFromSourceId } from "@/shared/services/map-layer";
|
||||||
|
// import { fetchGeoNetworkLayersFromSourceId } from "@/shared/services/metadata-url"; // No longer needed
|
||||||
|
|
||||||
|
const metadataSchema = z.object({
|
||||||
|
source_id: z.string().nullable(),
|
||||||
|
layer_url: z.string().min(1, "Link Map Server harus diisi"),
|
||||||
|
metadata_source_id: z.string().nullable(),
|
||||||
|
metadata_url: z.string().min(1, "Link Metadata Server harus diisi"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type MetadataFormValues = z.infer<typeof metadataSchema>;
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapsetMetadataFormProps {
|
||||||
|
initialData: Partial<MetadataFormValues>;
|
||||||
|
mapSources: SelectOption[];
|
||||||
|
onSubmit: (data: MetadataFormValues) => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayerOption {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapsetMetadataForm({ initialData, mapSources, onSubmit, onPrevious }: MapsetMetadataFormProps) {
|
||||||
|
const form = useForm<MetadataFormValues>({
|
||||||
|
resolver: zodResolver(metadataSchema),
|
||||||
|
defaultValues: {
|
||||||
|
source_id: initialData.source_id || "",
|
||||||
|
layer_url: initialData.layer_url || "",
|
||||||
|
metadata_source_id: initialData.metadata_source_id || "",
|
||||||
|
metadata_url: initialData.metadata_url || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const sourceId = useWatch({ control: form.control, name: "source_id" });
|
||||||
|
// const metadataSourceId = useWatch({
|
||||||
|
// control: form.control,
|
||||||
|
// name: "metadata_source_id",
|
||||||
|
// }); // No longer needed
|
||||||
|
|
||||||
|
const [layerOptions, setLayerOptions] = useState<LayerOption[]>([]);
|
||||||
|
// const [metadataOptions, setMetadataOptions] = useState<LayerOption[]>([]); // No longer needed
|
||||||
|
|
||||||
|
const [loadingLayers, setLoadingLayers] = useState(false);
|
||||||
|
// const [loadingMetadata, setLoadingMetadata] = useState(false); // No longer needed
|
||||||
|
|
||||||
|
const mapSourcesWithOthers = [...mapSources, { id: "lainnya", name: "Lainnya" }];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timerId = setTimeout(() => {
|
||||||
|
setDebouncedSearchQuery(searchQuery);
|
||||||
|
if (searchInputRef.current && document.activeElement === searchInputRef.current) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(timerId);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const filteredLayerOptions = useMemo(() => {
|
||||||
|
if (!debouncedSearchQuery) return layerOptions;
|
||||||
|
const query = debouncedSearchQuery.toLowerCase();
|
||||||
|
return layerOptions.filter((layer) => layer.name.toLowerCase().includes(query));
|
||||||
|
}, [debouncedSearchQuery, layerOptions]);
|
||||||
|
|
||||||
|
// const filteredMetadataOptions = useMemo(() => {
|
||||||
|
// if (!debouncedSearchQuery) return metadataOptions;
|
||||||
|
// const query = debouncedSearchQuery.toLowerCase();
|
||||||
|
// return metadataOptions.filter((meta) =>
|
||||||
|
// meta.name.toLowerCase().includes(query)
|
||||||
|
// );
|
||||||
|
// }, [debouncedSearchQuery, metadataOptions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLayers = async () => {
|
||||||
|
if (!sourceId || sourceId === "lainnya") {
|
||||||
|
setLayerOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingLayers(true);
|
||||||
|
try {
|
||||||
|
const layers = await fetchWMSLayersFromSourceId(sourceId);
|
||||||
|
setLayerOptions(layers);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch layers:", error);
|
||||||
|
setLayerOptions([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingLayers(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadLayers();
|
||||||
|
}, [sourceId]);
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// const loadMetadata = async () => {
|
||||||
|
// if (!metadataSourceId || metadataSourceId === "lainnya") {
|
||||||
|
// setMetadataOptions([]);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // setLoadingMetadata(true); // No longer needed
|
||||||
|
// try {
|
||||||
|
// const metadata = await fetchGeoNetworkLayersFromSourceId(
|
||||||
|
// metadataSourceId
|
||||||
|
// );
|
||||||
|
//
|
||||||
|
// setMetadataOptions(metadata);
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error("Failed to fetch metadata:", error);
|
||||||
|
// setMetadataOptions([]);
|
||||||
|
// } finally {
|
||||||
|
// // setLoadingMetadata(false); // No longer needed
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
// loadMetadata();
|
||||||
|
// }, [metadataSourceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset(initialData);
|
||||||
|
}, [initialData, mapSources, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 p-6">
|
||||||
|
{/* MAP SERVER */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="source_id"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Pilih MapServer<span className="text-red-500">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select key={`ms-${mapSources.length}-${field.value ?? ""}`} value={field.value ?? undefined} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Pilih server untuk menyimpan data" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{mapSourcesWithOthers.map((source) => (
|
||||||
|
<SelectItem key={source.id} value={source.id}>
|
||||||
|
{source.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* LAYER URL */}
|
||||||
|
{sourceId === "lainnya" ? (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="layer_url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
URL Layer<span className="text-red-500">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Masukkan URL layer secara manual" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="layer_url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Pilih Layer<span className="text-red-500">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
key={`ly-${filteredLayerOptions.length}-${field.value ?? ""}`}
|
||||||
|
value={field.value || undefined}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
disabled={loadingLayers}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setSearchQuery("");
|
||||||
|
else setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder={loadingLayers ? "Memuat layer..." : "Pilih layer dari server"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="min-w-[300px]">
|
||||||
|
<div className="p-2 sticky top-0 bg-background z-10">
|
||||||
|
<Input
|
||||||
|
placeholder="Cari layer..."
|
||||||
|
value={searchQuery}
|
||||||
|
ref={searchInputRef}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Escape" && (e.target as HTMLInputElement).blur()}
|
||||||
|
className="mb-2"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{loadingLayers ? (
|
||||||
|
<div className="p-4 text-center">
|
||||||
|
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : filteredLayerOptions.length > 0 ? (
|
||||||
|
filteredLayerOptions.map((layer) => (
|
||||||
|
<SelectItem key={layer.url} value={layer.url}>
|
||||||
|
{layer.name}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="p-2 text-center text-sm text-muted-foreground">Tidak ada layer yang sesuai</div>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* METADATA SERVER */}
|
||||||
|
{/* Hide the metadata server select and set default to 'lainnya' */}
|
||||||
|
<input type="hidden" value="lainnya" {...form.register("metadata_source_id")} />
|
||||||
|
|
||||||
|
{/* METADATA URL */}
|
||||||
|
{true ? (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata_url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
URL Metadata<span className="text-red-500">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Masukkan URL metadata" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* BUTTONS */}
|
||||||
|
<div className="flex space-x-4 pt-4">
|
||||||
|
<Button type="button" variant="secondary" className="bg-zinc-200 text-zinc-950" onClick={onPrevious}>
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{form.formState.isSubmitting ? "Menyimpan..." : "Lanjutkan"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
// components/mapset-organization-form.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Skema validasi untuk form organisasi
|
||||||
|
const organizationSchema = z.object({
|
||||||
|
organization_id: z.string().min(1, "Nama organisasi harus dipilih"),
|
||||||
|
phone_number: z.string().min(1, "Nomor telepon harus diisi"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type OrganizationFormValues = z.infer<typeof organizationSchema>;
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapsetOrganizationFormProps {
|
||||||
|
initialData: Partial<OrganizationFormValues>;
|
||||||
|
organizations: SelectOption[];
|
||||||
|
onSubmit: (data: OrganizationFormValues) => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MapsetOrganizationForm = ({
|
||||||
|
initialData,
|
||||||
|
organizations,
|
||||||
|
onSubmit,
|
||||||
|
onPrevious,
|
||||||
|
}: MapsetOrganizationFormProps) => {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<OrganizationFormValues>({
|
||||||
|
resolver: zodResolver(organizationSchema),
|
||||||
|
defaultValues: {
|
||||||
|
organization_id: initialData.organization_id || "",
|
||||||
|
phone_number: initialData.phone_number || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFormSubmit = (data: OrganizationFormValues) => {
|
||||||
|
onSubmit(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-6 space-y-6">
|
||||||
|
{/* Nama Organisasi */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="organization_id" className="text-sm font-medium">
|
||||||
|
Nama Organisasi<span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="organization_id"
|
||||||
|
{...register("organization_id")}
|
||||||
|
className="w-full p-2 border rounded-md"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
Pilih organisasi yang berkontribusi atas data ini
|
||||||
|
</option>
|
||||||
|
{organizations.map((org) => (
|
||||||
|
<option key={org.id} value={org.id}>
|
||||||
|
{org.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors.organization_id && (
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
{errors.organization_id.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* No Telepon Organisasi */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="phone_number" className="text-sm font-medium">
|
||||||
|
No Telpon Organisasi<span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="phone_number"
|
||||||
|
type="text"
|
||||||
|
{...register("phone_number")}
|
||||||
|
className="w-full p-2 border rounded-md"
|
||||||
|
placeholder="Masukkan nomor kontak resmi organisasi"
|
||||||
|
/>
|
||||||
|
{errors.phone_number && (
|
||||||
|
<p className="text-sm text-red-500">{errors.phone_number.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Buttons */}
|
||||||
|
<div className="flex space-x-4 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPrevious}
|
||||||
|
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Kembali
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:bg-blue-300"
|
||||||
|
>
|
||||||
|
Lanjutkan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
102
app/(modules)/admin/mapset/_components/form/mapset-tab.tsx
Normal file
102
app/(modules)/admin/mapset/_components/form/mapset-tab.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { cn } from "@/shared/utils/utils";
|
||||||
|
import { Circle, CircleCheck } from "lucide-react";
|
||||||
|
import { MapsetFormState, MapsetFormTab } from "../../state";
|
||||||
|
|
||||||
|
interface TabButtonProps {
|
||||||
|
isActive: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
isCompleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabButton = ({
|
||||||
|
isActive,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
isCompleted,
|
||||||
|
}: TabButtonProps) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center px-4 py-2 border-b-2 focus:outline-none relative",
|
||||||
|
isActive
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCompleted ? (
|
||||||
|
<span className="mr-2 text-primary">
|
||||||
|
<CircleCheck className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="mr-2 text-zinc-400">
|
||||||
|
<Circle />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MapsetTab({
|
||||||
|
formState,
|
||||||
|
activeTab,
|
||||||
|
handleTabChange,
|
||||||
|
}: // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
any) {
|
||||||
|
const isTabComplete = (tabKey: keyof MapsetFormState): boolean => {
|
||||||
|
const data = formState[tabKey];
|
||||||
|
|
||||||
|
switch (tabKey) {
|
||||||
|
case "info":
|
||||||
|
return Boolean(
|
||||||
|
data.name &&
|
||||||
|
data.projection_system_id &&
|
||||||
|
data.category_id &&
|
||||||
|
data.classification_id
|
||||||
|
);
|
||||||
|
case "metadata":
|
||||||
|
return Boolean(data.metadata_url && data.layer_url);
|
||||||
|
case "classification":
|
||||||
|
return Boolean(data.coverage_area && data.coverage_level);
|
||||||
|
case "version":
|
||||||
|
return Boolean(data.data_update_period && data.data_version);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-gray-200 bg-zinc-50 text-sm">
|
||||||
|
<div className="flex space-x-1">
|
||||||
|
<TabButton
|
||||||
|
isActive={activeTab === MapsetFormTab.INFO}
|
||||||
|
onClick={() => handleTabChange(MapsetFormTab.INFO)}
|
||||||
|
label="Informasi Mapset"
|
||||||
|
isCompleted={isTabComplete("info")} // Tandai tab sudah diisi
|
||||||
|
/>
|
||||||
|
<TabButton
|
||||||
|
isActive={activeTab === MapsetFormTab.METADATA}
|
||||||
|
onClick={() => handleTabChange(MapsetFormTab.METADATA)}
|
||||||
|
label="Metadata"
|
||||||
|
isCompleted={isTabComplete("metadata")}
|
||||||
|
/>
|
||||||
|
<TabButton
|
||||||
|
isActive={activeTab === MapsetFormTab.CLASSIFICATION}
|
||||||
|
onClick={() => handleTabChange(MapsetFormTab.CLASSIFICATION)}
|
||||||
|
label="Klasifikasi"
|
||||||
|
isCompleted={isTabComplete("classification")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TabButton
|
||||||
|
isActive={activeTab === MapsetFormTab.VERSION}
|
||||||
|
onClick={() => handleTabChange(MapsetFormTab.VERSION)}
|
||||||
|
label="Informasi Versi"
|
||||||
|
isCompleted={isTabComplete("version")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
// components/mapset-version-form.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/shared/components/ui/form";
|
||||||
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
const versionSchema = z.object({
|
||||||
|
data_update_period: z.string().min(1, "Periode update data harus diisi"),
|
||||||
|
data_version: z.string().min(1, "Edisi/Versi data harus diisi"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type VersionFormValues = z.infer<typeof versionSchema>;
|
||||||
|
|
||||||
|
interface MapsetVersionFormProps {
|
||||||
|
initialData: Partial<VersionFormValues>;
|
||||||
|
onSubmit: (data: VersionFormValues) => void;
|
||||||
|
onPrevious: () => void;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onDataChange: (data: VersionFormValues) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapsetVersionForm({ initialData, onSubmit, onPrevious, isSubmitting, onDataChange }: MapsetVersionFormProps) {
|
||||||
|
const form = useForm<VersionFormValues>({
|
||||||
|
resolver: zodResolver(versionSchema),
|
||||||
|
defaultValues: {
|
||||||
|
data_update_period: initialData.data_update_period || "",
|
||||||
|
data_version: initialData.data_version || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
data_update_period: initialData.data_update_period || "",
|
||||||
|
data_version: initialData.data_version || "",
|
||||||
|
});
|
||||||
|
}, [initialData, form]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = form.watch((value) => {
|
||||||
|
onDataChange(value as VersionFormValues);
|
||||||
|
});
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, [form, onDataChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 p-6">
|
||||||
|
{/* Periode Update Data */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="data_update_period"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Periode Update Data<span className="text-red-500">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Triwulanan, Tahunan, atau periode tertentu lainnya" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<p className="text-xs text-gray-500">Contoh: Triwulanan, Tahunan, atau periode tertentu lainnya.</p>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edisi/Versi Data */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="data_version"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Edisi/Versi Data<span className="text-red-500">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Tuliskan versi atau edisi data" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<p className="text-xs text-gray-500">Tuliskan versi atau edisi data.</p>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Navigation Buttons */}
|
||||||
|
<div className="flex space-x-4 pt-4">
|
||||||
|
<Button type="button" variant="secondary" className="bg-zinc-200 text-zinc-950" onClick={onPrevious}>
|
||||||
|
Kembali
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{isSubmitting ? "Menyimpan..." : "Simpan"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
264
app/(modules)/admin/mapset/_components/list/column.tsx
Normal file
264
app/(modules)/admin/mapset/_components/list/column.tsx
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
import { Button } from "@/shared/components/ds/button";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/shared/components/ui/dropdown-menu";
|
||||||
|
import { Mapset } from "@/shared/types/mapset";
|
||||||
|
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 { DeleteDialog } from "../../../_components/delete-dialog";
|
||||||
|
import mapsetApi from "@/shared/services/mapset";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { hasPermission } from "@/shared/config/role";
|
||||||
|
import { useAuthSession } from "@/shared/hooks/use-session";
|
||||||
|
import { ConfirmationDialog } from "../../../_components/confirmation-dialog";
|
||||||
|
import StatusValidation, { statusValidationLabel } from "@/shared/config/status-validation";
|
||||||
|
|
||||||
|
// Type for column configuration
|
||||||
|
interface ColumnConfig {
|
||||||
|
id: string;
|
||||||
|
header: string;
|
||||||
|
accessor?: keyof Mapset;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
accessorFn?: (row: Mapset) => any;
|
||||||
|
sortable?: boolean;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
cell?: (value: any) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default column configurations
|
||||||
|
const COLUMN_CONFIGS: ColumnConfig[] = [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
header: "Nama Mapset",
|
||||||
|
accessor: "name",
|
||||||
|
sortable: true,
|
||||||
|
cell: (value) => (
|
||||||
|
<div className="truncate max-w-[300px] 2xl:max-w-[500px]" title={value as string}>
|
||||||
|
{value as string}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "classification",
|
||||||
|
header: "Klasifikasi",
|
||||||
|
accessorFn: (row) => row.classification?.name,
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "producer",
|
||||||
|
header: "Instansi",
|
||||||
|
accessorFn: (row) => row.producer?.name,
|
||||||
|
sortable: false,
|
||||||
|
cell: (value) => (
|
||||||
|
<div className="truncate max-w-[250px] 2xl:max-w-[300px]" title={value as string}>
|
||||||
|
{value as string}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "is_active",
|
||||||
|
header: "Status Aktif",
|
||||||
|
accessor: "is_active",
|
||||||
|
sortable: true,
|
||||||
|
cell: (value) => (value ? "Aktif" : "Non-aktif"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status_validation",
|
||||||
|
accessor: "status_validation",
|
||||||
|
header: "Status Validasi",
|
||||||
|
cell: (value: StatusValidation) => statusValidationLabel[value],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const useMapsetColumns = (): ColumnDef<Mapset>[] => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [mapsetToDelete, setMapsetToDelete] = useState<Mapset | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { session } = useAuthSession();
|
||||||
|
const [mapsetToSubmit, setMapsetToSubmit] = useState<Mapset | null>(null);
|
||||||
|
const [mapsetToToggle, setMapsetToToggle] = useState<Mapset | null>(null);
|
||||||
|
|
||||||
|
const userRole = session?.user?.role;
|
||||||
|
const userOrganizationId = session?.user?.organizationId;
|
||||||
|
const isDataManager = session?.user?.role?.name === "data_manager";
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
return await mapsetApi.deleteMapset(id);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Berhasil menghapus data");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mapsets"] });
|
||||||
|
setMapsetToDelete(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Gagal menghapus data");
|
||||||
|
console.error("Error deleting mapset:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitValidationMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
return await mapsetApi.updateMapset(id, {
|
||||||
|
status_validation: "on_verification",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Berhasil mengajukan validasi");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mapsets"] });
|
||||||
|
setMapsetToSubmit(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Gagal mengajukan validasi");
|
||||||
|
console.error("Error submitting for validation:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleActiveMutation = useMutation({
|
||||||
|
mutationFn: async ({ id, is_active }: { id: string; is_active: boolean }) => {
|
||||||
|
return await mapsetApi.updateMapset(id, { is_active });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Berhasil mengubah status aktif");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mapsets"] });
|
||||||
|
setMapsetToToggle(null);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Gagal mengubah status aktif");
|
||||||
|
console.error("Error toggling active status:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to render sortable header
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const renderSortableHeader = (column: any, label: string) => (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="p-0 hover:bg-transparent flex items-center">
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate base columns from config
|
||||||
|
const baseColumns = COLUMN_CONFIGS.map((config) => {
|
||||||
|
const column: ColumnDef<Mapset> = {
|
||||||
|
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, "mapset", "read") || hasPermission(userRole, "mapset", "update") || hasPermission(userRole, "mapset", "delete"))) {
|
||||||
|
baseColumns.push({
|
||||||
|
id: "actions",
|
||||||
|
enableHiding: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const mapset = row.original;
|
||||||
|
const canManageMapset = !isDataManager || mapset.producer?.id === userOrganizationId;
|
||||||
|
|
||||||
|
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, "mapset", "read") && (
|
||||||
|
<DropdownMenuItem onClick={() => router.push(`/admin/mapset/detail/${mapset.id}`)} className="flex items-center gap-2">
|
||||||
|
Lihat Detail
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{hasPermission(userRole, "mapset", "update") && canManageMapset && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem onClick={() => router.push(`/admin/mapset/edit/${mapset.id}`)} className="flex items-center gap-2">
|
||||||
|
Edit Mapset
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{userRole.name !== "data_viewer" && (
|
||||||
|
<DropdownMenuItem onClick={() => setMapsetToToggle(mapset)} className="flex items-center gap-2">
|
||||||
|
{mapset.is_active ? "Nonaktifkan" : "Aktifkan"} Mapset
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{hasPermission(userRole, "mapset", "update") && mapset.status_validation === "rejected" && canManageMapset && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => setMapsetToSubmit(mapset)} className="flex items-center gap-2 text-warning focus:text-warning">
|
||||||
|
Ajukan Validasi
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{hasPermission(userRole, "mapset", "delete") && canManageMapset && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => setMapsetToDelete(mapset)} className="flex items-center gap-2 text-destructive focus:text-destructive">
|
||||||
|
Hapus Mapset
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{mapsetToDelete?.id === mapset.id && (
|
||||||
|
<DeleteDialog name={mapsetToDelete?.name} isDeleting={deleteMutation.isPending} onDelete={() => deleteMutation.mutate(mapsetToDelete.id)} onCancel={() => setMapsetToDelete(null)} open={true} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mapsetToSubmit?.id === mapset.id && (
|
||||||
|
<ConfirmationDialog
|
||||||
|
open={mapsetToSubmit?.id === mapset.id}
|
||||||
|
title="Ajukan Validasi"
|
||||||
|
description={`Ajukan mapset "${mapset.name}" ke status validasi?`}
|
||||||
|
confirmText="Ajukan"
|
||||||
|
isLoading={submitValidationMutation.isPending}
|
||||||
|
onConfirm={() => submitValidationMutation.mutate(mapset.id)}
|
||||||
|
onCancel={() => setMapsetToSubmit(null)}
|
||||||
|
variant="primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mapsetToToggle?.id === mapset.id && (
|
||||||
|
<ConfirmationDialog
|
||||||
|
open={mapsetToToggle?.id === mapset.id}
|
||||||
|
title={mapset.is_active ? "Nonaktifkan Mapset" : "Aktifkan Mapset"}
|
||||||
|
description={`Apakah Anda yakin ingin ${mapset.is_active ? "menonaktifkan" : "mengaktifkan"} mapset "${mapset.name}"?`}
|
||||||
|
confirmText={mapset.is_active ? "Nonaktifkan" : "Aktifkan"}
|
||||||
|
isLoading={toggleActiveMutation.isPending}
|
||||||
|
onConfirm={() =>
|
||||||
|
toggleActiveMutation.mutate({
|
||||||
|
id: mapset.id,
|
||||||
|
is_active: !mapset.is_active,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onCancel={() => setMapsetToToggle(null)}
|
||||||
|
variant={mapset.is_active ? "destructive" : "primary"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseColumns;
|
||||||
|
};
|
||||||
197
app/(modules)/admin/mapset/_components/list/filter-drawer.tsx
Normal file
197
app/(modules)/admin/mapset/_components/list/filter-drawer.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
// app/(dashboard)/manajemen-peta/components/filter-drawer.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
} from "@/shared/components/ui/sheet";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/shared/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
|
||||||
|
const FilterSchema = z.object({
|
||||||
|
status: z.string().optional(),
|
||||||
|
klasifikasi: z.string().optional(),
|
||||||
|
instansi: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FilterValues = z.infer<typeof FilterSchema>;
|
||||||
|
|
||||||
|
interface FilterDrawerProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onFilter: (filters: Record<string, string>) => void;
|
||||||
|
currentFilters: {
|
||||||
|
filter: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterDrawer({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onFilter,
|
||||||
|
currentFilters,
|
||||||
|
}: FilterDrawerProps) {
|
||||||
|
// Parse current filters
|
||||||
|
const parseCurrentFilters = (): FilterValues => {
|
||||||
|
try {
|
||||||
|
if (!currentFilters.filter) return {};
|
||||||
|
return JSON.parse(currentFilters.filter) as FilterValues;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm<FilterValues>({
|
||||||
|
resolver: zodResolver(FilterSchema),
|
||||||
|
defaultValues: parseCurrentFilters(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (values: FilterValues) => {
|
||||||
|
// Filter out empty values
|
||||||
|
const nonEmptyValues = Object.fromEntries(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
Object.entries(values).filter(([_, v]) => v && v.trim() !== "")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create filter string
|
||||||
|
const filterStr = Object.keys(nonEmptyValues).length
|
||||||
|
? JSON.stringify(nonEmptyValues)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
onFilter({ filter: filterStr });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
form.reset({});
|
||||||
|
onFilter({ filter: "" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={isOpen} onOpenChange={onClose}>
|
||||||
|
<SheetContent>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Filter Data</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
Atur filter untuk menampilkan data sesuai kebutuhan.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<div className="py-4">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Status</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Pilih status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Aktif">Aktif</SelectItem>
|
||||||
|
<SelectItem value="Non-aktif">Non-aktif</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="klasifikasi"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Klasifikasi</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Pilih klasifikasi" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Publik">Publik</SelectItem>
|
||||||
|
<SelectItem value="Internal">Internal</SelectItem>
|
||||||
|
<SelectItem value="Rahasia">Rahasia</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="instansi"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Instansi</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Pilih instansi" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Kementerian">Kementerian</SelectItem>
|
||||||
|
<SelectItem value="Pemerintah Daerah">
|
||||||
|
Pemerintah Daerah
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="BUMN">BUMN</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SheetFooter className="gap-2 sm:space-x-0">
|
||||||
|
<Button variant="outline" type="button" onClick={handleReset}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">Terapkan Filter</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { cn } from "@/shared/utils/utils";
|
||||||
|
|
||||||
|
interface TabItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
filters: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabNavigationProps {
|
||||||
|
activeTab: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabNavigation({ activeTab }: TabNavigationProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
// const { data: session } = useSession();
|
||||||
|
|
||||||
|
// const roleName = session?.user?.role?.name;
|
||||||
|
// const organizationId = session?.user?.organizationId;
|
||||||
|
// const shouldAddProducerFilter = roleName === "data_viewer" || roleName === "data_manager";
|
||||||
|
|
||||||
|
const tabs: TabItem[] = [
|
||||||
|
{
|
||||||
|
id: "all",
|
||||||
|
label: "Semua",
|
||||||
|
filters: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "active",
|
||||||
|
label: "Aktif",
|
||||||
|
filters: ['["is_active=true"]', '["status_validation=approved"]'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "verification",
|
||||||
|
label: "Verifikasi & Validasi",
|
||||||
|
filters: ['["is_active=true"]', '["status_validation=on_verification", "status_validation=rejected"]'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "inactive",
|
||||||
|
label: "Non Aktif",
|
||||||
|
filters: ['["is_active=false"]'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(tab: TabItem) => {
|
||||||
|
const newParams = new URLSearchParams(searchParams.toString());
|
||||||
|
|
||||||
|
// Reset pagination
|
||||||
|
newParams.set("offset", "0");
|
||||||
|
|
||||||
|
// Set active tab
|
||||||
|
newParams.set("tab", tab.id);
|
||||||
|
|
||||||
|
// Inject filter
|
||||||
|
newParams.delete("filter");
|
||||||
|
|
||||||
|
const combinedFilters = [...tab.filters];
|
||||||
|
|
||||||
|
// if (shouldAddProducerFilter && organizationId) {
|
||||||
|
// combinedFilters.push(`["producer_id=${organizationId}"]`);
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (combinedFilters.length > 0) {
|
||||||
|
const filterValue = combinedFilters.toString();
|
||||||
|
newParams.set("filter", `[${filterValue}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`?${newParams.toString()}`);
|
||||||
|
},
|
||||||
|
[router, searchParams]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex space-x-1 border-b mb-4">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => handleTabChange(tab)}
|
||||||
|
className={cn("px-4 py-2 text-sm font-medium transition-colors", "hover:text-primary focus:outline-none", tab.id === activeTab ? "border-b-2 border-primary text-primary" : "text-muted-foreground")}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/shared/components/ds/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/shared/components/ui/dialog";
|
||||||
|
import { Mapset } from "@/shared/types/mapset";
|
||||||
|
|
||||||
|
type VerificationAction = "approve" | "reject";
|
||||||
|
|
||||||
|
interface VerifyMapsetDialogProps {
|
||||||
|
mapset: Mapset;
|
||||||
|
isLoading: boolean;
|
||||||
|
onAction: (action: VerificationAction, note: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
open: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VerifyMapsetDialog = ({
|
||||||
|
mapset,
|
||||||
|
isLoading,
|
||||||
|
onAction,
|
||||||
|
onCancel,
|
||||||
|
open,
|
||||||
|
}: VerifyMapsetDialogProps) => {
|
||||||
|
const [note, setNote] = useState<string>("");
|
||||||
|
|
||||||
|
const handleNoteChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setNote(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onCancel()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Verifikasi Mapset</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Verifikasi mapset "{mapset.name}". Pilih untuk menyetujui
|
||||||
|
atau menolak mapset ini.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label
|
||||||
|
htmlFor="notes"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Catatan
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
value={note}
|
||||||
|
onChange={handleNoteChange}
|
||||||
|
className="mt-2 w-full p-2 border border-gray-300 rounded-md"
|
||||||
|
placeholder="Masukkan catatan Anda di sini..."
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="flex flex-row justify-end gap-2 sm:justify-end">
|
||||||
|
<Button variant="outline" onClick={onCancel} disabled={isLoading}>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => onAction("reject", note)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? "Memproses..." : "Tolak"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => onAction("approve", note)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? "Memproses..." : "Setujui"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
257
app/(modules)/admin/mapset/add/page.client.tsx
Normal file
257
app/(modules)/admin/mapset/add/page.client.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import categoryApi from "@/shared/services/category";
|
||||||
|
import classificationApi from "@/shared/services/classification";
|
||||||
|
import mapProjectionSystemApi from "@/shared/services/map-projection-system";
|
||||||
|
import { MapsetInfoForm } from "../_components/form/mapset-info-form";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import organizationApi from "@/shared/services/organization";
|
||||||
|
import mapSourceApi from "@/shared/services/map-source";
|
||||||
|
import { MapsetMetadataForm } from "../_components/form/mapset-metadata-form";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { MapsetVersionForm } from "../_components/form/mapset-version-form";
|
||||||
|
import mapsetApi from "@/shared/services/mapset";
|
||||||
|
import { MapsetSubmitPayload } from "@/shared/types/mapset";
|
||||||
|
import { PaginatedResponse } from "@/shared/types/api-response";
|
||||||
|
import MapsetTab from "../_components/form/mapset-tab";
|
||||||
|
import StatusValidation from "@/shared/config/status-validation";
|
||||||
|
import { activeTabAtom, initialFormState, mapsetFormAtom, MapsetFormState, MapsetFormTab } from "../state";
|
||||||
|
import { MapsetClassificationForm } from "../_components/form/mapset-classification-form";
|
||||||
|
import { useAuthSession } from "@/shared/hooks/use-session";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddMapsPageClient() {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [activeTab, setActiveTab] = useAtom(activeTabAtom);
|
||||||
|
const [formState, setFormState] = useAtom(mapsetFormAtom);
|
||||||
|
|
||||||
|
const { session } = useAuthSession();
|
||||||
|
const isDataManager = session?.user?.role?.name === "data_manager";
|
||||||
|
const userOrganizationId = session?.user?.organizationId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFormState(initialFormState);
|
||||||
|
setActiveTab(MapsetFormTab.INFO);
|
||||||
|
}, [setFormState, setActiveTab]);
|
||||||
|
|
||||||
|
// Add query to fetch organization details
|
||||||
|
const { data: userOrganization } = useQuery({
|
||||||
|
queryKey: ["user-organization", userOrganizationId],
|
||||||
|
queryFn: () => organizationApi.getOrganizationById(userOrganizationId || ""),
|
||||||
|
enabled: !!userOrganizationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDiskominfo = userOrganization?.name === "Dinas Komunikasi dan Informatika";
|
||||||
|
|
||||||
|
// Update the initial form state with organization ID for data managers
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDataManager && !isDiskominfo && userOrganizationId) {
|
||||||
|
setFormState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
info: {
|
||||||
|
...prev.info,
|
||||||
|
organization_id: userOrganizationId,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isDataManager, !isDiskominfo, userOrganizationId, setFormState]);
|
||||||
|
|
||||||
|
const { data: projectionSystemsResponse, isLoading: isLoadingProjections } = useQuery({
|
||||||
|
queryKey: ["projectionSystems"],
|
||||||
|
queryFn: () => mapProjectionSystemApi.getMapProjectionSystems(),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
enabled: !!session?.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: categoriesResponse, isLoading: isLoadingCategories } = useQuery({
|
||||||
|
queryKey: ["categories"],
|
||||||
|
queryFn: () => categoryApi.getCategories(),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
enabled: !!session?.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: classificationsResponse, isLoading: isLoadingClassifications } = useQuery({
|
||||||
|
queryKey: ["classifications"],
|
||||||
|
queryFn: () => classificationApi.getClassifications(),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
enabled: !!session?.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: organizationsResponse, isLoading: isLoadingOrganizations } = useQuery({
|
||||||
|
queryKey: ["organizations", { isDataManager, userOrganizationId, isDiskominfo }],
|
||||||
|
queryFn: () =>
|
||||||
|
organizationApi.getOrganizations({
|
||||||
|
filter: isDataManager && userOrganizationId && !isDiskominfo ? [`id=${userOrganizationId}`] : undefined,
|
||||||
|
}),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
enabled: !!session?.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: mapSourcesResponse, isLoading: isLoadingMapSources } = useQuery({
|
||||||
|
queryKey: ["map-sources"],
|
||||||
|
queryFn: () => mapSourceApi.getMapSources(),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
enabled: !!session?.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = isLoadingProjections || isLoadingCategories || isLoadingClassifications || isLoadingOrganizations || isLoadingMapSources;
|
||||||
|
|
||||||
|
// Ekstrak dan transformasi data untuk dikirim ke form
|
||||||
|
const mapDataToOptions = <T extends { id: string; name: string }>(response: PaginatedResponse<T[]> | undefined): SelectOption[] => {
|
||||||
|
if (!response || !response.items) return [];
|
||||||
|
return response.items.map((item) => ({ id: item.id, name: item.name }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectionSystemOptions = mapDataToOptions(projectionSystemsResponse);
|
||||||
|
const categoryOptions = mapDataToOptions(categoriesResponse);
|
||||||
|
const classificationOptions = mapDataToOptions(classificationsResponse);
|
||||||
|
const organizationOptions = mapDataToOptions(organizationsResponse);
|
||||||
|
const mapSourceOptions = mapDataToOptions(mapSourcesResponse);
|
||||||
|
|
||||||
|
// Handler untuk update form data
|
||||||
|
const updateFormData = (tabKey: keyof MapsetFormState, data: any) => {
|
||||||
|
setFormState({
|
||||||
|
...formState,
|
||||||
|
[tabKey]: data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContinue = () => {
|
||||||
|
if (activeTab < MapsetFormTab.VERSION) {
|
||||||
|
setActiveTab((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevious = () => {
|
||||||
|
if (activeTab > MapsetFormTab.INFO) {
|
||||||
|
setActiveTab(activeTab - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitMapsetMutation = useMutation({
|
||||||
|
mutationFn: (mapsetData: Omit<MapsetSubmitPayload, "id">) => {
|
||||||
|
return mapsetApi.createMapset(mapsetData);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mapsets"] });
|
||||||
|
setFormState(initialFormState);
|
||||||
|
setActiveTab(MapsetFormTab.INFO);
|
||||||
|
toast.success("Mapset berhasil disimpan!");
|
||||||
|
router.push("/admin/mapset");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error("Error creating mapset:", error);
|
||||||
|
toast.error(error.message || "Terjadi kesalahan saat menyimpan data");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmitMapset = (versionData: { data_update_period: string; data_version: string }) => {
|
||||||
|
const payload: MapsetSubmitPayload = {
|
||||||
|
name: formState.info.name,
|
||||||
|
description: formState.info.description,
|
||||||
|
scale: formState.info.scale,
|
||||||
|
projection_system_id: formState.info.projection_system_id,
|
||||||
|
category_id: formState.info.category_id,
|
||||||
|
data_status: formState.info.data_status,
|
||||||
|
classification_id: formState.info.classification_id,
|
||||||
|
producer_id: formState.info.organization_id,
|
||||||
|
layer_type: formState.info.layer_type,
|
||||||
|
source_id: [formState.metadata.source_id].filter((id) => id !== "lainnya" && id !== null) as string[],
|
||||||
|
layer_url: formState.metadata.layer_url,
|
||||||
|
metadata_url: formState.metadata.metadata_url,
|
||||||
|
coverage_level: formState.classification.coverage_level,
|
||||||
|
coverage_area: formState.classification.coverage_area,
|
||||||
|
data_update_period: versionData.data_update_period,
|
||||||
|
data_version: versionData.data_version,
|
||||||
|
is_popular: false,
|
||||||
|
is_active: true,
|
||||||
|
regional_id: "01968b53-a910-7a67-bd10-975b8923b92e",
|
||||||
|
notes: "Mapset baru dibuat",
|
||||||
|
status_validation: StatusValidation.ON_VERIFICATION,
|
||||||
|
};
|
||||||
|
|
||||||
|
submitMapsetMutation.mutate(payload);
|
||||||
|
};
|
||||||
|
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="pb-4">
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<MapsetTab formState={formState} activeTab={activeTab} handleTabChange={(e: number) => setActiveTab(e)} />
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div className="max-w-xl">
|
||||||
|
{activeTab === MapsetFormTab.INFO && (
|
||||||
|
<MapsetInfoForm
|
||||||
|
initialData={formState.info}
|
||||||
|
projectionSystems={projectionSystemOptions}
|
||||||
|
categories={categoryOptions}
|
||||||
|
classifications={classificationOptions}
|
||||||
|
organizations={organizationOptions}
|
||||||
|
onSubmit={(data) => {
|
||||||
|
updateFormData("info", data);
|
||||||
|
handleContinue();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === MapsetFormTab.METADATA && (
|
||||||
|
<MapsetMetadataForm
|
||||||
|
initialData={formState.metadata}
|
||||||
|
mapSources={mapSourceOptions}
|
||||||
|
onSubmit={(data) => {
|
||||||
|
updateFormData("metadata", data);
|
||||||
|
handleContinue();
|
||||||
|
}}
|
||||||
|
onPrevious={handlePrevious}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === MapsetFormTab.CLASSIFICATION && (
|
||||||
|
<MapsetClassificationForm
|
||||||
|
initialData={formState.classification}
|
||||||
|
onSubmit={(data) => {
|
||||||
|
updateFormData("classification", data);
|
||||||
|
handleContinue();
|
||||||
|
}}
|
||||||
|
onPrevious={handlePrevious}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === MapsetFormTab.VERSION && (
|
||||||
|
<MapsetVersionForm
|
||||||
|
initialData={formState.version}
|
||||||
|
onSubmit={handleSubmitMapset}
|
||||||
|
onPrevious={handlePrevious}
|
||||||
|
isSubmitting={submitMapsetMutation.isPending}
|
||||||
|
onDataChange={(data) => {
|
||||||
|
updateFormData("version", data);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
app/(modules)/admin/mapset/add/page.tsx
Normal file
20
app/(modules)/admin/mapset/add/page.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import PageHeader from "../../_components/page-header";
|
||||||
|
import AddMapsPageClient from "./page.client";
|
||||||
|
|
||||||
|
export default function AddMapsPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Tambah Mapset dan Metadata"
|
||||||
|
className="bg-zinc-50"
|
||||||
|
description="Tambah mapset dan metadata untuk memperbarui data geospasial di Satu Peta."
|
||||||
|
/>
|
||||||
|
<Suspense>
|
||||||
|
<Suspense fallback={<div>Memuat form...</div>}>
|
||||||
|
<AddMapsPageClient />
|
||||||
|
</Suspense>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
app/(modules)/admin/mapset/detail/[id]/page.tsx
Normal file
14
app/(modules)/admin/mapset/detail/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MapsetDetail } from "../../_components/detail/mapset-detail";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
|
export default function MapsetDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<MapsetDetail id={params.id?.toString() ?? ""} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
298
app/(modules)/admin/mapset/edit/[id]/page.tsx
Normal file
298
app/(modules)/admin/mapset/edit/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
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 mapsetApi from "@/shared/services/mapset";
|
||||||
|
|
||||||
|
import { MapsetInfoForm } from "../../_components/form/mapset-info-form";
|
||||||
|
import { MapsetMetadataForm } from "../../_components/form/mapset-metadata-form";
|
||||||
|
import { MapsetVersionForm } from "../../_components/form/mapset-version-form";
|
||||||
|
import MapsetTab from "../../_components/form/mapset-tab";
|
||||||
|
|
||||||
|
import { PaginatedResponse } from "@/shared/types/api-response";
|
||||||
|
import { MapsetSubmitPayload } from "@/shared/types/mapset";
|
||||||
|
import StatusValidation from "@/shared/config/status-validation";
|
||||||
|
import { activeTabAtom, mapsetFormAtom, MapsetFormState, MapsetFormTab } from "../../state";
|
||||||
|
import PageHeader from "../../../_components/page-header";
|
||||||
|
import { MapsetClassificationForm } from "../../_components/form/mapset-classification-form";
|
||||||
|
import { useAuthSession } from "@/shared/hooks/use-session";
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditMapsPageClient() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const mapsetId = params.id as string;
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [activeTab, setActiveTab] = useAtom(activeTabAtom);
|
||||||
|
const [formState, setFormState] = useAtom(mapsetFormAtom);
|
||||||
|
|
||||||
|
const { session } = useAuthSession();
|
||||||
|
const isDataManager = session?.user?.role?.name === "data_manager";
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: mapset,
|
||||||
|
isLoading: isLoadingMapset,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["mapset", mapsetId],
|
||||||
|
queryFn: () => mapsetApi.getMapsetById(mapsetId),
|
||||||
|
enabled: !!mapsetId && !!session?.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapsetId && session?.user?.role?.name) {
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
}, [mapsetId, session, refetch]);
|
||||||
|
|
||||||
|
const { data: projectionSystemsResponse, isLoading: isLoadingProjections } = useQuery({
|
||||||
|
queryKey: ["projectionSystems"],
|
||||||
|
queryFn: () => mapProjectionSystemApi.getMapProjectionSystems(),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
enabled: !!session?.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: categoriesResponse, isLoading: isLoadingCategories } = useQuery({
|
||||||
|
queryKey: ["categories"],
|
||||||
|
queryFn: () => categoryApi.getCategories(),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
enabled: !!session?.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: classificationsResponse, isLoading: isLoadingClassifications } = useQuery({
|
||||||
|
queryKey: ["classifications"],
|
||||||
|
queryFn: () => classificationApi.getClassifications(),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
enabled: !!session?.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: organizationsResponse, isLoading: isLoadingOrganizations } = useQuery({
|
||||||
|
queryKey: ["organizations"],
|
||||||
|
queryFn: () => organizationApi.getOrganizations(),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
enabled: !!session?.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: mapSourcesResponse, isLoading: isLoadingMapSources } = useQuery({
|
||||||
|
queryKey: ["map-sources"],
|
||||||
|
queryFn: () => mapSourceApi.getMapSources(),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
refetchOnMount: false,
|
||||||
|
enabled: !!session?.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapset) {
|
||||||
|
const geoserverSource = mapset.sources.find((source) => source.url?.includes("geoserver"));
|
||||||
|
|
||||||
|
const geonetworkSource = mapset.sources.find((source) => source.url?.includes("geonetwork"));
|
||||||
|
setFormState({
|
||||||
|
info: {
|
||||||
|
name: mapset.name,
|
||||||
|
description: mapset.description,
|
||||||
|
scale: mapset.scale,
|
||||||
|
projection_system_id: mapset?.projection_system?.id,
|
||||||
|
category_id: mapset?.category?.id,
|
||||||
|
data_status: mapset.data_status,
|
||||||
|
classification_id: mapset?.classification?.id,
|
||||||
|
organization_id: mapset?.producer?.id,
|
||||||
|
is_popular: mapset.is_popular,
|
||||||
|
layer_type: mapset?.layer_type,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
source_id: geoserverSource?.id || null,
|
||||||
|
layer_url: mapset.layer_url,
|
||||||
|
metadata_source_id: geonetworkSource?.id || null,
|
||||||
|
metadata_url: mapset.metadata_url,
|
||||||
|
},
|
||||||
|
classification: {
|
||||||
|
coverage_level: mapset.coverage_level,
|
||||||
|
coverage_area: mapset.coverage_area,
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
data_update_period: mapset.data_update_period,
|
||||||
|
data_version: mapset.data_version,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [mapset, setFormState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session?.user && !mapset && !isLoadingMapset) {
|
||||||
|
const currentUrl = window.location.pathname + window.location.search;
|
||||||
|
router.replace(currentUrl);
|
||||||
|
}
|
||||||
|
}, [session, mapset, isLoadingMapset, router]);
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
isLoadingMapset ||
|
||||||
|
isLoadingProjections ||
|
||||||
|
isLoadingCategories ||
|
||||||
|
isLoadingClassifications ||
|
||||||
|
isLoadingOrganizations ||
|
||||||
|
isLoadingMapSources ||
|
||||||
|
!projectionSystemsResponse ||
|
||||||
|
!categoriesResponse ||
|
||||||
|
!classificationsResponse ||
|
||||||
|
!organizationsResponse ||
|
||||||
|
!mapSourcesResponse;
|
||||||
|
|
||||||
|
const mapDataToOptions = <T extends { id: string; name: string }>(response: PaginatedResponse<T[]> | undefined): SelectOption[] => {
|
||||||
|
if (!response || !response.items) return [];
|
||||||
|
return response.items.map((item) => ({ id: item.id, name: item.name }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectionSystemOptions = mapDataToOptions(projectionSystemsResponse);
|
||||||
|
const categoryOptions = mapDataToOptions(categoriesResponse);
|
||||||
|
const classificationOptions = mapDataToOptions(classificationsResponse);
|
||||||
|
const organizationOptions = mapDataToOptions(organizationsResponse);
|
||||||
|
const mapSourceOptions = mapDataToOptions(mapSourcesResponse);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const updateFormData = (tabKey: keyof MapsetFormState, data: any) => {
|
||||||
|
setFormState({
|
||||||
|
...formState,
|
||||||
|
[tabKey]: data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContinue = () => {
|
||||||
|
if (activeTab < MapsetFormTab.VERSION) {
|
||||||
|
setActiveTab((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevious = () => {
|
||||||
|
if (activeTab > MapsetFormTab.INFO) {
|
||||||
|
setActiveTab(activeTab - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMapsetMutation = useMutation({
|
||||||
|
mutationFn: (mapsetData: MapsetSubmitPayload) => {
|
||||||
|
return mapsetApi.updateMapset(mapsetId, mapsetData);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mapsets"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mapset", mapsetId] });
|
||||||
|
toast.success("Mapset berhasil diperbarui!");
|
||||||
|
router.push("/admin/mapset");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || "Terjadi kesalahan saat memperbarui data");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmitMapset = (versionData: { data_update_period: string; data_version: string }) => {
|
||||||
|
updateFormData("version", versionData);
|
||||||
|
|
||||||
|
const payload: MapsetSubmitPayload = {
|
||||||
|
name: formState.info.name,
|
||||||
|
description: formState.info.description,
|
||||||
|
scale: formState.info.scale,
|
||||||
|
projection_system_id: formState.info.projection_system_id,
|
||||||
|
category_id: formState.info.category_id,
|
||||||
|
data_status: formState.info.data_status,
|
||||||
|
classification_id: formState.info.classification_id,
|
||||||
|
producer_id: formState.info.organization_id,
|
||||||
|
layer_type: formState.info.layer_type,
|
||||||
|
source_id: [formState.metadata.source_id, formState.metadata.metadata_source_id].filter((id) => id !== "lainnya" && id !== null) as string[],
|
||||||
|
layer_url: formState.metadata.layer_url,
|
||||||
|
metadata_url: formState.metadata.metadata_url,
|
||||||
|
coverage_level: formState.classification.coverage_level,
|
||||||
|
coverage_area: formState.classification.coverage_area,
|
||||||
|
data_update_period: versionData.data_update_period,
|
||||||
|
data_version: versionData.data_version,
|
||||||
|
is_popular: formState.info.is_popular || false,
|
||||||
|
is_active: mapset?.is_active || true,
|
||||||
|
regional_id: mapset?.regional.id || "01968b53-a910-7a67-bd10-975b8923b92e",
|
||||||
|
status_validation: mapset?.status_validation || StatusValidation.ON_VERIFICATION,
|
||||||
|
};
|
||||||
|
|
||||||
|
updateMapsetMutation.mutate(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading || !mapset) {
|
||||||
|
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>
|
||||||
|
<PageHeader className="bg-zinc-50" title="Ubah Mapset dan Metadata" description="Ubah mapset dan metadata untuk memperbarui data geospasial di Satu Peta." />
|
||||||
|
<MapsetTab formState={formState} activeTab={activeTab} handleTabChange={(e: number) => setActiveTab(e)} />
|
||||||
|
<div className="max-w-xl">
|
||||||
|
{activeTab === MapsetFormTab.INFO && (
|
||||||
|
<MapsetInfoForm
|
||||||
|
initialData={formState.info}
|
||||||
|
projectionSystems={projectionSystemOptions}
|
||||||
|
categories={categoryOptions}
|
||||||
|
classifications={classificationOptions}
|
||||||
|
organizations={organizationOptions}
|
||||||
|
isOrgFieldDisabled={isDataManager}
|
||||||
|
onSubmit={(data) => {
|
||||||
|
updateFormData("info", data);
|
||||||
|
handleContinue();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === MapsetFormTab.METADATA && (
|
||||||
|
<MapsetMetadataForm
|
||||||
|
initialData={formState.metadata}
|
||||||
|
mapSources={mapSourceOptions}
|
||||||
|
onSubmit={(data) => {
|
||||||
|
updateFormData("metadata", data);
|
||||||
|
handleContinue();
|
||||||
|
}}
|
||||||
|
onPrevious={handlePrevious}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === MapsetFormTab.CLASSIFICATION && (
|
||||||
|
<MapsetClassificationForm
|
||||||
|
initialData={formState.classification}
|
||||||
|
onSubmit={(data) => {
|
||||||
|
updateFormData("classification", data);
|
||||||
|
handleContinue();
|
||||||
|
}}
|
||||||
|
onPrevious={handlePrevious}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === MapsetFormTab.VERSION && (
|
||||||
|
<MapsetVersionForm
|
||||||
|
initialData={formState.version}
|
||||||
|
onSubmit={handleSubmitMapset}
|
||||||
|
onPrevious={handlePrevious}
|
||||||
|
isSubmitting={updateMapsetMutation.isPending}
|
||||||
|
onDataChange={(data) => {
|
||||||
|
updateFormData("version", data);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
app/(modules)/admin/mapset/page.client.tsx
Normal file
151
app/(modules)/admin/mapset/page.client.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Mapset } from "@/shared/types/mapset";
|
||||||
|
import { useMapsetColumns } from "./_components/list/column";
|
||||||
|
import mapsetApi from "@/shared/services/mapset";
|
||||||
|
import { useTableState } from "../_hooks/use-table-state";
|
||||||
|
import { ResourceTable } from "../_components/resource-table";
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { TabNavigation } from "./_components/list/tab-navigation";
|
||||||
|
import { useTabState } from "../_hooks/use-tab";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ConfirmationDialog } from "../_components/confirmation-dialog";
|
||||||
|
import organizationApi from "@/shared/services/organization";
|
||||||
|
import { Organization } from "@/shared/types/organization";
|
||||||
|
import classificationApi from "@/shared/services/classification";
|
||||||
|
import { useAuthSession } from "@/shared/hooks/use-session";
|
||||||
|
|
||||||
|
export default function MapsetPageClient() {
|
||||||
|
const columns = useMapsetColumns();
|
||||||
|
const { currentTab } = useTabState();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedMapsetsForBulk, setSelectedMapsetsForBulk] = useState<Mapset[]>([]);
|
||||||
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
|
const { session } = useAuthSession();
|
||||||
|
const userRole = session?.user?.role;
|
||||||
|
const isDataViewer = userRole?.name === "data_viewer";
|
||||||
|
|
||||||
|
const { data: organizations } = useQuery({
|
||||||
|
queryKey: ["organizations_filter"],
|
||||||
|
queryFn: () => organizationApi.getOrganizations().then((res) => res.items),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: classifications } = useQuery({
|
||||||
|
queryKey: ["classifications_filter"],
|
||||||
|
queryFn: () => classificationApi.getClassifications().then((res) => res.items),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: mapsets,
|
||||||
|
total,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
searchValue,
|
||||||
|
sorting,
|
||||||
|
handleSearchInputChange,
|
||||||
|
handlePaginationChange,
|
||||||
|
updateSortingParams,
|
||||||
|
pageIndex,
|
||||||
|
pageCount,
|
||||||
|
limit,
|
||||||
|
setSorting,
|
||||||
|
} = useTableState<Mapset>({
|
||||||
|
resourceName: "mapsets",
|
||||||
|
fetchAction: mapsetApi.getMapsets,
|
||||||
|
defaultLimit: 10,
|
||||||
|
defaultSort: { id: "created_at", desc: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkDeactivateMutation = useMutation({
|
||||||
|
mutationFn: (mapsetIds: string[]) => mapsetApi.bulkDeactivate(mapsetIds),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Mapset berhasil dinonaktifkan");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["mapsets"] });
|
||||||
|
setShowConfirmDialog(false);
|
||||||
|
setSelectedMapsetsForBulk([]);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Gagal menonaktifkan mapset");
|
||||||
|
setShowConfirmDialog(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleBulkAction = (selectedRows: Mapset[]) => {
|
||||||
|
setSelectedMapsetsForBulk(selectedRows);
|
||||||
|
setShowConfirmDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmBulkAction = () => {
|
||||||
|
const mapsetIds = selectedMapsetsForBulk.map((mapset) => mapset.id);
|
||||||
|
bulkDeactivateMutation.mutate(mapsetIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelBulkAction = () => {
|
||||||
|
setShowConfirmDialog(false);
|
||||||
|
setSelectedMapsetsForBulk([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TabNavigation activeTab={currentTab} />
|
||||||
|
<ResourceTable
|
||||||
|
data={mapsets}
|
||||||
|
columns={columns as ColumnDef<Mapset, unknown>[]}
|
||||||
|
total={total}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
searchValue={searchValue}
|
||||||
|
onSearchChangeAction={handleSearchInputChange}
|
||||||
|
sorting={sorting}
|
||||||
|
enableRowSelection={!isDataViewer}
|
||||||
|
onSortingChangeAction={(newSorting) => {
|
||||||
|
setSorting(newSorting);
|
||||||
|
updateSortingParams(newSorting);
|
||||||
|
}}
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
pageCount={pageCount}
|
||||||
|
pageSize={limit}
|
||||||
|
onPaginationChangeAction={handlePaginationChange}
|
||||||
|
emptyStateProps={{
|
||||||
|
title: "Peta tidak ditemukan",
|
||||||
|
}}
|
||||||
|
actionBarProps={{
|
||||||
|
buttonLabel: "Tambah Peta",
|
||||||
|
buttonLink: isDataViewer ? "" : "/admin/mapset/add",
|
||||||
|
bulkLabel: "Non-Aktif Mapset",
|
||||||
|
showBulkAction: !isDataViewer,
|
||||||
|
onBulkAction: handleBulkAction,
|
||||||
|
}}
|
||||||
|
refetchAction={refetch}
|
||||||
|
filterOptions={[
|
||||||
|
...(classifications?.map((classification) => ({
|
||||||
|
label: classification.name,
|
||||||
|
value: classification.id.toString(),
|
||||||
|
group: "classification_id",
|
||||||
|
groupLabel: "Klasifikasi",
|
||||||
|
})) || []),
|
||||||
|
...(organizations?.map((org: Organization) => ({
|
||||||
|
label: org.name,
|
||||||
|
value: org.id,
|
||||||
|
group: "producer_id",
|
||||||
|
groupLabel: "Organisasi",
|
||||||
|
})) || []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmationDialog
|
||||||
|
open={showConfirmDialog}
|
||||||
|
title="Konfirmasi Nonaktifkan Mapset"
|
||||||
|
description={`Apakah Anda yakin ingin menonaktifkan ${selectedMapsetsForBulk.length} mapset yang dipilih?`}
|
||||||
|
confirmText="Nonaktifkan"
|
||||||
|
variant="destructive"
|
||||||
|
isLoading={bulkDeactivateMutation.isPending}
|
||||||
|
onConfirm={confirmBulkAction}
|
||||||
|
onCancel={cancelBulkAction}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user