From bc0c6438a29a7b5ce355d616377a0fb9ef81a57a Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Thu, 20 Feb 2025 23:50:45 +0700 Subject: [PATCH] fecth dynamic navitem --- sigap-website/actions/dashboard/nav-items.ts | 211 ++++++++++++++++++ .../(admin-pages)/dashboard/page.tsx | 59 +++-- .../app/protected/(admin-pages)/layout.tsx | 30 +++ sigap-website/components/app-sidebar.tsx | 207 +++++++++-------- sigap-website/components/dynamic-icon.tsx | 53 +++++ sigap-website/lib/db.ts | 6 +- sigap-website/package-lock.json | 69 ++++++ sigap-website/package.json | 8 +- .../migration.sql | 106 +++++++++ .../migration.sql | 108 +++++++++ sigap-website/prisma/schema.prisma | 114 +++++++--- sigap-website/prisma/seed.ts | 68 ++++++ .../entities/models/nav-items.model.ts | 77 +++++++ .../repositories/nav-items.repository.ts | 17 ++ ...ct-us.usecase.ts => contact-us.usecase.ts} | 4 +- .../usecases/nav-items.usecase.ts | 33 +++ .../usecases/{signin => }/signin.usecases.ts | 4 +- .../src/infrastructure/hooks/use-nav-items.ts | 1 + .../repositories/nav-items.repository.impl.ts | 54 +++++ 19 files changed, 1058 insertions(+), 171 deletions(-) create mode 100644 sigap-website/actions/dashboard/nav-items.ts create mode 100644 sigap-website/app/protected/(admin-pages)/layout.tsx create mode 100644 sigap-website/components/dynamic-icon.tsx create mode 100644 sigap-website/prisma/migrations/20250220132229_rename_field_to_snake_case/migration.sql create mode 100644 sigap-website/prisma/migrations/20250220134948_set_id_field_to_uuid_and_use_varchar_and_text_to_specific_field/migration.sql create mode 100644 sigap-website/prisma/seed.ts create mode 100644 sigap-website/src/applications/entities/models/nav-items.model.ts create mode 100644 sigap-website/src/applications/repositories/nav-items.repository.ts rename sigap-website/src/applications/usecases/{contact-us/create-contact-us.usecase.ts => contact-us.usecase.ts} (70%) create mode 100644 sigap-website/src/applications/usecases/nav-items.usecase.ts rename sigap-website/src/applications/usecases/{signin => }/signin.usecases.ts (61%) create mode 100644 sigap-website/src/infrastructure/hooks/use-nav-items.ts create mode 100644 sigap-website/src/infrastructure/repositories/nav-items.repository.impl.ts diff --git a/sigap-website/actions/dashboard/nav-items.ts b/sigap-website/actions/dashboard/nav-items.ts new file mode 100644 index 0000000..b12037a --- /dev/null +++ b/sigap-website/actions/dashboard/nav-items.ts @@ -0,0 +1,211 @@ +"use server"; + +import { + NavItems, + navItemsDeleteSchema, + NavItemsInsert, + navItemsInsertSchema, + navItemsUpdateSchema, +} from "@/src/applications/entities/models/nav-items.model"; +import { NavItemsRepository } from "@/src/applications/repositories/nav-items.repository"; +import { NavItemsRepositoryImpl } from "@/src/infrastructure/repositories/nav-items.repository.impl"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +// Initialize repository +const navItemsRepo: NavItemsRepository = new NavItemsRepositoryImpl(); + +/** + * Get all navigation items + */ +export async function getNavItems() { + return await navItemsRepo.getNavItems(); +} + +/** + * Create a new navigation item + */ +export async function createNavItem( + formData: FormData | Record +) { + try { + const data = + formData instanceof FormData + ? Object.fromEntries(formData.entries()) + : formData; + + // Parse and validate input data + const validatedData = navItemsInsertSchema.parse(data); + + // Call repository method (assuming it exists) + const result = await navItemsRepo.createNavItems(validatedData); + + // Revalidate cache if successful + if (result.success) { + revalidatePath("/admin/navigation"); + } + + return result; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + errors: error.errors.reduce( + (acc, curr) => { + acc[curr.path.join(".")] = curr.message; + return acc; + }, + {} as Record + ), + message: "Validation failed", + }; + } + + return { + success: false, + error: "Failed to create navigation item", + message: + error instanceof Error ? error.message : "Unknown error occurred", + }; + } +} + +/** + * Update an existing navigation item + */ +export async function updateNavItem( + id: string, + formData: FormData | Record +) { + try { + const data = + formData instanceof FormData + ? Object.fromEntries(formData.entries()) + : formData; + + // Parse and validate input data + const validatedData = navItemsUpdateSchema.parse(data); + + // Call repository method (assuming it exists) + const result = await navItemsRepo.updateNavItems(id, validatedData); + + // Revalidate cache if successful + if (result.success) { + revalidatePath("/admin/navigation"); + } + + return result; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + errors: error.errors.reduce( + (acc, curr) => { + acc[curr.path.join(".")] = curr.message; + return acc; + }, + {} as Record + ), + message: "Validation failed", + }; + } + + return { + success: false, + error: "Failed to update navigation item", + message: + error instanceof Error ? error.message : "Unknown error occurred", + }; + } +} + +/** + * Delete a navigation item + */ +export async function deleteNavItem(id: string) { + try { + // Parse and validate input + const validatedData = navItemsDeleteSchema.parse({ id }); + + // Call repository method (assuming it exists) + const result = await navItemsRepo.deleteNavItems(validatedData.id); + + // Revalidate cache if successful + if (result.success) { + revalidatePath("/admin/navigation"); + } + + return result; + } catch (error) { + if (error instanceof z.ZodError) { + return { + success: false, + errors: error.errors.reduce( + (acc, curr) => { + acc[curr.path.join(".")] = curr.message; + return acc; + }, + {} as Record + ), + message: "Validation failed", + }; + } + + return { + success: false, + error: "Failed to delete navigation item", + message: + error instanceof Error ? error.message : "Unknown error occurred", + }; + } +} + +// /** +// * Toggle active status of a navigation item +// */ +// export async function toggleNavItemActive(id: string) { +// try { +// // Call repository method (assuming it exists) +// const result = await navItemsRepo.toggleActive(id); + +// // Revalidate cache if successful +// if (result.success) { +// revalidatePath("/admin/navigation"); +// } + +// return result; +// } catch (error) { +// return { +// success: false, +// error: "Failed to toggle navigation item status", +// message: +// error instanceof Error ? error.message : "Unknown error occurred", +// }; +// } +// } + +// /** +// * Update navigation items order +// */ +// export async function updateNavItemsOrder( +// items: { id: string; order_seq: number }[] +// ) { +// try { +// // Call repository method (assuming it exists) +// const result = await navItemsRepo.updateOrder(items); + +// // Revalidate cache if successful +// if (result.success) { +// revalidatePath("/admin/navigation"); +// } + +// return result; +// } catch (error) { +// return { +// success: false, +// error: "Failed to update navigation order", +// message: +// error instanceof Error ? error.message : "Unknown error occurred", +// }; +// } +// } diff --git a/sigap-website/app/protected/(admin-pages)/dashboard/page.tsx b/sigap-website/app/protected/(admin-pages)/dashboard/page.tsx index 4bfe34b..2b29bcc 100644 --- a/sigap-website/app/protected/(admin-pages)/dashboard/page.tsx +++ b/sigap-website/app/protected/(admin-pages)/dashboard/page.tsx @@ -16,37 +16,34 @@ import { export default function Page() { return ( - - - -
-
- - - - - - - Building Your Application - - - - - Data Fetching - - - -
-
-
-
-
-
-
-
-
+ <> +
+
+ + + + + + + Building Your Application + + + + + Data Fetching + + +
- - +
+
+
+
+
+
+
+
+
+ ); } diff --git a/sigap-website/app/protected/(admin-pages)/layout.tsx b/sigap-website/app/protected/(admin-pages)/layout.tsx new file mode 100644 index 0000000..1cf2c61 --- /dev/null +++ b/sigap-website/app/protected/(admin-pages)/layout.tsx @@ -0,0 +1,30 @@ +import { checkSession } from "@/actions/auth/session"; +import { AppSidebar } from "@/components/app-sidebar"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { Separator } from "@/components/ui/separator"; +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar"; +import { redirect } from "next/navigation"; + +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + ); +} diff --git a/sigap-website/components/app-sidebar.tsx b/sigap-website/components/app-sidebar.tsx index 94ad3a0..d3ed682 100644 --- a/sigap-website/components/app-sidebar.tsx +++ b/sigap-website/components/app-sidebar.tsx @@ -1,33 +1,36 @@ -"use client" +"use client"; -import * as React from "react" +import * as React from "react"; +import { useEffect, useState } from "react"; import { AudioWaveform, - BookOpen, - Bot, Command, Frame, GalleryVerticalEnd, - Map, - PieChart, - Settings2, + Loader2, SquareTerminal, -} from "lucide-react" +} from "lucide-react"; -import { NavMain } from "@/components/nav-main" -import { NavProjects } from "@/components/nav-projects" -import { NavUser } from "@/components/nav-user" -import { TeamSwitcher } from "@/components/team-switcher" +import { NavMain } from "@/components/nav-main"; +import { NavProjects } from "@/components/nav-projects"; +import { NavUser } from "@/components/nav-user"; +import { TeamSwitcher } from "@/components/team-switcher"; import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarRail, -} from "@/components/ui/sidebar" +} from "@/components/ui/sidebar"; +import { NavItemsGet } from "@/src/applications/entities/models/nav-items.model"; +import { getNavItems } from "@/actions/dashboard/nav-items"; +import DynamicIcon, { DynamicIconProps } from "./dynamic-icon"; -// This is sample data. -const data = { +// 🔧 Konstanta untuk localStorage key +const NAV_ITEMS_STORAGE_KEY = "navItemsData"; + +// 🎨 Data fallback +const fallbackData = { user: { name: "shadcn", email: "m@example.com", @@ -71,71 +74,7 @@ const data = { }, ], }, - { - title: "Models", - url: "#", - icon: Bot, - items: [ - { - title: "Genesis", - url: "#", - }, - { - title: "Explorer", - url: "#", - }, - { - title: "Quantum", - url: "#", - }, - ], - }, - { - title: "Documentation", - url: "#", - icon: BookOpen, - items: [ - { - title: "Introduction", - url: "#", - }, - { - title: "Get Started", - url: "#", - }, - { - title: "Tutorials", - url: "#", - }, - { - title: "Changelog", - url: "#", - }, - ], - }, - { - title: "Settings", - url: "#", - icon: Settings2, - items: [ - { - title: "General", - url: "#", - }, - { - title: "Team", - url: "#", - }, - { - title: "Billing", - url: "#", - }, - { - title: "Limits", - url: "#", - }, - ], - }, + // additional items... ], projects: [ { @@ -143,33 +82,109 @@ const data = { url: "#", icon: Frame, }, - { - name: "Sales & Marketing", - url: "#", - icon: PieChart, - }, - { - name: "Travel", - url: "#", - icon: Map, - }, + // additional projects... ], -} +}; export function AppSidebar({ ...props }: React.ComponentProps) { + const [navItems, setNavItems] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // 📦 Ambil data dari localStorage + const loadNavItemsFromStorage = (): NavItemsGet | null => { + const storedData = localStorage.getItem(NAV_ITEMS_STORAGE_KEY); + return storedData ? (JSON.parse(storedData) as NavItemsGet) : null; + }; + + // ⚡ Fungsi untuk membandingkan dua objek secara deep + const isDataEqual = (data1: any, data2: any): boolean => + JSON.stringify(data1) === JSON.stringify(data2); + + useEffect(() => { + const fetchAndSyncNavItems = async () => { + try { + const localData = loadNavItemsFromStorage(); + + if (localData) { + setNavItems(localData); + setIsLoading(false); + } + + const response = await getNavItems(); + if (response.success && response.data) { + const fetchedData = response.data as NavItemsGet; + const currentStoredData = loadNavItemsFromStorage(); + + // 🔄 Jika data berbeda, update localStorage dan state + if (!isDataEqual(currentStoredData, fetchedData)) { + localStorage.setItem( + NAV_ITEMS_STORAGE_KEY, + JSON.stringify(fetchedData) + ); + setNavItems(fetchedData); + } + } else { + setError(response.error || "Failed to load navigation items"); + } + } catch (err) { + setError("An unexpected error occurred while fetching navigation"); + console.error(err); + } finally { + setIsLoading(false); + } + }; + + fetchAndSyncNavItems(); + }, []); + + // 🎛️ Transformasi data DB ke format NavMain + const formatNavItems = React.useMemo(() => { + if (!navItems) return fallbackData.navMain; + return navItems.map((item) => ({ + title: item.title, + url: item.url, + icon: item.icon, + isActive: item.is_active, + items: item.sub_items.map((subItem) => ({ + title: subItem.title, + url: subItem.url, + isActive: subItem.is_active, + })), + })); + }, [navItems]); + + // 🧩 Komponen NavMain dinamis dengan ikon + const DynamicNavMain = ({ items }: { items: any[] }) => ( + ({ + ...item, + icon: () => , + }))} + /> + ); + return ( - + - - + {isLoading ? ( +
+ +
+ ) : error ? ( +
{error}
+ ) : ( + + )} +
- +
- ) + ); } diff --git a/sigap-website/components/dynamic-icon.tsx b/sigap-website/components/dynamic-icon.tsx new file mode 100644 index 0000000..8a28ee7 --- /dev/null +++ b/sigap-website/components/dynamic-icon.tsx @@ -0,0 +1,53 @@ +// components/icons/DynamicIcon.tsx +import React from "react"; +import * as TablerIcons from "@tabler/icons-react"; + +export interface DynamicIconProps { + iconName: string; + size?: number; + color?: string; + className?: string; + stroke?: number; +} + +/** + * 🔥 DynamicIcon - Reusable component untuk merender ikon @tabler/icons-react secara dinamis + * @param iconName - Nama ikon (harus sesuai dengan nama ikon dari @tabler/icons-react, contoh: "IconUsers") + * @param size - Ukuran ikon (default: 24) + * @param color - Warna ikon (opsional) + * @param className - CSS class tambahan (opsional) + * @param stroke - Ketebalan garis (opsional, default dari library) + */ +const DynamicIcon: React.FC = ({ + iconName, + size = 32, + color, + className = "IconAlertHexagon", + stroke, +}) => { + // Safety check: Ensure the iconName exists in TablerIcons + if (!(iconName in TablerIcons)) { + console.warn(`Icon "${iconName}" not found in @tabler/icons-react library`); + return null; + } + + const IconComponent = TablerIcons[ + iconName as keyof typeof TablerIcons + ] as React.ComponentType<{ + size?: number; + color?: string; + className?: string; + stroke?: number; + }>; + + return ( + + ); +}; + +export default DynamicIcon; diff --git a/sigap-website/lib/db.ts b/sigap-website/lib/db.ts index e5d7482..0e2ce72 100644 --- a/sigap-website/lib/db.ts +++ b/sigap-website/lib/db.ts @@ -8,8 +8,8 @@ declare const globalThis: { prismaGlobal: ReturnType; } & typeof global; -const prisma = globalThis.prismaGlobal ?? prismaClientSingleton(); +const db = globalThis.prismaGlobal ?? prismaClientSingleton(); -export default prisma; +export default db; -if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = prisma; +if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = db; diff --git a/sigap-website/package-lock.json b/sigap-website/package-lock.json index a49f51d..0995860 100644 --- a/sigap-website/package-lock.json +++ b/sigap-website/package-lock.json @@ -26,6 +26,7 @@ "@react-email/components": "0.0.33", "@supabase/ssr": "latest", "@supabase/supabase-js": "latest", + "@tabler/icons-react": "^3.30.0", "autoprefixer": "10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -46,6 +47,7 @@ "@types/react-dom": "19.0.2", "postcss": "8.4.49", "prisma": "^6.3.1", + "prisma-json-types-generator": "^3.2.2", "react-email": "3.0.7", "supabase": "^2.12.1", "tailwind-merge": "^2.6.0", @@ -1590,6 +1592,23 @@ "@prisma/get-platform": "6.3.1" } }, + "node_modules/@prisma/generator-helper": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-6.0.0.tgz", + "integrity": "sha512-5DkG7hspZo6U4OtqI2W0JcgtY37sr7HgT8Q0W/sjL4VoV4px6ivzK6Eif5bKM7q+S4yFUHtjUt/3s69ErfLn7A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.0.0" + } + }, + "node_modules/@prisma/generator-helper/node_modules/@prisma/debug": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.0.0.tgz", + "integrity": "sha512-eUjoNThlDXdyJ1iQ2d7U6aTVwm59EwvODb5zFVNJEokNoSiQmiYWNzZIwZyDmZ+j51j42/0iTaHIJ4/aZPKFRg==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@prisma/get-platform": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.3.1.tgz", @@ -2901,6 +2920,32 @@ "tslib": "^2.8.0" } }, + "node_modules/@tabler/icons": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.30.0.tgz", + "integrity": "sha512-c8OKLM48l00u9TFbh2qhSODMONIzML8ajtCyq95rW8vzkWcBrKRPM61tdkThz2j4kd5u17srPGIjqdeRUZdfdw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.30.0.tgz", + "integrity": "sha512-9KZ9D1UNAyjlLkkYp2HBPHdf6lAJ2aelDqh8YYAnnmLF3xwprWKxxW8+zw5jlI0IwdfN4XFFuzqePkaw+DpIOg==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.30.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -5334,6 +5379,30 @@ } } }, + "node_modules/prisma-json-types-generator": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/prisma-json-types-generator/-/prisma-json-types-generator-3.2.2.tgz", + "integrity": "sha512-kvEbJPIP5gxk65KmLs0nAvY+CxpqVMWb4OsEvXlyXZmp2IGfi5f52BUV7ezTYQNjRPZyR4QlayWJXffoqVVAfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@prisma/generator-helper": "6.0.0", + "tslib": "2.8.1" + }, + "bin": { + "prisma-json-types-generator": "index.js" + }, + "engines": { + "node": ">=14.0" + }, + "funding": { + "url": "https://github.com/arthurfiorette/prisma-json-types-generator?sponsor=1" + }, + "peerDependencies": { + "prisma": "^5 || ^6", + "typescript": "^5.6.2" + } + }, "node_modules/prismjs": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", diff --git a/sigap-website/package.json b/sigap-website/package.json index 852d4ab..2cdd8d5 100644 --- a/sigap-website/package.json +++ b/sigap-website/package.json @@ -3,7 +3,11 @@ "scripts": { "dev": "next dev", "build": "next build", - "start": "next start" + "start": "next start", + "db:seed": "npx prisma db seed" + }, + "prisma": { + "seed": "ts-node prisma/seed.ts" }, "dependencies": { "@hookform/resolvers": "^4.0.0", @@ -23,6 +27,7 @@ "@react-email/components": "0.0.33", "@supabase/ssr": "latest", "@supabase/supabase-js": "latest", + "@tabler/icons-react": "^3.30.0", "autoprefixer": "10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -43,6 +48,7 @@ "@types/react-dom": "19.0.2", "postcss": "8.4.49", "prisma": "^6.3.1", + "prisma-json-types-generator": "^3.2.2", "react-email": "3.0.7", "supabase": "^2.12.1", "tailwind-merge": "^2.6.0", diff --git a/sigap-website/prisma/migrations/20250220132229_rename_field_to_snake_case/migration.sql b/sigap-website/prisma/migrations/20250220132229_rename_field_to_snake_case/migration.sql new file mode 100644 index 0000000..73026b5 --- /dev/null +++ b/sigap-website/prisma/migrations/20250220132229_rename_field_to_snake_case/migration.sql @@ -0,0 +1,106 @@ +/* + Warnings: + + - You are about to drop the column `createdAt` on the `contact_messages` table. All the data in the column will be lost. + - You are about to drop the column `updatedAt` on the `contact_messages` table. All the data in the column will be lost. + - You are about to drop the column `birthDate` on the `profiles` table. All the data in the column will be lost. + - You are about to drop the column `userId` on the `profiles` table. All the data in the column will be lost. + - You are about to drop the column `createdAt` on the `users` table. All the data in the column will be lost. + - You are about to drop the column `emailVerified` on the `users` table. All the data in the column will be lost. + - You are about to drop the column `firstName` on the `users` table. All the data in the column will be lost. + - You are about to drop the column `lastName` on the `users` table. All the data in the column will be lost. + - You are about to drop the column `lastSignedIn` on the `users` table. All the data in the column will be lost. + - You are about to drop the column `updatedAt` on the `users` table. All the data in the column will be lost. + - A unique constraint covering the columns `[user_id]` on the table `profiles` will be added. If there are existing duplicate values, this will fail. + - Added the required column `user_id` to the `profiles` table without a default value. This is not possible if the table is not empty. + - Added the required column `updated_at` to the `users` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateExtension +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- DropForeignKey +ALTER TABLE "profiles" DROP CONSTRAINT "profiles_userId_fkey"; + +-- DropIndex +DROP INDEX "profiles_userId_key"; + +-- AlterTable +ALTER TABLE "contact_messages" DROP COLUMN "createdAt", +DROP COLUMN "updatedAt", +ADD COLUMN "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT now(), +ADD COLUMN "updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT now(), +ALTER COLUMN "id" SET DEFAULT gen_random_uuid(); + +-- AlterTable +ALTER TABLE "profiles" DROP COLUMN "birthDate", +DROP COLUMN "userId", +ADD COLUMN "birth_date" TIMESTAMP(3), +ADD COLUMN "user_id" TEXT NOT NULL, +ALTER COLUMN "id" SET DEFAULT gen_random_uuid(); + +-- AlterTable +ALTER TABLE "users" DROP COLUMN "createdAt", +DROP COLUMN "emailVerified", +DROP COLUMN "firstName", +DROP COLUMN "lastName", +DROP COLUMN "lastSignedIn", +DROP COLUMN "updatedAt", +ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "email_verified" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "first_name" TEXT, +ADD COLUMN "last_name" TEXT, +ADD COLUMN "last_signed_in" TIMESTAMP(3), +ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL; + +-- CreateTable +CREATE TABLE "nav_items" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "title" VARCHAR(255) NOT NULL, + "url" VARCHAR(255) NOT NULL, + "icon" VARCHAR(100) NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT false, + "order_seq" INTEGER NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT now(), + "updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT now(), + "created_by" UUID, + "updated_by" UUID, + + CONSTRAINT "nav_items_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sub_items" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "title" VARCHAR(255) NOT NULL, + "url" VARCHAR(255) NOT NULL, + "order_seq" INTEGER NOT NULL, + "nav_item_id" UUID NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT now(), + "updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT now(), + "created_by" UUID, + "updated_by" UUID, + + CONSTRAINT "sub_items_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "nav_items_title_idx" ON "nav_items"("title"); + +-- CreateIndex +CREATE INDEX "nav_items_is_active_idx" ON "nav_items"("is_active"); + +-- CreateIndex +CREATE INDEX "sub_items_nav_item_id_idx" ON "sub_items"("nav_item_id"); + +-- CreateIndex +CREATE INDEX "sub_items_title_idx" ON "sub_items"("title"); + +-- CreateIndex +CREATE UNIQUE INDEX "profiles_user_id_key" ON "profiles"("user_id"); + +-- AddForeignKey +ALTER TABLE "profiles" ADD CONSTRAINT "profiles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sub_items" ADD CONSTRAINT "sub_items_nav_item_id_fkey" FOREIGN KEY ("nav_item_id") REFERENCES "nav_items"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/sigap-website/prisma/migrations/20250220134948_set_id_field_to_uuid_and_use_varchar_and_text_to_specific_field/migration.sql b/sigap-website/prisma/migrations/20250220134948_set_id_field_to_uuid_and_use_varchar_and_text_to_specific_field/migration.sql new file mode 100644 index 0000000..b121ed6 --- /dev/null +++ b/sigap-website/prisma/migrations/20250220134948_set_id_field_to_uuid_and_use_varchar_and_text_to_specific_field/migration.sql @@ -0,0 +1,108 @@ +/* + Warnings: + + - The primary key for the `contact_messages` table will be changed. If it partially fails, the table could be left without primary key constraint. + - The `id` column on the `contact_messages` table would be dropped and recreated. This will lead to data loss if there is data in the column. + - You are about to alter the column `name` on the `contact_messages` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(255)`. + - You are about to alter the column `email` on the `contact_messages` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(255)`. + - You are about to alter the column `phone` on the `contact_messages` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(20)`. + - You are about to alter the column `message_type` on the `contact_messages` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(50)`. + - You are about to alter the column `message_type_label` on the `contact_messages` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(50)`. + - The primary key for the `profiles` table will be changed. If it partially fails, the table could be left without primary key constraint. + - The `id` column on the `profiles` table would be dropped and recreated. This will lead to data loss if there is data in the column. + - You are about to alter the column `phone` on the `profiles` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(20)`. + - You are about to alter the column `address` on the `profiles` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(255)`. + - You are about to alter the column `city` on the `profiles` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(100)`. + - You are about to alter the column `country` on the `profiles` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(100)`. + - The primary key for the `users` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to alter the column `email` on the `users` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(255)`. + - You are about to alter the column `password` on the `users` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(255)`. + - You are about to alter the column `avatar` on the `users` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(255)`. + - You are about to alter the column `first_name` on the `users` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(255)`. + - You are about to alter the column `last_name` on the `users` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(255)`. + - You are about to drop the `sub_items` table. If the table is not empty, all the data it contains will be lost. + - Changed the type of `user_id` on the `profiles` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + - Changed the type of `id` on the `users` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- DropForeignKey +ALTER TABLE "profiles" DROP CONSTRAINT "profiles_user_id_fkey"; + +-- DropForeignKey +ALTER TABLE "sub_items" DROP CONSTRAINT "sub_items_nav_item_id_fkey"; + +-- AlterTable +ALTER TABLE "contact_messages" DROP CONSTRAINT "contact_messages_pkey", +DROP COLUMN "id", +ADD COLUMN "id" UUID NOT NULL DEFAULT gen_random_uuid(), +ALTER COLUMN "name" SET DATA TYPE VARCHAR(255), +ALTER COLUMN "email" SET DATA TYPE VARCHAR(255), +ALTER COLUMN "phone" SET DATA TYPE VARCHAR(20), +ALTER COLUMN "message_type" SET DATA TYPE VARCHAR(50), +ALTER COLUMN "message_type_label" SET DATA TYPE VARCHAR(50), +ALTER COLUMN "created_at" SET DEFAULT now(), +ALTER COLUMN "updated_at" SET DEFAULT now(), +ADD CONSTRAINT "contact_messages_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "nav_items" ALTER COLUMN "created_at" SET DEFAULT now(), +ALTER COLUMN "updated_at" SET DEFAULT now(); + +-- AlterTable +ALTER TABLE "profiles" DROP CONSTRAINT "profiles_pkey", +DROP COLUMN "id", +ADD COLUMN "id" UUID NOT NULL DEFAULT gen_random_uuid(), +ALTER COLUMN "phone" SET DATA TYPE VARCHAR(20), +ALTER COLUMN "address" SET DATA TYPE VARCHAR(255), +ALTER COLUMN "city" SET DATA TYPE VARCHAR(100), +ALTER COLUMN "country" SET DATA TYPE VARCHAR(100), +DROP COLUMN "user_id", +ADD COLUMN "user_id" UUID NOT NULL, +ADD CONSTRAINT "profiles_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "users" DROP CONSTRAINT "users_pkey", +DROP COLUMN "id", +ADD COLUMN "id" UUID NOT NULL, +ALTER COLUMN "email" SET DATA TYPE VARCHAR(255), +ALTER COLUMN "password" SET DATA TYPE VARCHAR(255), +ALTER COLUMN "avatar" SET DATA TYPE VARCHAR(255), +ALTER COLUMN "first_name" SET DATA TYPE VARCHAR(255), +ALTER COLUMN "last_name" SET DATA TYPE VARCHAR(255), +ADD CONSTRAINT "users_pkey" PRIMARY KEY ("id"); + +-- DropTable +DROP TABLE "sub_items"; + +-- CreateTable +CREATE TABLE "nav_sub_items" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "title" VARCHAR(255) NOT NULL, + "url" VARCHAR(255) NOT NULL, + "order_seq" INTEGER NOT NULL, + "nav_item_id" UUID NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT now(), + "updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT now(), + "created_by" UUID, + "updated_by" UUID, + + CONSTRAINT "nav_sub_items_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "nav_sub_items_nav_item_id_idx" ON "nav_sub_items"("nav_item_id"); + +-- CreateIndex +CREATE INDEX "nav_sub_items_title_idx" ON "nav_sub_items"("title"); + +-- CreateIndex +CREATE UNIQUE INDEX "profiles_user_id_key" ON "profiles"("user_id"); + +-- CreateIndex +CREATE INDEX "users_role_idx" ON "users"("role"); + +-- AddForeignKey +ALTER TABLE "profiles" ADD CONSTRAINT "profiles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "nav_sub_items" ADD CONSTRAINT "nav_sub_items_nav_item_id_fkey" FOREIGN KEY ("nav_item_id") REFERENCES "nav_items"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/sigap-website/prisma/schema.prisma b/sigap-website/prisma/schema.prisma index 580366b..93e487d 100644 --- a/sigap-website/prisma/schema.prisma +++ b/sigap-website/prisma/schema.prisma @@ -5,64 +5,106 @@ // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + previewFeatures = ["postgresqlExtensions"] } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") - directUrl = env("DIRECT_URL") + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DIRECT_URL") + extensions = [pgcrypto] } model User { - id String @id - email String @unique - emailVerified Boolean @default(false) - password String? - firstName String? - lastName String? - avatar String? - role Role @default(user) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - lastSignedIn DateTime? - metadata Json? + id String @id @db.Uuid + email String @unique @db.VarChar(255) + email_verified Boolean @default(false) + password String? @db.VarChar(255) + first_name String? @db.VarChar(255) + last_name String? @db.VarChar(255) + avatar String? @db.VarChar(255) + role Role @default(user) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + last_signed_in DateTime? + metadata Json? - // Relations (optional examples) profile Profile? - @@map("users") // Maps to Supabase's 'users' table + @@index([role]) + @@map("users") } model Profile { - id String @id @default(uuid()) - userId String @unique - bio String? - phone String? - address String? - city String? - country String? - birthDate DateTime? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + user_id String @unique @db.Uuid + bio String? @db.Text + phone String? @db.VarChar(20) + address String? @db.VarChar(255) + city String? @db.VarChar(100) + country String? @db.VarChar(100) + birth_date DateTime? + user User @relation(fields: [user_id], references: [id]) + @@index([user_id]) @@map("profiles") // Maps to Supabase's 'profiles' table } model ContactMessages { - id String @id @default(dbgenerated("gen_random_uuid()")) - name String? - email String? - phone String? - message_type String? - message_type_label String? - message String? + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String? @db.VarChar(255) + email String? @db.VarChar(255) + phone String? @db.VarChar(20) + message_type String? @db.VarChar(50) + message_type_label String? @db.VarChar(50) + message String? @db.Text status StatusContactMessages @default(new) - createdAt DateTime @default(dbgenerated("now()")) @db.Timestamptz(6) - updatedAt DateTime @default(dbgenerated("now()")) @updatedAt @db.Timestamptz(6) + created_at DateTime @default(dbgenerated("now()")) @db.Timestamptz(6) + updated_at DateTime @default(dbgenerated("now()")) @updatedAt @db.Timestamptz(6) @@map("contact_messages") // Maps to Supabase's 'contact_messages' table } +model NavItems { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + title String @db.VarChar(255) + url String @db.VarChar(255) + slug String @db.VarChar(255) + icon String @db.VarChar(100) + is_active Boolean @default(false) + order_seq Int + created_at DateTime @default(dbgenerated("now()")) @db.Timestamptz(6) + updated_at DateTime @default(dbgenerated("now()")) @updatedAt @db.Timestamptz(6) + sub_items NavSubItems[] + created_by String? @db.Uuid + updated_by String? @db.Uuid + + @@index([title]) + @@index([is_active]) + @@map("nav_items") +} + +model NavSubItems { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + title String @db.VarChar(255) + url String @db.VarChar(255) + slug String @db.VarChar(255) + icon String @db.VarChar(100) + is_active Boolean @default(false) + order_seq Int + nav_item_id String @db.Uuid + created_at DateTime @default(dbgenerated("now()")) @db.Timestamptz(6) + updated_at DateTime @default(dbgenerated("now()")) @updatedAt @db.Timestamptz(6) + created_by String? @db.Uuid + updated_by String? @db.Uuid + nav_item NavItems @relation(fields: [nav_item_id], references: [id], onDelete: Cascade) + + @@index([nav_item_id]) + @@index([title]) + @@map("nav_sub_items") +} + enum Role { admin staff diff --git a/sigap-website/prisma/seed.ts b/sigap-website/prisma/seed.ts new file mode 100644 index 0000000..ab1c27a --- /dev/null +++ b/sigap-website/prisma/seed.ts @@ -0,0 +1,68 @@ +const { PrismaClient } = require("@prisma/client"); +const prisma = new PrismaClient(); + +async function main() { + // Clear existing data + await prisma.navSubItems.deleteMany({}); + await prisma.navItems.deleteMany({}); + + const navItemDatas = [ + { + title: "Dashboard", + url: "/dashboard", + slug: "dashboard", + order_seq: 1, + icon: "LayoutDashboard", + sub_items: [], + }, + { + title: "Master", + url: "/master", + slug: "master", + order_seq: 2, + icon: "IconDashboard", + sub_items: [ + { + title: "Users", + url: "/master/users", + slug: "users", + icon: "IconUsers ", + order_seq: 1, + }, + ], + }, + { + title: "Map", + url: "/map", + slug: "map", + order_seq: 3, + icon: "Map", + sub_items: [], + }, + ]; + + // Create nav items and their sub-items + for (const navItemData of navItemDatas) { + const { sub_items, ...navItemFields } = navItemData; + + await prisma.navItems.create({ + data: { + ...navItemFields, + sub_items: { + create: sub_items, + }, + }, + }); + } + + console.log("Seed data created successfully", navItemDatas); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/sigap-website/src/applications/entities/models/nav-items.model.ts b/sigap-website/src/applications/entities/models/nav-items.model.ts new file mode 100644 index 0000000..55cfbda --- /dev/null +++ b/sigap-website/src/applications/entities/models/nav-items.model.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +export const navSubItemsSchema = z.object({ + id: z.string().uuid(), + title: z.string(), + url: z.string(), + slug: z.string(), + icon: z.string(), + is_active: z.boolean().default(false), + order_seq: z.number().int(), + nav_item_id: z.string().uuid(), + created_at: z.date(), + updated_at: z.date(), + created_by: z.string().uuid().nullable(), + updated_by: z.string().uuid().nullable(), +}); + +export type NavSubItems = z.infer; + +export const navItemsSchema = z.object({ + id: z.string().uuid(), + title: z.string(), + url: z.string(), + slug: z.string(), + icon: z.string(), + is_active: z.boolean().default(false), + order_seq: z.number().int(), + created_at: z.date(), + updated_at: z.date(), + created_by: z.string().uuid().nullable(), + updated_by: z.string().uuid().nullable(), + sub_items: z.array(navSubItemsSchema), +}); + +export type NavItems = z.infer; + +export const navItemsGetSchema = z.array(navItemsSchema); + +export type NavItemsGet = z.infer; + +export const navItemsInsertSchema = navItemsSchema.pick({ + title: true, + url: true, + slug: true, + icon: true, + is_active: true, + order_seq: true, + sub_items: true, +}); + +export type NavItemsInsert = z.infer; + +export const navItemsUpdateSchema = navItemsSchema.pick({ + title: true, + url: true, + slug: true, + icon: true, + is_active: true, + order_seq: true, + sub_items: true, +}); + +export type NavItemsUpdate = z.infer; + +export const navItemsDeleteSchema = z.object({ + id: z.string().uuid(), +}); + +export type NavItemsDelete = z.infer; + +export interface NavItemsResponse { + success: boolean; + message?: string; + error?: string; + errors?: Record; + data?: NavItems | NavItemsGet; +} diff --git a/sigap-website/src/applications/repositories/nav-items.repository.ts b/sigap-website/src/applications/repositories/nav-items.repository.ts new file mode 100644 index 0000000..05871d2 --- /dev/null +++ b/sigap-website/src/applications/repositories/nav-items.repository.ts @@ -0,0 +1,17 @@ +import { + NavItemsDelete, + NavItemsGet, + NavItemsInsert, + NavItemsResponse, + NavItemsUpdate, +} from "../entities/models/nav-items.model"; + +export interface NavItemsRepository { + getNavItems(): Promise; + createNavItems(navItems: NavItemsInsert): Promise; + updateNavItems( + id: string, + navItems: NavItemsUpdate + ): Promise; + deleteNavItems(id: string): Promise; +} diff --git a/sigap-website/src/applications/usecases/contact-us/create-contact-us.usecase.ts b/sigap-website/src/applications/usecases/contact-us.usecase.ts similarity index 70% rename from sigap-website/src/applications/usecases/contact-us/create-contact-us.usecase.ts rename to sigap-website/src/applications/usecases/contact-us.usecase.ts index 8726ceb..2995993 100644 --- a/sigap-website/src/applications/usecases/contact-us/create-contact-us.usecase.ts +++ b/sigap-website/src/applications/usecases/contact-us.usecase.ts @@ -2,8 +2,8 @@ import { ContactUs, ContactUsInsert, ContactUsResponse, -} from "../../entities/models/contact-us.model"; -import { ContactUsRepository } from "../../repositories/contact-us.repository"; +} from "../entities/models/contact-us.model"; +import { ContactUsRepository } from "../repositories/contact-us.repository"; export class CreateContactUseCase { constructor(private contactRepository: ContactUsRepository) {} diff --git a/sigap-website/src/applications/usecases/nav-items.usecase.ts b/sigap-website/src/applications/usecases/nav-items.usecase.ts new file mode 100644 index 0000000..729d666 --- /dev/null +++ b/sigap-website/src/applications/usecases/nav-items.usecase.ts @@ -0,0 +1,33 @@ +import { + NavItemsDelete, + NavItemsGet, + NavItemsInsert, + NavItemsResponse, + NavItemsUpdate, +} from "../entities/models/nav-items.model"; +import { NavItemsRepository } from "../repositories/nav-items.repository"; + +export class NavItemsUseCase { + constructor(private navItemsRepository: NavItemsRepository) {} + + async executeGetNavItems(): Promise { + return this.navItemsRepository.getNavItems(); + } + + async executeCreateNavItems( + navItems: NavItemsInsert + ): Promise { + return this.navItemsRepository.createNavItems(navItems); + } + + async executeUpdateNavItems( + id: string, + navItems: NavItemsUpdate + ): Promise { + return this.navItemsRepository.updateNavItems(id, navItems); + } + + async executeDeleteNavItems(id: string): Promise { + return this.navItemsRepository.deleteNavItems(id); + } +} diff --git a/sigap-website/src/applications/usecases/signin/signin.usecases.ts b/sigap-website/src/applications/usecases/signin.usecases.ts similarity index 61% rename from sigap-website/src/applications/usecases/signin/signin.usecases.ts rename to sigap-website/src/applications/usecases/signin.usecases.ts index f18b45e..ce413b5 100644 --- a/sigap-website/src/applications/usecases/signin/signin.usecases.ts +++ b/sigap-website/src/applications/usecases/signin.usecases.ts @@ -1,5 +1,5 @@ -import { SignInResponse, SignInWithOtp } from "../../entities/models/user.model"; -import { SignInRepository } from "../../repositories/signin.repository"; +import { SignInResponse, SignInWithOtp } from "../entities/models/user.model"; +import { SignInRepository } from "../repositories/signin.repository"; export class SignInUseCase { constructor(private signInRepository: SignInRepository) {} diff --git a/sigap-website/src/infrastructure/hooks/use-nav-items.ts b/sigap-website/src/infrastructure/hooks/use-nav-items.ts new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/sigap-website/src/infrastructure/hooks/use-nav-items.ts @@ -0,0 +1 @@ + diff --git a/sigap-website/src/infrastructure/repositories/nav-items.repository.impl.ts b/sigap-website/src/infrastructure/repositories/nav-items.repository.impl.ts new file mode 100644 index 0000000..c5e18e6 --- /dev/null +++ b/sigap-website/src/infrastructure/repositories/nav-items.repository.impl.ts @@ -0,0 +1,54 @@ +import db from "@/lib/db"; +import { + NavItemsDelete, + NavItemsGet, + NavItemsInsert, + NavItemsResponse, + navItemsSchema, + NavItemsUpdate, +} from "@/src/applications/entities/models/nav-items.model"; +import { NavItemsRepository } from "@/src/applications/repositories/nav-items.repository"; + +export class NavItemsRepositoryImpl implements NavItemsRepository { + async getNavItems(): Promise { + const data = await db.navItems.findMany({ + where: { + is_active: true, + }, + include: { + sub_items: true, + }, + }); + return { + success: true, + data, + }; + } + + async createNavItems(navItems: NavItemsInsert): Promise { + return { + success: true, + }; + } + + async updateNavItems( + id: string, + navItems: NavItemsUpdate + ): Promise { + return { + success: true, + }; + } + + async deleteNavItems(id: string): Promise { + await db.navItems.delete({ + where: { + id, + }, + }); + return { + success: true, + message: "Navigation item deleted", + }; + } +}