first commit
This commit is contained in:
commit
d522d39419
|
@ -0,0 +1,45 @@
|
|||
# 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
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
.vscode
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MaterialThemeProjectNewConfig">
|
||||
<option name="metadata">
|
||||
<MTProjectMetadataState>
|
||||
<option name="migrated" value="true" />
|
||||
<option name="pristineConfig" value="false" />
|
||||
<option name="userId" value="-57fe568e:197a6ffde32:-7ffe" />
|
||||
</MTProjectMetadataState>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/sikma-app-nextjs.iml" filepath="$PROJECT_DIR$/.idea/sikma-app-nextjs.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
|
@ -0,0 +1,84 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AutoImportSettings">
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="6b521452-6976-4c21-a9c3-a0a0ac33697b" name="Changes" comment="">
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||
</component>
|
||||
<component name="Git.Settings">
|
||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||
</component>
|
||||
<component name="ProjectColorInfo">{
|
||||
"associatedIndex": 2
|
||||
}</component>
|
||||
<component name="ProjectId" id="2z01JubPymlsp5iFv6B6k4i74F7" />
|
||||
<component name="ProjectViewState">
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent"><![CDATA[{
|
||||
"keyToString": {
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"git-widget-placeholder": "master",
|
||||
"ignore.virus.scanning.warn.message": "true",
|
||||
"js.debugger.nextJs.config.created.client": "true",
|
||||
"js.debugger.nextJs.config.created.server": "true",
|
||||
"last_opened_file_path": "C:/Users/RIZKY-ROMADHON/Desktop/sikma-app-nextjs",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "yarn",
|
||||
"settings.editor.selected.configurable": "preferences.pluginManager",
|
||||
"ts.external.directory.path": "C:\\Users\\RIZKY-ROMADHON\\Desktop\\sikma-app-nextjs\\node_modules\\typescript\\lib",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
}
|
||||
}]]></component>
|
||||
<component name="RunManager" selected="npm.Next.js: server-side">
|
||||
<configuration name="Next.js: debug client-side" type="JavascriptDebugType" uri="http://localhost:3000/">
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="Next.js: server-side" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<script value="dev" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
<component name="SharedIndexes">
|
||||
<attachedChunks>
|
||||
<set>
|
||||
<option value="bundled-js-predefined-d6986cc7102b-09060db00ec0-JavaScript-WS-251.26927.40" />
|
||||
</set>
|
||||
</attachedChunks>
|
||||
</component>
|
||||
<component name="TaskManager">
|
||||
<task active="true" id="Default" summary="Default task">
|
||||
<changelist id="6b521452-6976-4c21-a9c3-a0a0ac33697b" name="Changes" comment="" />
|
||||
<created>1750853108780</created>
|
||||
<option name="number" value="Default" />
|
||||
<option name="presentableId" value="Default" />
|
||||
<updated>1750853108780</updated>
|
||||
<workItem from="1750853111614" duration="116000" />
|
||||
<workItem from="1750853489158" duration="283000" />
|
||||
<workItem from="1750857956991" duration="656000" />
|
||||
</task>
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
<option name="version" value="3" />
|
||||
</component>
|
||||
</project>
|
|
@ -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,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.config({
|
||||
extends: ["next/core-web-vitals", "next/typescript"],
|
||||
rules: {
|
||||
"react/no-unescaped-entities": "off",
|
||||
"@next/next/no-page-custom-font": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "error",
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-expressions": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-empty-object-type": "off",
|
||||
"@typescript-eslint/no-this-alias": "off",
|
||||
"@typescript-eslint/no-unsafe-function-types": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-constraint": "off",
|
||||
"@typescript-eslint/no-wrapper-object-types": "off",
|
||||
},
|
||||
ignorePatterns: ["src/generated/prisma/**/*.d.ts"],
|
||||
}),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
|
@ -0,0 +1,28 @@
|
|||
const nextConfig = {
|
||||
devIndicators: false,
|
||||
reactStrictMode: false,
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "placehold.co",
|
||||
port: "",
|
||||
pathname: "/**",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "res.cloudinary.com",
|
||||
port: "",
|
||||
pathname: "/**",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "upload.wikimedia.org",
|
||||
port: "",
|
||||
pathname: "/**",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
|
@ -0,0 +1,87 @@
|
|||
{
|
||||
"name": "sikma-app-nextjs",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.9.1",
|
||||
"@headlessui/react": "^2.2.4",
|
||||
"@prisma/client": "^6.10.1",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"accordion": "^3.0.2",
|
||||
"axios": "^1.10.0",
|
||||
"badge": "^1.0.3",
|
||||
"bcrypt-ts": "^7.1.0",
|
||||
"bufferutil": "^4.0.9",
|
||||
"calendar": "^0.1.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cloudinary": "^2.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"framer-motion": "^12.18.1",
|
||||
"lucide-react": "^0.522.0",
|
||||
"next": "15.3.3",
|
||||
"next-auth": "^5.0.0-beta.28",
|
||||
"next-cloudinary": "^6.16.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"popover": "^2.4.1",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.7.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-loading-skeleton": "^3.5.0",
|
||||
"recharts": "^2.15.3",
|
||||
"shadcn": "^2.7.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.5",
|
||||
"swr": "^2.3.3",
|
||||
"table": "^6.9.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"use-debounce": "^10.0.5",
|
||||
"utf-8-validate": "^6.0.5",
|
||||
"ws": "^8.18.2",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@types/node": "^20.19.1",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.3",
|
||||
"prisma": "^6.10.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
content: [
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
autoprefixer: {},
|
||||
};
|
||||
export default config;
|
|
@ -0,0 +1,329 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "Role" AS ENUM ('ADMIN', 'DOSEN', 'MAHASISWA');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SemesterType" AS ENUM ('GANJIL', 'GENAP');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RuanganType" AS ENUM ('TEORI', 'PRAKTIKUM');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Hari" AS ENUM ('SENIN', 'SELASA', 'RABU', 'KAMIS', 'JUMAT', 'SABTU', 'MINGGU');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StatusPresensi" AS ENUM ('HADIR', 'TIDAK_HADIR', 'IZIN', 'SAKIT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TipePengajuan" AS ENUM ('IZIN', 'SAKIT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StatusPengajuan" AS ENUM ('DIPROSES', 'DISETUJUI', 'DITOLAK');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "accounts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"provider_account_id" TEXT NOT NULL,
|
||||
"refresh_token" TEXT,
|
||||
"access_token" TEXT,
|
||||
"expires_at" INTEGER,
|
||||
"token_type" TEXT,
|
||||
"scope" TEXT,
|
||||
"id_token" TEXT,
|
||||
"session_state" TEXT,
|
||||
|
||||
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sessions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"session_token" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"expires" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user" (
|
||||
"id" TEXT NOT NULL,
|
||||
"uid" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT,
|
||||
"alamat" TEXT,
|
||||
"no_hp" TEXT,
|
||||
"foto" TEXT,
|
||||
"nim" TEXT,
|
||||
"nip" TEXT,
|
||||
"is_profile_complete" BOOLEAN NOT NULL DEFAULT false,
|
||||
"role" "Role" NOT NULL DEFAULT 'MAHASISWA',
|
||||
"gender" TEXT,
|
||||
"prodiId" TEXT,
|
||||
"golonganId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "verification_tokens" (
|
||||
"identifier" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expires" TIMESTAMP(3) NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Semester" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"tipe" "SemesterType" NOT NULL,
|
||||
|
||||
CONSTRAINT "Semester_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Ruangan" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kode" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" "RuanganType" NOT NULL,
|
||||
|
||||
CONSTRAINT "Ruangan_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProgramStudi" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "ProgramStudi_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PresensiKuliah" (
|
||||
"id" TEXT NOT NULL,
|
||||
"waktu_presensi" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"status" "StatusPresensi" NOT NULL,
|
||||
"keterangan" TEXT,
|
||||
"mahasiswaId" TEXT NOT NULL,
|
||||
"matkulId" TEXT NOT NULL,
|
||||
"jadwalKuliahId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "PresensiKuliah_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PengajuanIzin" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tanggal_izin" TIMESTAMP(3) NOT NULL,
|
||||
"tipe_pengajuan" "TipePengajuan" NOT NULL,
|
||||
"pesan" TEXT NOT NULL,
|
||||
"file_bukti" TEXT,
|
||||
"status" "StatusPengajuan" NOT NULL DEFAULT 'DIPROSES',
|
||||
"catatan_dosen" TEXT,
|
||||
"mahasiswaId" TEXT NOT NULL,
|
||||
"jadwalKuliahId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PengajuanIzin_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Notifikasi" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tipe" TEXT NOT NULL,
|
||||
"konten" TEXT NOT NULL,
|
||||
"url_tujuan" TEXT,
|
||||
"read_at" TIMESTAMP(3),
|
||||
"userId" TEXT NOT NULL,
|
||||
"senderId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Notifikasi_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MataKuliah" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kode" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "MataKuliah_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LaporanMahasiswa" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tipe" TEXT NOT NULL,
|
||||
"pesan" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'Belum Ditangani',
|
||||
"balasan" TEXT,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "LaporanMahasiswa_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "JadwalKuliah" (
|
||||
"id" TEXT NOT NULL,
|
||||
"is_kelas_besar" BOOLEAN NOT NULL DEFAULT false,
|
||||
"hari" "Hari" NOT NULL,
|
||||
"jam_mulai" TIMESTAMP(3) NOT NULL,
|
||||
"jam_selesai" TIMESTAMP(3) NOT NULL,
|
||||
"dosenId" TEXT NOT NULL,
|
||||
"matkulId" TEXT NOT NULL,
|
||||
"semesterId" INTEGER NOT NULL,
|
||||
"prodiId" TEXT NOT NULL,
|
||||
"golonganId" TEXT NOT NULL,
|
||||
"ruanganId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "JadwalKuliah_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Golongan" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"prodiId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Golongan_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AlatPresensi" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"mode" TEXT NOT NULL,
|
||||
"jadwal_nyala" TIMESTAMP(3),
|
||||
"jadwal_mati" TIMESTAMP(3),
|
||||
"status" TEXT NOT NULL,
|
||||
"ruanganId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "AlatPresensi_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PesertaKuliah" (
|
||||
"id" TEXT NOT NULL,
|
||||
"mahasiswaId" TEXT NOT NULL,
|
||||
"jadwalKuliahId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PesertaKuliah_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "accounts_provider_provider_account_id_key" ON "accounts"("provider", "provider_account_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sessions_session_token_key" ON "sessions"("session_token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_uid_key" ON "user"("uid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_nim_key" ON "user"("nim");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_nip_key" ON "user"("nip");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "verification_tokens_identifier_token_key" ON "verification_tokens"("identifier", "token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Semester_name_key" ON "Semester"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Ruangan_kode_key" ON "Ruangan"("kode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProgramStudi_name_key" ON "ProgramStudi"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProgramStudi_slug_key" ON "ProgramStudi"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MataKuliah_kode_key" ON "MataKuliah"("kode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PesertaKuliah_mahasiswaId_jadwalKuliahId_key" ON "PesertaKuliah"("mahasiswaId", "jadwalKuliahId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user" ADD CONSTRAINT "user_prodiId_fkey" FOREIGN KEY ("prodiId") REFERENCES "ProgramStudi"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user" ADD CONSTRAINT "user_golonganId_fkey" FOREIGN KEY ("golonganId") REFERENCES "Golongan"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PresensiKuliah" ADD CONSTRAINT "PresensiKuliah_mahasiswaId_fkey" FOREIGN KEY ("mahasiswaId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PresensiKuliah" ADD CONSTRAINT "PresensiKuliah_matkulId_fkey" FOREIGN KEY ("matkulId") REFERENCES "MataKuliah"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PresensiKuliah" ADD CONSTRAINT "PresensiKuliah_jadwalKuliahId_fkey" FOREIGN KEY ("jadwalKuliahId") REFERENCES "JadwalKuliah"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PengajuanIzin" ADD CONSTRAINT "PengajuanIzin_mahasiswaId_fkey" FOREIGN KEY ("mahasiswaId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PengajuanIzin" ADD CONSTRAINT "PengajuanIzin_jadwalKuliahId_fkey" FOREIGN KEY ("jadwalKuliahId") REFERENCES "JadwalKuliah"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notifikasi" ADD CONSTRAINT "Notifikasi_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notifikasi" ADD CONSTRAINT "Notifikasi_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LaporanMahasiswa" ADD CONSTRAINT "LaporanMahasiswa_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JadwalKuliah" ADD CONSTRAINT "JadwalKuliah_dosenId_fkey" FOREIGN KEY ("dosenId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JadwalKuliah" ADD CONSTRAINT "JadwalKuliah_matkulId_fkey" FOREIGN KEY ("matkulId") REFERENCES "MataKuliah"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JadwalKuliah" ADD CONSTRAINT "JadwalKuliah_semesterId_fkey" FOREIGN KEY ("semesterId") REFERENCES "Semester"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JadwalKuliah" ADD CONSTRAINT "JadwalKuliah_prodiId_fkey" FOREIGN KEY ("prodiId") REFERENCES "ProgramStudi"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JadwalKuliah" ADD CONSTRAINT "JadwalKuliah_golonganId_fkey" FOREIGN KEY ("golonganId") REFERENCES "Golongan"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JadwalKuliah" ADD CONSTRAINT "JadwalKuliah_ruanganId_fkey" FOREIGN KEY ("ruanganId") REFERENCES "Ruangan"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Golongan" ADD CONSTRAINT "Golongan_prodiId_fkey" FOREIGN KEY ("prodiId") REFERENCES "ProgramStudi"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AlatPresensi" ADD CONSTRAINT "AlatPresensi_ruanganId_fkey" FOREIGN KEY ("ruanganId") REFERENCES "Ruangan"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PesertaKuliah" ADD CONSTRAINT "PesertaKuliah_mahasiswaId_fkey" FOREIGN KEY ("mahasiswaId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PesertaKuliah" ADD CONSTRAINT "PesertaKuliah_jadwalKuliahId_fkey" FOREIGN KEY ("jadwalKuliahId") REFERENCES "JadwalKuliah"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
@ -0,0 +1,5 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "user" ADD COLUMN "semesterId" INTEGER;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user" ADD CONSTRAINT "user_semesterId_fkey" FOREIGN KEY ("semesterId") REFERENCES "Semester"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
@ -0,0 +1,11 @@
|
|||
-- DropForeignKey
|
||||
ALTER TABLE "JadwalKuliah" DROP CONSTRAINT "JadwalKuliah_golonganId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "JadwalKuliah" ALTER COLUMN "golonganId" DROP NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ProgramStudi" ALTER COLUMN "slug" DROP NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JadwalKuliah" ADD CONSTRAINT "JadwalKuliah_golonganId_fkey" FOREIGN KEY ("golonganId") REFERENCES "Golongan"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `golonganId` on the `JadwalKuliah` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "JadwalKuliah" DROP CONSTRAINT "JadwalKuliah_golonganId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "JadwalKuliah" DROP COLUMN "golonganId";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_GolonganToJadwalKuliah" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "_GolonganToJadwalKuliah_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_GolonganToJadwalKuliah_B_index" ON "_GolonganToJadwalKuliah"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_GolonganToJadwalKuliah" ADD CONSTRAINT "_GolonganToJadwalKuliah_A_fkey" FOREIGN KEY ("A") REFERENCES "Golongan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_GolonganToJadwalKuliah" ADD CONSTRAINT "_GolonganToJadwalKuliah_B_fkey" FOREIGN KEY ("B") REFERENCES "JadwalKuliah"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `semesterId` to the `Golongan` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Golongan" ADD COLUMN "semesterId" INTEGER NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Golongan" ADD CONSTRAINT "Golongan_semesterId_fkey" FOREIGN KEY ("semesterId") REFERENCES "Semester"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `Semester` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- Made the column `semesterId` on table `user` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Golongan" DROP CONSTRAINT "Golongan_semesterId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "JadwalKuliah" DROP CONSTRAINT "JadwalKuliah_semesterId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "user" DROP CONSTRAINT "user_semesterId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Golongan" ALTER COLUMN "semesterId" SET DATA TYPE TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "JadwalKuliah" ALTER COLUMN "semesterId" SET DATA TYPE TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Semester" DROP CONSTRAINT "Semester_pkey",
|
||||
ALTER COLUMN "id" DROP DEFAULT,
|
||||
ALTER COLUMN "id" SET DATA TYPE TEXT,
|
||||
ADD CONSTRAINT "Semester_pkey" PRIMARY KEY ("id");
|
||||
DROP SEQUENCE "Semester_id_seq";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user" ALTER COLUMN "semesterId" SET NOT NULL,
|
||||
ALTER COLUMN "semesterId" SET DATA TYPE TEXT;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user" ADD CONSTRAINT "user_semesterId_fkey" FOREIGN KEY ("semesterId") REFERENCES "Semester"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JadwalKuliah" ADD CONSTRAINT "JadwalKuliah_semesterId_fkey" FOREIGN KEY ("semesterId") REFERENCES "Semester"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Golongan" ADD CONSTRAINT "Golongan_semesterId_fkey" FOREIGN KEY ("semesterId") REFERENCES "Semester"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
@ -0,0 +1,8 @@
|
|||
-- DropForeignKey
|
||||
ALTER TABLE "user" DROP CONSTRAINT "user_semesterId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user" ALTER COLUMN "semesterId" DROP NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user" ADD CONSTRAINT "user_semesterId_fkey" FOREIGN KEY ("semesterId") REFERENCES "Semester"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[prodiId,semesterId,name]` on the table `Golongan` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Golongan_prodiId_semesterId_name_key" ON "Golongan"("prodiId", "semesterId", "name");
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `_GolonganToJadwalKuliah` table. If the table is not empty, all the data it contains will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "_GolonganToJadwalKuliah" DROP CONSTRAINT "_GolonganToJadwalKuliah_A_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "_GolonganToJadwalKuliah" DROP CONSTRAINT "_GolonganToJadwalKuliah_B_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "_GolonganToJadwalKuliah";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_JadwalToGolongan" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "_JadwalToGolongan_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_JadwalToGolongan_B_index" ON "_JadwalToGolongan"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_JadwalToGolongan" ADD CONSTRAINT "_JadwalToGolongan_A_fkey" FOREIGN KEY ("A") REFERENCES "Golongan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_JadwalToGolongan" ADD CONSTRAINT "_JadwalToGolongan_B_fkey" FOREIGN KEY ("B") REFERENCES "JadwalKuliah"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- The `mode` column on the `AlatPresensi` table would be dropped and recreated. This will lead to data loss if there is data in the column.
|
||||
- The `status` column on the `AlatPresensi` table would be dropped and recreated. This will lead to data loss if there is data in the column.
|
||||
|
||||
*/
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AlatMode" AS ENUM ('MASUK', 'PULANG', 'PRESENSI', 'REGISTRASI');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AlatStatus" AS ENUM ('AKTIF', 'NONAKTIF', 'ERROR');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "AlatPresensi" DROP COLUMN "mode",
|
||||
ADD COLUMN "mode" "AlatMode" NOT NULL DEFAULT 'PRESENSI',
|
||||
DROP COLUMN "status",
|
||||
ADD COLUMN "status" "AlatStatus" NOT NULL DEFAULT 'NONAKTIF';
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "AlatPresensi" ADD COLUMN "targetMahasiswaId" TEXT;
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "StatusPresensi" ADD VALUE 'TERLAMBAT';
|
|
@ -0,0 +1,9 @@
|
|||
-- DropForeignKey
|
||||
ALTER TABLE "PesertaKuliah" DROP CONSTRAINT "PesertaKuliah_jadwalKuliahId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "JadwalKuliah" ALTER COLUMN "jam_mulai" SET DATA TYPE TEXT,
|
||||
ALTER COLUMN "jam_selesai" SET DATA TYPE TEXT;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PesertaKuliah" ADD CONSTRAINT "PesertaKuliah_jadwalKuliahId_fkey" FOREIGN KEY ("jadwalKuliahId") REFERENCES "JadwalKuliah"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -0,0 +1,5 @@
|
|||
-- DropForeignKey
|
||||
ALTER TABLE "PresensiKuliah" DROP CONSTRAINT "PresensiKuliah_jadwalKuliahId_fkey";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PresensiKuliah" ADD CONSTRAINT "PresensiKuliah_jadwalKuliahId_fkey" FOREIGN KEY ("jadwalKuliahId") REFERENCES "JadwalKuliah"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -0,0 +1,44 @@
|
|||
-- DropForeignKey
|
||||
ALTER TABLE "JadwalKuliah" DROP CONSTRAINT "JadwalKuliah_dosenId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "LaporanMahasiswa" DROP CONSTRAINT "LaporanMahasiswa_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Notifikasi" DROP CONSTRAINT "Notifikasi_senderId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Notifikasi" DROP CONSTRAINT "Notifikasi_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "PengajuanIzin" DROP CONSTRAINT "PengajuanIzin_mahasiswaId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "PesertaKuliah" DROP CONSTRAINT "PesertaKuliah_mahasiswaId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "PresensiKuliah" DROP CONSTRAINT "PresensiKuliah_mahasiswaId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "JadwalKuliah" ALTER COLUMN "dosenId" DROP NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PresensiKuliah" ADD CONSTRAINT "PresensiKuliah_mahasiswaId_fkey" FOREIGN KEY ("mahasiswaId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PengajuanIzin" ADD CONSTRAINT "PengajuanIzin_mahasiswaId_fkey" FOREIGN KEY ("mahasiswaId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notifikasi" ADD CONSTRAINT "Notifikasi_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Notifikasi" ADD CONSTRAINT "Notifikasi_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LaporanMahasiswa" ADD CONSTRAINT "LaporanMahasiswa_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "JadwalKuliah" ADD CONSTRAINT "JadwalKuliah_dosenId_fkey" FOREIGN KEY ("dosenId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PesertaKuliah" ADD CONSTRAINT "PesertaKuliah_mahasiswaId_fkey" FOREIGN KEY ("mahasiswaId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "PresensiKuliah" ALTER COLUMN "waktu_presensi" DROP NOT NULL;
|
|
@ -0,0 +1,2 @@
|
|||
-- DropIndex
|
||||
DROP INDEX "user_uid_key";
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[uid]` on the table `user` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_uid_key" ON "user"("uid");
|
|
@ -0,0 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
|
@ -0,0 +1,327 @@
|
|||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "../src/generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
DOSEN
|
||||
MAHASISWA
|
||||
}
|
||||
|
||||
enum SemesterType {
|
||||
GANJIL
|
||||
GENAP
|
||||
}
|
||||
|
||||
enum RuanganType {
|
||||
TEORI
|
||||
PRAKTIKUM
|
||||
}
|
||||
|
||||
enum Hari {
|
||||
SENIN
|
||||
SELASA
|
||||
RABU
|
||||
KAMIS
|
||||
JUMAT
|
||||
SABTU
|
||||
MINGGU
|
||||
}
|
||||
|
||||
enum StatusPresensi {
|
||||
HADIR
|
||||
TIDAK_HADIR
|
||||
IZIN
|
||||
SAKIT
|
||||
TERLAMBAT
|
||||
}
|
||||
|
||||
enum TipePengajuan {
|
||||
IZIN
|
||||
SAKIT
|
||||
}
|
||||
|
||||
enum StatusPengajuan {
|
||||
DIPROSES
|
||||
DISETUJUI
|
||||
DITOLAK
|
||||
}
|
||||
|
||||
enum AlatMode {
|
||||
MASUK
|
||||
PULANG
|
||||
PRESENSI
|
||||
REGISTRASI
|
||||
}
|
||||
|
||||
enum AlatStatus {
|
||||
AKTIF
|
||||
NONAKTIF
|
||||
ERROR
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String @map("provider_account_id")
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
@@map("accounts")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique @map("session_token")
|
||||
userId String @map("user_id")
|
||||
expires DateTime
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
uid String? @unique
|
||||
name String
|
||||
email String @unique
|
||||
password String?
|
||||
alamat String?
|
||||
no_hp String?
|
||||
foto String?
|
||||
nim String? @unique
|
||||
nip String? @unique
|
||||
is_profile_complete Boolean @default(false)
|
||||
role Role @default(MAHASISWA)
|
||||
gender String?
|
||||
|
||||
semesterId String?
|
||||
prodiId String?
|
||||
golonganId String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
semester Semester? @relation(fields: [semesterId], references: [id])
|
||||
prodi ProgramStudi? @relation(fields: [prodiId], references: [id])
|
||||
golongan Golongan? @relation(fields: [golonganId], references: [id])
|
||||
|
||||
laporans LaporanMahasiswa[] @relation("LaporanDariUser")
|
||||
presensi PresensiKuliah[] @relation
|
||||
pengajuanIzin PengajuanIzin[] @relation
|
||||
notifikasiDiterima Notifikasi[] @relation("NotifikasiUntukUser")
|
||||
notifikasiDikirim Notifikasi[] @relation("NotifikasiDariSender")
|
||||
jadwalMengajar JadwalKuliah[] @relation
|
||||
jadwalDiikuti PesertaKuliah[] @relation
|
||||
|
||||
account Account[]
|
||||
session Session[]
|
||||
|
||||
@@map("user")
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String
|
||||
expires DateTime
|
||||
|
||||
@@unique([identifier, token])
|
||||
@@map("verification_tokens")
|
||||
}
|
||||
|
||||
model Semester {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
tipe SemesterType
|
||||
|
||||
// Relasi
|
||||
jadwal_kuliah JadwalKuliah[]
|
||||
user User[]
|
||||
Golongan Golongan[]
|
||||
}
|
||||
|
||||
model Ruangan {
|
||||
id String @id @default(cuid())
|
||||
kode String @unique
|
||||
name String
|
||||
type RuanganType
|
||||
|
||||
// Relasi
|
||||
jadwal_kuliah JadwalKuliah[]
|
||||
alat_presensi AlatPresensi[]
|
||||
}
|
||||
|
||||
model ProgramStudi {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
slug String? @unique
|
||||
|
||||
// Relasi
|
||||
users User[]
|
||||
golongan Golongan[]
|
||||
jadwal_kuliah JadwalKuliah[]
|
||||
}
|
||||
|
||||
model PresensiKuliah {
|
||||
id String @id @default(cuid())
|
||||
waktu_presensi DateTime? @default(now())
|
||||
status StatusPresensi
|
||||
keterangan String?
|
||||
|
||||
mahasiswaId String
|
||||
matkulId String
|
||||
jadwalKuliahId String
|
||||
|
||||
mahasiswa User @relation(fields: [mahasiswaId], references: [id], onDelete: Cascade)
|
||||
mata_kuliah MataKuliah @relation(fields: [matkulId], references: [id])
|
||||
jadwal_kuliah JadwalKuliah @relation(fields: [jadwalKuliahId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model PengajuanIzin {
|
||||
id String @id @default(cuid())
|
||||
tanggal_izin DateTime
|
||||
tipe_pengajuan TipePengajuan
|
||||
pesan String @db.Text
|
||||
file_bukti String?
|
||||
status StatusPengajuan @default(DIPROSES)
|
||||
catatan_dosen String? @db.Text
|
||||
|
||||
mahasiswaId String
|
||||
jadwalKuliahId String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
mahasiswa User @relation(fields: [mahasiswaId], references: [id], onDelete: Cascade)
|
||||
jadwal_kuliah JadwalKuliah @relation(fields: [jadwalKuliahId], references: [id])
|
||||
}
|
||||
|
||||
model Notifikasi {
|
||||
id String @id @default(cuid())
|
||||
tipe String
|
||||
konten String @db.Text
|
||||
url_tujuan String?
|
||||
read_at DateTime?
|
||||
|
||||
userId String
|
||||
senderId String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation("NotifikasiUntukUser", fields: [userId], references: [id], onDelete: Cascade)
|
||||
sender User @relation("NotifikasiDariSender", fields: [senderId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model MataKuliah {
|
||||
id String @id @default(cuid())
|
||||
kode String @unique
|
||||
name String
|
||||
|
||||
jadwal_kuliah JadwalKuliah[]
|
||||
presensi PresensiKuliah[]
|
||||
}
|
||||
|
||||
model LaporanMahasiswa {
|
||||
id String @id @default(cuid())
|
||||
tipe String
|
||||
pesan String @db.Text
|
||||
status String @default("Belum Ditangani")
|
||||
balasan String? @db.Text
|
||||
|
||||
userId String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation("LaporanDariUser", fields: [userId], references: [id], onDelete: Cascade) // BENAR: onDelete di sisi anak
|
||||
}
|
||||
|
||||
model JadwalKuliah {
|
||||
id String @id @default(cuid())
|
||||
is_kelas_besar Boolean @default(false)
|
||||
hari Hari
|
||||
jam_mulai String
|
||||
jam_selesai String
|
||||
|
||||
dosenId String?
|
||||
matkulId String
|
||||
semesterId String
|
||||
prodiId String
|
||||
ruanganId String
|
||||
|
||||
dosen User? @relation(fields: [dosenId], references: [id], onDelete: SetNull)
|
||||
mata_kuliah MataKuliah @relation(fields: [matkulId], references: [id])
|
||||
semester Semester @relation(fields: [semesterId], references: [id])
|
||||
prodi ProgramStudi @relation(fields: [prodiId], references: [id])
|
||||
golongans Golongan[] @relation("JadwalToGolongan")
|
||||
ruangan Ruangan @relation(fields: [ruanganId], references: [id], onDelete: Restrict)
|
||||
presensi PresensiKuliah[]
|
||||
pengajuanIzin PengajuanIzin[]
|
||||
peserta PesertaKuliah[]
|
||||
}
|
||||
|
||||
model Golongan {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
prodiId String
|
||||
semesterId String
|
||||
|
||||
prodi ProgramStudi @relation(fields: [prodiId], references: [id])
|
||||
semester Semester? @relation(fields: [semesterId], references: [id])
|
||||
users User[]
|
||||
jadwal_kuliah JadwalKuliah[] @relation("JadwalToGolongan")
|
||||
|
||||
@@unique([prodiId, semesterId, name])
|
||||
}
|
||||
|
||||
model AlatPresensi {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
mode AlatMode @default(PRESENSI)
|
||||
jadwal_nyala DateTime?
|
||||
jadwal_mati DateTime?
|
||||
status AlatStatus @default(NONAKTIF)
|
||||
|
||||
ruanganId String
|
||||
|
||||
ruangan Ruangan @relation(fields: [ruanganId], references: [id])
|
||||
targetMahasiswaId String?
|
||||
}
|
||||
|
||||
model PesertaKuliah {
|
||||
id String @id @default(cuid())
|
||||
|
||||
mahasiswaId String
|
||||
jadwalKuliahId String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
mahasiswa User @relation(fields: [mahasiswaId], references: [id], onDelete: Cascade)
|
||||
jadwal_kuliah JadwalKuliah @relation(fields: [jadwalKuliahId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
|
||||
@@unique([mahasiswaId, jadwalKuliahId])
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import { PrismaClient } from "../src/generated/prisma"; // atau '@prisma/client' jika default
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const prodiList = await prisma.programStudi.findMany({ select: { id: true } });
|
||||
const semesterList = await prisma.semester.findMany({ select: { id: true } });
|
||||
|
||||
const namaGolongan = ["A", "B", "C", "D"];
|
||||
|
||||
for (const prodi of prodiList) {
|
||||
for (const semester of semesterList) {
|
||||
for (const name of namaGolongan) {
|
||||
await prisma.golongan.upsert({
|
||||
where: {
|
||||
// gunakan kombinasi unik: prodiId, semesterId, name
|
||||
prodiId_semesterId_name: {
|
||||
prodiId: prodi.id,
|
||||
semesterId: semester.id,
|
||||
name,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
name,
|
||||
prodi: { connect: { id: prodi.id } },
|
||||
semester: { connect: { id: semester.id } },
|
||||
},
|
||||
});
|
||||
console.log(`✅ Golongan '${name}' dibuat untuk prodi=${prodi.id}, semester=${semester.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error("❌ Gagal seeding golongan:", e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
Binary file not shown.
After Width: | Height: | Size: 6.1 KiB |
|
@ -0,0 +1 @@
|
|||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 391 B |
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
After Width: | Height: | Size: 1.0 KiB |
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
After Width: | Height: | Size: 1.3 KiB |
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 128 B |
|
@ -0,0 +1 @@
|
|||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
After Width: | Height: | Size: 385 B |
|
@ -0,0 +1,99 @@
|
|||
import { auth } from "@/auth";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { LuUsers, LuUser } from "react-icons/lu";
|
||||
import StatCard from "@/components/admin/dashboard/StatCard";
|
||||
import StudentsPerSemesterChart from "@/components/admin/dashboard/StudentsPerSemesterChart";
|
||||
import StudentsByProdiChart from "@/components/admin/dashboard/StudentsByProdiChart";
|
||||
import { getTotalStudents, getTotalLecturers, getStudentsPerSemester, getStudentsByProdi } from "@/lib/data";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
|
||||
const DashboardAdminPage = async () => {
|
||||
const session = await auth();
|
||||
|
||||
if (!session || !session.user || session.user.role !== "ADMIN") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
include: {
|
||||
prodi: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || user.role !== "ADMIN" || !user.prodiId || !user.prodi?.name) {
|
||||
return (
|
||||
<div className="min-h-screen p-8 bg-[#1E1E1E] text-gray-100 flex items-center justify-center">
|
||||
<p className="text-xl text-red-500">
|
||||
Akses ditolak atau Program Studi tidak ditemukan untuk admin ini.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const adminProdiId = user.prodiId;
|
||||
const adminProdiName = user.prodi.name;
|
||||
|
||||
const [totalStudents, totalLecturers, studentsPerSemester, studentsByProdi] = await Promise.all([
|
||||
getTotalStudents(adminProdiId),
|
||||
getTotalLecturers(adminProdiId),
|
||||
getStudentsPerSemester(adminProdiId),
|
||||
getStudentsByProdi(adminProdiId, adminProdiName),
|
||||
]);
|
||||
|
||||
console.log("Data from getStudentsPerSemester (Server):", studentsPerSemester);
|
||||
console.log("Data from getStudentsByProdi (Server):", studentsByProdi);
|
||||
|
||||
return (
|
||||
<div className="h-20 p-6 text-gray-900 dark:text-gray-100">
|
||||
<div className="ml-12 mb-6">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="#">Admin</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Dashboard</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mb-2 text-black dark:text-white tracking-tight">Dashboard Admin</h1>
|
||||
<p className="text-sm text-gray-900 dark:text-gray-300 mb-8">
|
||||
Program Studi - <span className="text-blue-500 dark:text-blue-400">{adminProdiName}</span>
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<StatCard
|
||||
title={`Total Mahasiswa ${adminProdiName}`}
|
||||
value={totalStudents}
|
||||
icon={<LuUsers className="w-7 h-7" />}
|
||||
/>
|
||||
<StatCard
|
||||
title={`Total Dosen ${adminProdiName}`}
|
||||
value={totalLecturers}
|
||||
icon={<LuUser className="w-7 h-7" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<StudentsPerSemesterChart data={studentsPerSemester} />
|
||||
<StudentsByProdiChart data={studentsByProdi} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardAdminPage;
|
|
@ -0,0 +1,290 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@headlessui/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
export default function LaporanMahasiswaPage() {
|
||||
const [semester, setSemester] = useState("all");
|
||||
const [golongan, setGolongan] = useState("all");
|
||||
const [status, setStatus] = useState("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [semesters, setSemesters] = useState<any[]>([]);
|
||||
const [golongans, setGolongans] = useState<any[]>([]);
|
||||
const [userProdiId, setUserProdiId] = useState<string | null>(null);
|
||||
const [laporan, setLaporan] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedLaporan, setSelectedLaporan] = useState<any>(null);
|
||||
const [balasan, setBalasan] = useState("");
|
||||
const [actionType, setActionType] = useState<"Proses" | "Selesai" | null>(null);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const laporanStatus = searchParams.get("laporan-mahasiswa");
|
||||
if (laporanStatus === "proses") {
|
||||
setStatus("Diproses");
|
||||
} else if (laporanStatus === "selesai") {
|
||||
setStatus("Selesai");
|
||||
} else {
|
||||
setStatus("all");
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleStatusChange = (value: string) => {
|
||||
setStatus(value);
|
||||
const urlParam = value === "Diproses" ? "proses" : value === "Selesai" ? "selesai" : "all";
|
||||
router.replace(`?laporan-mahasiswa=${urlParam}`);
|
||||
};
|
||||
|
||||
const handleAction = (laporan: any, type: "Proses" | "Selesai") => {
|
||||
setSelectedLaporan(laporan);
|
||||
setActionType(type);
|
||||
setBalasan(type === "Proses" ? "Laporan Anda sedang diproses." : "Laporan Anda telah selesai ditangani.");
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const fetchLaporan = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (semester && semester !== "all") params.append("semester", semester);
|
||||
if (golongan && golongan !== "all") params.append("golongan", golongan);
|
||||
if (status && status !== "all") params.append("status", status);
|
||||
if (search) params.append("search", search);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/laporan/admin?${params.toString()}`);
|
||||
const data = await res.json();
|
||||
setLaporan(data);
|
||||
} catch (err) {
|
||||
console.error("Gagal mengambil data laporan", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [semester, golongan, status, search]);
|
||||
|
||||
const handleSubmitAction = async () => {
|
||||
if (!selectedLaporan) return;
|
||||
const newStatus = actionType === "Selesai" ? "Selesai" : "Diproses";
|
||||
|
||||
await fetch(`/api/laporan/admin/update-status`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
id: selectedLaporan.id,
|
||||
status: actionType === "Selesai" ? "Selesai" : "Diproses",
|
||||
balasan,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
setModalOpen(false);
|
||||
setSelectedLaporan(null);
|
||||
setActionType(null);
|
||||
setBalasan("");
|
||||
const urlParam = newStatus === "Diproses" ? "proses" : "selesai";
|
||||
router.replace(`?laporan-mahasiswa=${urlParam}`);
|
||||
fetchLaporan();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchInitialData = async () => {
|
||||
const res = await fetch("/api/laporan/admin/init");
|
||||
const data = await res.json();
|
||||
setSemesters(data.semesters);
|
||||
setUserProdiId(data.prodiId);
|
||||
};
|
||||
fetchInitialData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!semester || semester === "all" || !userProdiId) return setGolongans([]);
|
||||
|
||||
const fetchGolongans = async () => {
|
||||
const res = await fetch(`/api/laporan/admin/golongans?semesterId=${semester}&prodiId=${userProdiId}`);
|
||||
const data = await res.json();
|
||||
setGolongans(data);
|
||||
};
|
||||
|
||||
fetchGolongans();
|
||||
}, [semester, userProdiId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLaporan();
|
||||
}, [fetchLaporan]);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Breadcrumb className="ml-12 mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/dashboard">Admin</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Laporan Mahasiswa</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Laporan Mahasiswa</h1>
|
||||
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Select onValueChange={setSemester} defaultValue="all">
|
||||
<SelectTrigger className="w-50">
|
||||
<SelectValue placeholder="Semua Semester" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Semester</SelectItem>
|
||||
{semesters.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select onValueChange={setGolongan} disabled={!semester || semester === "all"} defaultValue="all">
|
||||
<SelectTrigger className="w-50">
|
||||
<SelectValue placeholder="Semua Golongan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Golongan</SelectItem>
|
||||
{golongans.map((g) => (
|
||||
<SelectItem key={g.id} value={g.id}>
|
||||
Golongan {g.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select onValueChange={setStatus} defaultValue="all">
|
||||
<SelectTrigger className="w-50">
|
||||
<SelectValue placeholder="Semua Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua Status</SelectItem>
|
||||
<SelectItem value="Belum Ditangani">Belum Ditangani</SelectItem>
|
||||
<SelectItem value="Diproses">Diproses</SelectItem>
|
||||
<SelectItem value="Selesai">Selesai</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
className="w-100"
|
||||
placeholder="Cari nama atau NIM..."
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{loading ? (
|
||||
<>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-32 w-full rounded-xl" />
|
||||
))}
|
||||
</>
|
||||
) : laporan.length === 0 ? (
|
||||
<p>Tidak ada laporan ditemukan.</p>
|
||||
) : (
|
||||
laporan.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<CardContent className="px-6 py-2 space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<p className="font-semibold">
|
||||
{item.user?.name} ({item.user?.nim})
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.user?.semester?.name} - Golongan {item.user?.golongan?.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Tipe Laporan: {item.tipe}</p>
|
||||
<p className="text-sm">Alasan: {item.pesan}</p>
|
||||
{item.balasan && <p className="text-sm">Balasan: {item.balasan}</p>}
|
||||
</div>
|
||||
<div className="flex flex-col justify-between items-end">
|
||||
<Badge
|
||||
variant={item.status === "Selesai" ? "success" : "destructive"}
|
||||
className={cn(
|
||||
"capitalize",
|
||||
item.status === "Selesai" ? "bg-emerald-500 text-white" : "bg-red-500"
|
||||
)}
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
{item.status !== "Selesai" && (
|
||||
<div className="flex items-end justify-end gap-2 mt-2">
|
||||
{item.status !== "Diproses" && (
|
||||
<Button
|
||||
onClick={() => handleAction(item, "Proses")}
|
||||
className="bg-amber-400 dark:bg-amber-700 hover:bg-yellow-500 dark:hover:bg-amber-800 text-amber-900 dark:text-white cursor-pointer"
|
||||
>
|
||||
Proses
|
||||
</Button>
|
||||
)}
|
||||
{item.status === "Diproses" && (
|
||||
<Button
|
||||
onClick={() => handleAction(item, "Selesai")}
|
||||
className="bg-emerald-600 dark:bg-emerald-800 hover:bg-green-700 dark:hover:bg-emerald-900 text-white cursor-pointer"
|
||||
>
|
||||
Selesaikan Laporan
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{actionType === "Proses" ? "Proses Laporan" : "Selesaikan Laporan"}</DialogTitle>
|
||||
<DialogDescription>Kirim Pesan Balasan ke Mahasiswa yang terkait</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
value={balasan}
|
||||
onChange={(e) => setBalasan(e.target.value)}
|
||||
className="px-4 py-2 text-sm text-neutral-700 dark:text-neutral-400 block bg-muted/40 border border-neutral-200 dark:border-neutral-700 w-full rounded-md focus:outline-none focus:shadow-[0_0_2px_2px] shadow-neutral-400/80 transition-all duration-200 ease-in-out"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button onClick={handleSubmitAction}>Kirim</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,217 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, ChangeEvent } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { User, ProgramStudi } from "@/generated/prisma/client";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { LuCircleAlert, LuCircleArrowLeft, LuCircleArrowRight } from "react-icons/lu";
|
||||
import { BsPlusCircleDotted } from "react-icons/bs";
|
||||
|
||||
type DosenWithProdi = User & { prodi: ProgramStudi | null };
|
||||
interface FilterOption {
|
||||
id: string | number;
|
||||
name: string;
|
||||
}
|
||||
interface FilterState {
|
||||
prodi?: string;
|
||||
nip?: string;
|
||||
}
|
||||
|
||||
export default function DosenTable({
|
||||
data,
|
||||
totalPages,
|
||||
currentPage,
|
||||
filters,
|
||||
}: {
|
||||
data: DosenWithProdi[];
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
filters: { prodis: FilterOption[]; current: FilterState };
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedDosen, setSelectedDosen] = useState<DosenWithProdi | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const updateFilter = (key: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
params.set("page", "1");
|
||||
router.push(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleOpenModal = (dosen: DosenWithProdi) => {
|
||||
setSelectedDosen(dosen);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!selectedDosen) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/dosen/${selectedDosen.id}`, { method: "DELETE" });
|
||||
if (!response.ok) throw new Error("Gagal menghapus dosen.");
|
||||
setIsModalOpen(false);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("dosen", "delete_success");
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) alert(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayValue = (val?: string | null) => (val && val.trim() !== "" ? val : "-");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
className=" px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/50 dark:text-white border-gray-300 dark:border-neutral-800 text-sm focus:outline-none w-60"
|
||||
value={filters.current.prodi || ""}
|
||||
onChange={(e) => updateFilter("prodi", e.target.value)}
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900">
|
||||
Semua Program Studi
|
||||
</option>
|
||||
{filters.prodis.map((p) => (
|
||||
<option key={p.id} value={p.id.toString()} className="bg-white dark:bg-neutral-900">
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari Dosen berdasarkan NIP..."
|
||||
className="bg-white dark:bg-neutral-950/50 text-black dark:text-gray-200 border border-gray-300 dark:border-neutral-800 rounded-md px-4 py-2 focus:outline-none text-sm w-80"
|
||||
defaultValue={filters.current.nip || ""}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => updateFilter("nip", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<SubmitButton
|
||||
text="Tambah Dosen"
|
||||
href="/admin/manajemen-akademik/dosen/create"
|
||||
icon={<BsPlusCircleDotted />}
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-full hover:bg-gray-100 dark:hover:bg-black/10 hover:transition-all text-sm border border-gray-300 dark:border-neutral-800 flex items-center gap-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* --- Tabel --- */}
|
||||
<div className="overflow-auto rounded border border-gray-300 dark:border-neutral-800 shadow-sm">
|
||||
<table className="min-w-full text-sm text-left text-black dark:text-gray-200">
|
||||
<thead className="bg-gray-100 dark:bg-neutral-950/50 uppercase tracking-wide">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-semibold">NIP</th>
|
||||
<th className="px-6 py-3 font-semibold">Nama</th>
|
||||
<th className="px-6 py-3 font-semibold">Prodi</th>
|
||||
<th className="px-6 py-3 font-semibold text-center w-px whitespace-nowrap">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center py-8 text-gray-400">
|
||||
Tidak ada dosen ditemukan.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((d) => (
|
||||
<tr
|
||||
key={d.id}
|
||||
className="border-t border-gray-200 dark:border-neutral-800 hover:bg-gray-50 dark:hover:bg-neutral-950/40"
|
||||
>
|
||||
<td className="px-6 py-4 font-mono">{displayValue(d.nip)}</td>
|
||||
<td className="px-6 py-4">{displayValue(d.name)}</td>
|
||||
<td className="px-6 py-4">{displayValue(d.prodi?.name)}</td>
|
||||
<td className="px-6 py-4 flex items-center gap-4 justify-center">
|
||||
<SubmitButton
|
||||
text="Edit"
|
||||
href={`/admin/manajemen-akademik/dosen/${d.id}/edit`}
|
||||
className="bg-white w-18 dark:bg-neutral-950/40 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-md hover:bg-gray-200 dark:hover:bg-[#1A1A1A] hover:transition-all text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Hapus"
|
||||
onClick={() => handleOpenModal(d)}
|
||||
className="bg-red-700 w-18 dark:bg-red-800/80 text-white dark:text-gray-200 px-4 py-2 rounded-md hover:bg-red-800 dark:hover:bg-red-950 hover:transition-all text-sm border-none"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-400">
|
||||
Halaman {currentPage} dari {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`?page=${currentPage - 1}`}
|
||||
className={`px-3 py-1 text-sm rounded-md flex items-center gap-2 ${
|
||||
currentPage <= 1
|
||||
? "pointer-events-none bg-transparent dark:bg-black/80 border border-gray-300 dark:border-gray-800 text-gray-400"
|
||||
: "bg-gray-100 dark:bg-gray-200 border border-gray-300 dark:border-gray-800 text-gray-800 dark:text-gray-800 hover:bg-gray-200 dark:hover:bg-gray-300 hover:transition-all"
|
||||
}`}
|
||||
>
|
||||
<LuCircleArrowLeft />
|
||||
Sebelumnya
|
||||
</Link>
|
||||
<Link
|
||||
href={`?page=${currentPage + 1}`}
|
||||
className={`px-3 py-1 text-sm rounded-md flex items-center gap-2 ${
|
||||
currentPage >= totalPages
|
||||
? "pointer-events-none bg-transparent dark:bg-black/80 border border-gray-300 dark:border-gray-800 text-gray-400"
|
||||
: "bg-gray-100 dark:bg-gray-200 border border-gray-300 dark:border-gray-800 text-gray-800 dark:text-gray-800 hover:bg-gray-200 dark:hover:bg-gray-300 hover:transition-all"
|
||||
}`}
|
||||
>
|
||||
Selanjutnya
|
||||
<LuCircleArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-neutral-950 dark:backdrop-blur-sm rounded-lg p-6 w-full max-w-lg mx-4 shadow-[0_0_30px_1px_#C10007] dark:shadow-[0_0_34px_1px_#460809]">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<LuCircleAlert className="w-6 h-6 text-red-600" /> Konfirmasi Hapus
|
||||
</h2>
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
Apakah Anda yakin ingin menghapus <strong>{selectedDosen?.name}</strong>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
Tindakan ini tidak dapat dibatalkan.
|
||||
</p>
|
||||
</p>
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="bg-gray-100 dark:bg-neutral-900/50 border border-neutral-300 dark:border-neutral-800 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-md text-sm hover:bg-gray-300 dark:hover:bg-neutral-900 transition-all"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Ya, Hapus"
|
||||
isLoading={isLoading}
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 dark:bg-red-800 text-white px-4 py-2 rounded-md text-sm hover:bg-red-700 dark:hover:bg-red-950 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,283 @@
|
|||
"use client";
|
||||
|
||||
import { useState, ChangeEvent, FormEvent, useMemo, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface DosenData {
|
||||
id: string;
|
||||
name: string;
|
||||
nip: string | null;
|
||||
email: string;
|
||||
no_hp: string | null;
|
||||
alamat: string | null;
|
||||
gender: string | null;
|
||||
foto: string | null;
|
||||
prodiId: string | null;
|
||||
}
|
||||
|
||||
interface EditDosenFormProps {
|
||||
dosen: DosenData;
|
||||
prodis: Option[];
|
||||
}
|
||||
|
||||
export default function EditMahasiswaForm({ dosen, prodis }: EditDosenFormProps) {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState({
|
||||
name: dosen.name || "",
|
||||
nip: dosen.nip || "",
|
||||
email: dosen.email || "",
|
||||
no_hp: dosen.no_hp || "",
|
||||
alamat: dosen.alamat || "",
|
||||
prodi: dosen.prodiId || "",
|
||||
gender: dosen.gender || "",
|
||||
foto: null as File | null,
|
||||
});
|
||||
|
||||
const [preview, setPreview] = useState<string | null>(dosen.foto);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
if (type === "file" && e.target instanceof HTMLInputElement) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setIsUploading(true);
|
||||
setForm((prev) => ({ ...prev, foto: file }));
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPreview(reader.result as string);
|
||||
setIsUploading(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
} else {
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const imagePreview = useMemo(() => {
|
||||
if (!preview) return null;
|
||||
return (
|
||||
<Image
|
||||
key="preview"
|
||||
src={preview}
|
||||
alt="Preview Foto"
|
||||
width={200}
|
||||
height={200}
|
||||
className="object-cover"
|
||||
/>
|
||||
);
|
||||
}, [preview]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
const formData = new FormData();
|
||||
Object.entries(form).forEach(([key, value]) => {
|
||||
if (value) formData.append(key, value);
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/dosen/${dosen.id}`, {
|
||||
method: "PUT",
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Gagal menyimpan perubahan.");
|
||||
}
|
||||
router.push("/admin/manajemen-akademik/dosen?dosen=update_success");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) alert(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto px-8 py-6 bg-white dark:bg-neutral-900 rounded shadow-[0_0_10px_1px_#1a1a1a1a] dark:shadow-[0_0_20px_1px_#ffffff1a]">
|
||||
<Breadcrumb className="ml-10 mb-8">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/dashboard">Admin</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="#">Manajemen Akademik</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/manajemen-akademik/dosen">Dosen</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Edit Dosen - {dosen.name}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<form onSubmit={handleSubmit} className="flex items-start gap-8 w-full">
|
||||
<div className="space-y-4 w-3/2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<input
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Nama Lengkap"
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
name="nip"
|
||||
value={form.nip}
|
||||
onChange={handleChange}
|
||||
placeholder="NIP"
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email dan No HP */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
placeholder="Email"
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
name="no_hp"
|
||||
value={form.no_hp}
|
||||
onChange={handleChange}
|
||||
placeholder="No HP"
|
||||
autoComplete="new-password"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alamat */}
|
||||
<textarea
|
||||
name="alamat"
|
||||
value={form.alamat}
|
||||
onChange={handleChange}
|
||||
placeholder="Alamat"
|
||||
autoComplete="new-password"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<select
|
||||
name="prodi"
|
||||
value={form.prodi}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih Program Studi
|
||||
</option>
|
||||
{prodis.map((p) => (
|
||||
<option key={p.id} value={p.id} className="bg-white dark:bg-neutral-900">
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
name="gender"
|
||||
value={form.gender}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih Gender
|
||||
</option>
|
||||
<option value="LAKI-LAKI" className="bg-white dark:bg-neutral-900">
|
||||
Laki-laki
|
||||
</option>
|
||||
<option value="PEREMPUAN" className="bg-white dark:bg-neutral-900">
|
||||
Perempuan
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Submit */}
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
text="Simpan"
|
||||
isLoading={isSubmitting}
|
||||
className="bg-black dark:bg-white text-white dark:text-gray-900 dark:hover:bg-gray-200 hover:bg-black/80 px-6 py-2 rounded-md text-sm"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
href="/admin/manajemen-akademik/dosen"
|
||||
className="bg-white dark:bg-neutral-950/50 text-text-gray-900 dark:white dark:hover:bg-black/10 hover:bg-gray-200 px-6 py-2 rounded-md text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kanan: Foto */}
|
||||
<div className="flex flex-col items-center gap-2 w-1/3">
|
||||
<div className="w-50 h-61 border border-dashed border-gray-400 dark:border-neutral-600 rounded-md flex items-center justify-center overflow-hidden bg-transparent transition-opacity duration-300 will-change-opacity">
|
||||
{imagePreview ?? (
|
||||
<span className="text-sm text-gray-400 dark:text-neutral-500 text-center">Belum ada foto</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SubmitButton
|
||||
type="button"
|
||||
text={form.foto ? "Ganti Foto" : "Upload Foto"}
|
||||
isLoading={isUploading}
|
||||
className="w-50 text-center mt-2 px-4 py-2 text-sm rounded border bg-gray-100 dark:bg-neutral-950/50 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-neutral-800 hover:bg-gray-200 dark:hover:bg-black/20"
|
||||
onClick={handleUploadClick}
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="foto"
|
||||
type="file"
|
||||
name="foto"
|
||||
accept="image/*"
|
||||
onChange={handleChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
import EditDosenForm from "./EditDosenForm";
|
||||
|
||||
async function getFormData(dosenId: string) {
|
||||
const [user, prodis] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: dosenId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nip: true,
|
||||
email: true,
|
||||
no_hp: true,
|
||||
alamat: true,
|
||||
gender: true,
|
||||
foto: true,
|
||||
prodiId: true,
|
||||
},
|
||||
}),
|
||||
prisma.programStudi.findMany({ orderBy: { name: "asc" } }),
|
||||
]);
|
||||
|
||||
if (!user) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return { user, prodis };
|
||||
}
|
||||
type Params = Promise<{ id: string }>;
|
||||
|
||||
export default async function EditDosenPage({ params }: { params: Params }) {
|
||||
const { id } = await params;
|
||||
|
||||
const { user, prodis } = await getFormData(id);
|
||||
|
||||
return <EditDosenForm dosen={user} prodis={prodis.map((p) => ({ id: p.id, name: p.name }))} />;
|
||||
}
|
|
@ -0,0 +1,300 @@
|
|||
"use client";
|
||||
|
||||
import { useState, ChangeEvent, FormEvent, useMemo } from "react";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import Image from "next/image";
|
||||
import { useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CreateDosenFormProps {
|
||||
prodis: Option[];
|
||||
}
|
||||
|
||||
export default function CreateDosenForm({ prodis }: CreateDosenFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
nip: "",
|
||||
no_hp: "",
|
||||
alamat: "",
|
||||
prodi: "",
|
||||
gender: "",
|
||||
foto: null as File | null,
|
||||
});
|
||||
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
if (type === "file" && e.target instanceof HTMLInputElement) {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setForm((prev) => ({ ...prev, foto: file }));
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPreview(reader.result as string);
|
||||
setIsUploading(false);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
return;
|
||||
}
|
||||
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
setIsUploading(true);
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
setTimeout(() => {
|
||||
const file = fileInputRef.current?.files?.[0];
|
||||
if (!file) {
|
||||
setIsUploading(false);
|
||||
}
|
||||
window.removeEventListener("focus", handleWindowFocus);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
window.addEventListener("focus", handleWindowFocus);
|
||||
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
const imagePreview = useMemo(() => {
|
||||
if (!preview) return null;
|
||||
return (
|
||||
<Image
|
||||
key="preview"
|
||||
src={preview}
|
||||
alt="Preview Foto"
|
||||
width={200}
|
||||
height={200}
|
||||
className="object-cover"
|
||||
/>
|
||||
);
|
||||
}, [preview]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
const formData = new FormData();
|
||||
Object.entries(form).forEach(([key, value]) => {
|
||||
if (value) formData.append(key, value);
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/dosen", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Gagal menambahkan dosen.");
|
||||
}
|
||||
|
||||
router.push("/admin/manajemen-akademik/dosen?dosen=create_success");
|
||||
router.refresh();
|
||||
localStorage.removeItem("foto-preview");
|
||||
localStorage.removeItem("foto-preview-expiry");
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto px-8 py-6 bg-white dark:bg-neutral-900 rounded shadow-[0_0_10px_1px_#1a1a1a1a] dark:shadow-[0_0_20px_1px_#ffffff1a]">
|
||||
<Breadcrumb className="ml-10 mb-8">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/dashboard">Admin</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="#">Manajemen Akademik</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/manajemen-akademik/dosen">Dosen</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Tambah Dosen</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex items-start gap-8 w-full">
|
||||
<div className="space-y-4 w-3/2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<input
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Nama Lengkap"
|
||||
autoComplete="new-password"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
name="nip"
|
||||
value={form.nip}
|
||||
onChange={handleChange}
|
||||
placeholder="NIP"
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
placeholder="Email"
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
name="no_hp"
|
||||
value={form.no_hp}
|
||||
onChange={handleChange}
|
||||
placeholder="No HP"
|
||||
autoComplete="new-password"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alamat */}
|
||||
<textarea
|
||||
name="alamat"
|
||||
value={form.alamat}
|
||||
onChange={handleChange}
|
||||
placeholder="Alamat"
|
||||
autoComplete="new-password"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<select
|
||||
name="prodi"
|
||||
value={form.prodi}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih Program Studi
|
||||
</option>
|
||||
{prodis.map((p) => (
|
||||
<option key={p.id} value={p.id} className="bg-white dark:bg-neutral-900">
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
name="gender"
|
||||
value={form.gender}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih Gender
|
||||
</option>
|
||||
<option value="LAKI-LAKI" className="bg-white dark:bg-neutral-900">
|
||||
Laki-laki
|
||||
</option>
|
||||
<option value="PEREMPUAN" className="bg-white dark:bg-neutral-900">
|
||||
Perempuan
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-6">
|
||||
{/* Submit */}
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
text="Tambah"
|
||||
isLoading={isSubmitting}
|
||||
className="bg-black dark:bg-white text-white dark:text-gray-900 dark:hover:bg-gray-200 hover:bg-black/80 px-6 py-2 rounded-md text-sm"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
href="/admin/manajemen-akademik/dosen"
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-900 dark:text-white dark:hover:bg-black/10 hover:bg-gray-200 px-6 py-2 rounded-md text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kanan: Foto */}
|
||||
<div className="flex flex-col items-center gap-2 w-1/3">
|
||||
<div className="w-50 h-61 border border-dashed border-gray-400 dark:border-neutral-600 rounded-md flex items-center justify-center overflow-hidden bg-transparent transition-opacity duration-300 will-change-opacity">
|
||||
{imagePreview ?? (
|
||||
<span className="text-sm text-gray-400 dark:text-neutral-500 text-center">Belum ada foto</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SubmitButton
|
||||
type="button"
|
||||
text={form.foto ? "Ganti Foto" : "Upload Foto"}
|
||||
isLoading={isUploading}
|
||||
className="w-50 text-center mt-2 px-4 py-2 text-sm rounded-md border bg-gray-100 dark:bg-neutral-950/50 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-neutral-800 hover:bg-gray-200 dark:hover:bg-black/20"
|
||||
onClick={handleUploadClick}
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="foto"
|
||||
type="file"
|
||||
name="foto"
|
||||
accept="image/*"
|
||||
onChange={handleChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import CreateDosenForm from "@/app/(admins)/admin/manajemen-akademik/dosen/create/CreateDosenForm";
|
||||
|
||||
export default async function CreateDosenPage() {
|
||||
const [prodis] = await Promise.all([prisma.programStudi.findMany({ orderBy: { name: "asc" } })]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CreateDosenForm prodis={prodis.map((p) => ({ id: p.id.toString(), name: p.name }))} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import DosenTable from "./DosenTable";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
|
||||
const ITEMS_PER_PAGE = 6;
|
||||
|
||||
async function getDosen(prodiId?: string, nip?: string, page: number = 1) {
|
||||
const whereClause: Prisma.UserWhereInput = {
|
||||
role: "DOSEN",
|
||||
nip: { not: null },
|
||||
};
|
||||
|
||||
if (prodiId) {
|
||||
whereClause.prodiId = prodiId;
|
||||
}
|
||||
if (nip) {
|
||||
whereClause.nip = { contains: nip, mode: "insensitive" };
|
||||
}
|
||||
|
||||
const [data, totalCount] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: whereClause,
|
||||
take: ITEMS_PER_PAGE,
|
||||
skip: (page - 1) * ITEMS_PER_PAGE,
|
||||
include: {
|
||||
prodi: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.user.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||
|
||||
return { data, totalPages };
|
||||
}
|
||||
|
||||
async function getAllProdi() {
|
||||
return await prisma.programStudi.findMany({ orderBy: { name: "asc" } });
|
||||
}
|
||||
|
||||
export default async function ManajemenDosenPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ prodi?: string; nip?: string; page?: number }>;
|
||||
}) {
|
||||
const { prodi, nip, page } = await searchParams;
|
||||
const currentPage = page ?? 1;
|
||||
|
||||
const [{ data, totalPages }, prodis] = await Promise.all([
|
||||
getDosen(prodi, nip, currentPage),
|
||||
getAllProdi(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Breadcrumb className="ml-12 mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/dashboard">Admin</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="#">Manajemen Akademik</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Dosen</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Manajemen Dosen</h1>
|
||||
</div>
|
||||
|
||||
<DosenTable
|
||||
data={data}
|
||||
totalPages={totalPages}
|
||||
currentPage={currentPage}
|
||||
filters={{
|
||||
prodis: prodis.map((p) => ({ id: p.id, name: p.name })),
|
||||
current: { prodi, nip },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,261 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Golongan, ProgramStudi, Semester } from "@/generated/prisma/client";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { LuCircleAlert } from "react-icons/lu";
|
||||
import Link from "next/link";
|
||||
import { LuCircleArrowRight, LuCircleArrowLeft } from "react-icons/lu";
|
||||
import { BsPlusCircleDotted } from "react-icons/bs";
|
||||
|
||||
type GolonganWithProdi = Golongan & {
|
||||
prodi: ProgramStudi;
|
||||
semester: Semester | null;
|
||||
};
|
||||
|
||||
interface GolonganTableProps {
|
||||
initialData: GolonganWithProdi[];
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
prodiList: ProgramStudi[];
|
||||
semesterList: Semester[];
|
||||
currentProdiFilter?: string;
|
||||
currentSemesterFilter?: string;
|
||||
}
|
||||
|
||||
const GolonganTable = ({
|
||||
initialData,
|
||||
totalPages,
|
||||
currentPage,
|
||||
prodiList,
|
||||
semesterList,
|
||||
currentSemesterFilter,
|
||||
currentProdiFilter,
|
||||
}: GolonganTableProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedGolongan, setSelectedGolongan] = useState<GolonganWithProdi | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const createPaginationUrl = (page: number) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", page.toString());
|
||||
return `?${params.toString()}`;
|
||||
};
|
||||
|
||||
const handleFilterChange = (param: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (value) {
|
||||
params.set(param, value);
|
||||
} else {
|
||||
params.delete(param);
|
||||
}
|
||||
params.set("page", "1");
|
||||
router.push(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleOpenModal = (golongan: GolonganWithProdi) => {
|
||||
setSelectedGolongan(golongan);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedGolongan(null);
|
||||
};
|
||||
|
||||
function buildQueryWith(filters: URLSearchParams, updates: Record<string, string>) {
|
||||
const params = new URLSearchParams(filters);
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
params.set(key, value);
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!selectedGolongan) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/golongan/${selectedGolongan.id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Gagal menghapus golongan.");
|
||||
}
|
||||
|
||||
const currentParams = new URLSearchParams(Array.from(searchParams.entries()));
|
||||
const updatedQuery = buildQueryWith(currentParams, {
|
||||
page: currentPage.toString(),
|
||||
golongan: "delete_success",
|
||||
});
|
||||
|
||||
setIsModalOpen(false);
|
||||
router.push(`/admin/manajemen-akademik/golongan?${updatedQuery}`);
|
||||
console.log("Redirect to:", `/admin/manajemen-akademik/golongan?${updatedQuery}`);
|
||||
|
||||
setIsModalOpen(false);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) alert(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={currentSemesterFilter || ""}
|
||||
onChange={(e) => handleFilterChange("semester", e.target.value)}
|
||||
className="w-60 px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/50 dark:text-white border-gray-300 dark:border-gray-800 text-sm focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900">
|
||||
Semua Semester
|
||||
</option>
|
||||
{semesterList.map((smt) => (
|
||||
<option key={smt.id} value={smt.id} className="bg-white dark:bg-neutral-900">
|
||||
{smt.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
id="prodi"
|
||||
onChange={(e) => handleFilterChange("prodi", e.target.value)}
|
||||
value={currentProdiFilter || ""}
|
||||
className="w-60 px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/50 dark:text-white border-gray-300 dark:border-gray-800 text-sm focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900">
|
||||
Semua Program Studi
|
||||
</option>
|
||||
{prodiList.map((prodi) => (
|
||||
<option key={prodi.id} value={prodi.id} className="bg-white dark:bg-neutral-900">
|
||||
{prodi.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<SubmitButton
|
||||
text="Tambah Golongan"
|
||||
href="/admin/manajemen-akademik/golongan/create"
|
||||
icon={<BsPlusCircleDotted />}
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-full hover:bg-gray-100 dark:hover:bg-black/10 hover:transition-all text-sm border border-gray-300 dark:border-neutral-800 flex items-center gap-2 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-auto rounded border border-gray-300 dark:border-neutral-800 shadow-sm">
|
||||
<table className="min-w-full text-sm text-left text-black dark:text-gray-200">
|
||||
<thead className="bg-gray-100 dark:bg-neutral-950/50 uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-semibold">Semester</th>
|
||||
<th className="px-6 py-3 font-semibold ">Program Studi</th>
|
||||
<th className="px-6 py-3 font-semibold text-center">Nama Golongan</th>
|
||||
<th className="px-6 py-3 font-semibold text-center w-px whitespace-nowrap">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{initialData?.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center py-8 text-gray-400 dark:text-gray-300">
|
||||
Belum ada data golongan.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
initialData?.map((golongan) => (
|
||||
<tr
|
||||
key={golongan.id}
|
||||
className="border-t border-gray-200 dark:border-neutral-800 hover:bg-gray-50 dark:hover:bg-neutral-950/40"
|
||||
>
|
||||
<td className="px-6 py-4">{golongan.semester?.name ?? "-"}</td>
|
||||
<td className="px-6 py-4">{golongan.prodi.name}</td>
|
||||
<td className="px-6 py-4 text-center">{golongan.name}</td>
|
||||
<td className="px-6 py-4 flex items-center gap-4 justify-center">
|
||||
<SubmitButton
|
||||
href={`/admin/manajemen-akademik/golongan/${golongan.id}/edit`}
|
||||
text="Edit"
|
||||
className="bg-white w-18 dark:bg-neutral-950/40 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-md hover:bg-gray-200 dark:hover:bg-[#1A1A1A] hover:transition-all text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Hapus"
|
||||
onClick={() => handleOpenModal(golongan)}
|
||||
className="bg-red-700 w-18 dark:bg-red-800/80 text-white dark:text-gray-200 px-4 py-2 rounded-md hover:bg-red-800 dark:hover:bg-red-950 hover:transition-all text-sm border-none"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-400">
|
||||
Halaman {currentPage} dari {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={createPaginationUrl(currentPage - 1)}
|
||||
className={`px-3 py-1 text-sm rounded-md flex items-center gap-2 ${
|
||||
currentPage <= 1
|
||||
? "pointer-events-none bg-transparent dark:bg-black/80 border border-gray-300 dark:border-gray-800 text-gray-400"
|
||||
: "bg-gray-100 dark:bg-gray-200 border border-gray-300 dark:border-gray-800 text-gray-800 dark:text-gray-800 hover:bg-gray-200 dark:hover:bg-gray-300 hover:transition-all"
|
||||
}`}
|
||||
>
|
||||
<LuCircleArrowLeft />
|
||||
Sebelumnya
|
||||
</Link>
|
||||
<Link
|
||||
href={createPaginationUrl(currentPage + 1)}
|
||||
className={`px-3 py-1 text-sm rounded-md flex items-center gap-2 ${
|
||||
currentPage >= totalPages
|
||||
? "pointer-events-none bg-transparent dark:bg-black/80 border border-gray-300 dark:border-gray-800 text-gray-400"
|
||||
: "bg-gray-100 dark:bg-gray-200 border border-gray-300 dark:border-gray-800 text-gray-800 dark:text-gray-800 hover:bg-gray-200 dark:hover:bg-gray-300 hover:transition-all"
|
||||
}`}
|
||||
>
|
||||
Selanjutnya
|
||||
<LuCircleArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-neutral-950 dark:backdrop-blur-sm rounded-lg p-6 w-full max-w-lg mx-4 shadow-[0_0_30px_1px_#C10007] dark:shadow-[0_0_34px_1px_#460809]">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<LuCircleAlert className="w-6 h-6 text-red-600" /> Konfirmasi Hapus
|
||||
</h2>
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
Apakah Anda yakin ingin menghapus <strong>{selectedGolongan?.name}</strong> -{" "}
|
||||
<strong>{selectedGolongan?.prodi.name}</strong>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
Tindakan ini tidak dapat dibatalkan.
|
||||
</p>
|
||||
</p>
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
onClick={handleCloseModal}
|
||||
className="bg-gray-200 dark:bg-black dark:border dark:border-gray-800 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-md text-sm hover:bg-gray-300 dark:hover:bg-slate-800 transition-all"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Ya, Hapus"
|
||||
isLoading={isLoading}
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 dark:bg-red-800 text-white px-4 py-2 rounded-md text-sm hover:bg-red-700 dark:hover:bg-red-950 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GolonganTable;
|
|
@ -0,0 +1,142 @@
|
|||
"use client";
|
||||
|
||||
import { useState, FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Golongan, ProgramStudi as Prodi, Semester } from "@/generated/prisma/client";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
|
||||
type GolonganWithProdi = Golongan & {
|
||||
prodi: Prodi;
|
||||
};
|
||||
|
||||
interface EditGolonganFormProps {
|
||||
golongan: GolonganWithProdi;
|
||||
prodiList: Prodi[];
|
||||
semesterList: Semester[];
|
||||
}
|
||||
|
||||
export default function EditGolonganForm({ golongan, prodiList, semesterList }: EditGolonganFormProps) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const currentPage = params.get("page") || "1";
|
||||
const [form, setForm] = useState({
|
||||
name: golongan.name,
|
||||
prodiId: golongan.prodiId,
|
||||
semesterId: golongan.semesterId,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/golongan/${golongan.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: form.name,
|
||||
prodiId: form.prodiId,
|
||||
semesterId: form.semesterId,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Gagal menyimpan perubahan.");
|
||||
|
||||
router.push("/admin/manajemen-akademik/golongan?page=${currentPage}&golongan=update_success");
|
||||
router.refresh();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<div className="space-y-4 w-full">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Nama Golongan
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="Golongan A"
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="semesterId"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Semester
|
||||
</label>
|
||||
<select
|
||||
id="semesterId"
|
||||
name="semesterId"
|
||||
value={form.semesterId}
|
||||
onChange={(e) => setForm({ ...form, semesterId: e.target.value })}
|
||||
required
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih Semester
|
||||
</option>
|
||||
{semesterList.map((smt) => (
|
||||
<option key={smt.id} value={smt.id} className="bg-white dark:bg-neutral-900">
|
||||
{smt.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="prodiId"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Program Studi
|
||||
</label>
|
||||
<select
|
||||
id="prodiId"
|
||||
name="prodiId"
|
||||
value={form.prodiId}
|
||||
onChange={(e) => setForm({ ...form, prodiId: e.target.value })}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
{prodiList.map((prodi) => (
|
||||
<option key={prodi.id} value={prodi.id} className="bg-white dark:bg-neutral-900">
|
||||
{prodi.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500 dark:text-red-400">{error}</p>}
|
||||
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
text="Simpan"
|
||||
isLoading={isLoading}
|
||||
className="bg-black dark:bg-white text-white dark:text-gray-900 px-6 py-2 hover:bg-gray-800 dark:hover:bg-gray-200 rounded text-sm hover:transition-all"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
href={`/admin/manajemen-akademik/golongan?page=${currentPage}`}
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-900 dark:text-white dark:hover:bg-black/10 hover:bg-gray-200 px-6 py-2 rounded-md text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
import EditGolonganForm from "./EditGolonganForm";
|
||||
|
||||
async function getGolonganById(id: string) {
|
||||
const golongan = await prisma.golongan.findUnique({
|
||||
where: { id: id },
|
||||
include: {
|
||||
prodi: true,
|
||||
semester: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!golongan) {
|
||||
notFound();
|
||||
}
|
||||
return golongan;
|
||||
}
|
||||
|
||||
async function getAllProdi() {
|
||||
return await prisma.programStudi.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
async function getAllSemester() {
|
||||
return await prisma.semester.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
export default async function EditGolonganPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const [golongan, prodiList, semesterList] = await Promise.all([
|
||||
getGolonganById(id),
|
||||
getAllProdi(),
|
||||
getAllSemester(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mt-10 mx-auto p-8 bg-white dark:bg-black/20 rounded-md shadow-[0_0_10px_1px_#1a1a1a1a] dark:shadow-[0_0_20px_1px_#ffffff1a]">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
Edit Golongan {golongan.name} - {golongan.prodi.name} - {golongan?.semester?.name}
|
||||
</h1>
|
||||
|
||||
<EditGolonganForm golongan={golongan} prodiList={prodiList} semesterList={semesterList} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,133 @@
|
|||
"use client";
|
||||
|
||||
import { useState, FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ProgramStudi, Semester } from "@/generated/prisma/client";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
|
||||
interface CreateGolonganFormProps {
|
||||
prodiList: ProgramStudi[];
|
||||
semesterList: Semester[];
|
||||
}
|
||||
|
||||
export default function CreateGolonganForm({ prodiList, semesterList }: CreateGolonganFormProps) {
|
||||
const [form, setForm] = useState({ name: "", prodiId: "", semesterId: "" });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.prodiId) {
|
||||
setError("Program Studi harus dipilih.");
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/golongan", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...form, prodiId: form.prodiId }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Gagal menambahkan golongan.");
|
||||
router.push("/admin/manajemen-akademik/golongan?golongan=create_success");
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<div className="space-y-4 w-full ">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Nama Golongan
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value={form.name}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="Golongan A"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="semesterId"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Semester
|
||||
</label>
|
||||
<select
|
||||
id="semesterId"
|
||||
name="semesterId"
|
||||
value={form.semesterId}
|
||||
onChange={(e) => setForm({ ...form, semesterId: e.target.value })}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih Semester
|
||||
</option>
|
||||
{semesterList.map((smt) => (
|
||||
<option key={smt.id} value={smt.id} className="bg-white dark:bg-neutral-900">
|
||||
{smt.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="prodiId"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Program Studi
|
||||
</label>
|
||||
<select
|
||||
id="prodiId"
|
||||
name="prodiId"
|
||||
value={form.prodiId}
|
||||
onChange={(e) => setForm({ ...form, prodiId: e.target.value })}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih Program Studi
|
||||
</option>
|
||||
{prodiList.map((prodi) => (
|
||||
<option key={prodi.id} value={prodi.id} className="bg-white dark:bg-neutral-900">
|
||||
{prodi.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
{error && <p className="text-sm text-red-500 dark:text-red-400">{error}</p>}
|
||||
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
text="Tambah"
|
||||
isLoading={isLoading}
|
||||
className="bg-black dark:bg-white text-white dark:text-gray-900 hover:bg-slate-900 dark:hover:bg-gray-200 px-6 py-2 rounded-md text-sm"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
href="/admin/manajemen-akademik/golongan"
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-900 dark:text-white dark:hover:bg-black/10 hover:bg-gray-200 px-6 py-2 rounded-md text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import CreateGolonganForm from "./CreateGolonganForm";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
async function getAllProdi() {
|
||||
return await prisma.programStudi.findMany();
|
||||
}
|
||||
|
||||
async function getAllSemester() {
|
||||
return await prisma.semester.findMany({ orderBy: { name: "asc" } });
|
||||
}
|
||||
|
||||
export default async function CreateGolonganPage() {
|
||||
const [prodiList, semesterList] = await Promise.all([getAllProdi(), getAllSemester()]);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mt-10 mx-auto p-8 bg-white dark:bg-black/20 rounded-md shadow-[0_0_10px_1px_#1a1a1a1a] dark:shadow-[0_0_20px_1px_#ffffff1a]">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Tambah Golongan</h1>
|
||||
<CreateGolonganForm prodiList={prodiList} semesterList={semesterList} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { BsPlusCircleDotted } from "react-icons/bs";
|
||||
import GolonganTable from "./GolonganTable";
|
||||
import { Prisma } from "@/generated/prisma";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
|
||||
const ITEMS_PER_PAGE = 6;
|
||||
|
||||
async function getGolongan(prodiId: string | undefined, semesterId: string | undefined, page: number) {
|
||||
const whereClause: Prisma.GolonganWhereInput = {
|
||||
semester: { isNot: null },
|
||||
};
|
||||
|
||||
if (prodiId) {
|
||||
whereClause.prodiId = prodiId;
|
||||
}
|
||||
if (semesterId) {
|
||||
whereClause.semesterId = semesterId;
|
||||
}
|
||||
|
||||
const [data, totalCount] = await Promise.all([
|
||||
prisma.golongan.findMany({
|
||||
where: whereClause,
|
||||
take: ITEMS_PER_PAGE,
|
||||
skip: (page - 1) * ITEMS_PER_PAGE,
|
||||
orderBy: {
|
||||
name: "asc",
|
||||
},
|
||||
include: {
|
||||
prodi: true,
|
||||
semester: true,
|
||||
},
|
||||
}),
|
||||
prisma.golongan.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
return { data, currentPage: page, totalPages: Math.ceil(totalCount / ITEMS_PER_PAGE) };
|
||||
}
|
||||
|
||||
async function getAllProdi() {
|
||||
return await prisma.programStudi.findMany({ orderBy: { name: "asc" } });
|
||||
}
|
||||
|
||||
async function getAllSemester() {
|
||||
return await prisma.semester.findMany({ orderBy: { name: "asc" } });
|
||||
}
|
||||
|
||||
export default async function ManajemenGolonganPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ prodi?: string; semester?: string; page?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const { prodi, semester, page } = params;
|
||||
const prodiId = prodi || undefined;
|
||||
const semesterId = semester || undefined;
|
||||
const currentPage = Number(page || 1);
|
||||
|
||||
const [{ data, totalPages }, prodiList, semesterList] = await Promise.all([
|
||||
getGolongan(prodiId, semesterId, currentPage),
|
||||
getAllProdi(),
|
||||
getAllSemester(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto p-6 rounded-lg shadow">
|
||||
<Breadcrumb className="ml-12 mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/dashboard">Admin</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="#">Manajemen Akademik</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Golongan</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Manajemen Golongan</h1>
|
||||
<GolonganTable
|
||||
initialData={data}
|
||||
totalPages={totalPages}
|
||||
currentPage={currentPage}
|
||||
prodiList={prodiList}
|
||||
currentProdiFilter={prodiId}
|
||||
semesterList={semesterList}
|
||||
currentSemesterFilter={semesterId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,333 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { LuCircleAlert, LuCircleArrowLeft, LuCircleArrowRight } from "react-icons/lu";
|
||||
import Link from "next/link";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
type JadwalKuliahData = {
|
||||
id: string;
|
||||
hari: string;
|
||||
jam_mulai: string;
|
||||
jam_selesai: string;
|
||||
mata_kuliah: { name: string };
|
||||
dosen: { name: string } | null;
|
||||
semester: { name: string };
|
||||
golongans: { name: string }[];
|
||||
ruangan: { name: string };
|
||||
prodi: { name: string };
|
||||
};
|
||||
|
||||
interface FilterOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface JadwalKuliahTableProps {
|
||||
data: JadwalKuliahData[];
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
filters: {
|
||||
semesters: FilterOption[];
|
||||
golongans: FilterOption[];
|
||||
current: { semester?: string; golongan?: string; search?: string };
|
||||
};
|
||||
prodiId: string;
|
||||
}
|
||||
|
||||
export default function JadwalKuliahTable({
|
||||
data,
|
||||
totalPages,
|
||||
currentPage,
|
||||
filters,
|
||||
prodiId,
|
||||
}: JadwalKuliahTableProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// --- 1. State Management untuk Modal Delete ---
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedJadwal, setSelectedJadwal] = useState<JadwalKuliahData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [localSemester, setLocalSemester] = useState(filters.current.semester || "");
|
||||
const [localGolongan, setLocalGolongan] = useState(filters.current.golongan || "");
|
||||
const [golonganOptions, setGolonganOptions] = useState<FilterOption[]>(filters.golongans);
|
||||
|
||||
useEffect(() => {
|
||||
if (!localSemester) {
|
||||
setGolonganOptions([]);
|
||||
setLocalGolongan("");
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchGolongan = async () => {
|
||||
const res = await fetch(`/api/golongan?semesterId=${localSemester}&prodiId=${prodiId}`);
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
setGolonganOptions(data);
|
||||
} else {
|
||||
console.error("API /api/golongan tidak mengembalikan array:", data);
|
||||
setGolonganOptions([]);
|
||||
}
|
||||
setLocalGolongan(""); // reset pilihan golongan
|
||||
};
|
||||
|
||||
fetchGolongan();
|
||||
}, [localSemester, prodiId]);
|
||||
|
||||
// --- 2. Event Handlers ---
|
||||
const handleFilterChange = (key: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
params.set("page", "1");
|
||||
router.push(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleSearch = useDebouncedCallback((term: string) => {
|
||||
handleFilterChange("search", term);
|
||||
}, 500);
|
||||
|
||||
const handleOpenModal = (jadwal: JadwalKuliahData) => {
|
||||
setSelectedJadwal(jadwal);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedJadwal(null);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!selectedJadwal) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/jadwal-kuliah/${selectedJadwal.id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Gagal menghapus jadwal.");
|
||||
}
|
||||
handleCloseModal();
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("jadwal-kuliah", "delete_success");
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) alert(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createPageURL = (pageNumber: number | string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", pageNumber.toString());
|
||||
return `?${params.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* --- Filter --- */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<select
|
||||
id="semester"
|
||||
value={localSemester}
|
||||
onChange={(e) => {
|
||||
const semesterValue = e.target.value;
|
||||
setLocalSemester(semesterValue);
|
||||
setLocalGolongan("");
|
||||
handleFilterChange("semester", semesterValue);
|
||||
}}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/50 dark:text-white border-gray-300 dark:border-neutral-800 text-sm focus:outline-none"
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900">
|
||||
Semua Semester
|
||||
</option>
|
||||
{filters.semesters.map((s) => (
|
||||
<option key={s.id} value={s.id} className="bg-white dark:bg-neutral-900">
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
id="golongan"
|
||||
value={localGolongan}
|
||||
onChange={(e) => {
|
||||
setLocalGolongan(e.target.value);
|
||||
handleFilterChange("golongan", e.target.value);
|
||||
}}
|
||||
disabled={!localSemester || golonganOptions.length === 0}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/50 dark:text-white border-gray-300 dark:border-neutral-800 text-sm focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{!localSemester && (
|
||||
<option value="" className="bg-white dark:bg-neutral-900">
|
||||
Pilih semester terlebih dahulu
|
||||
</option>
|
||||
)}
|
||||
|
||||
{golonganOptions.length === 0 && (
|
||||
<option value="" className="bg-white dark:bg-neutral-900">
|
||||
Tidak ada golongan
|
||||
</option>
|
||||
)}
|
||||
<option value="" className="bg-white dark:bg-neutral-900">
|
||||
Semua Golongan
|
||||
</option>
|
||||
{golonganOptions.map((g) => (
|
||||
<option key={g.id} value={g.id} className="bg-white dark:bg-neutral-900">
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
id="search"
|
||||
type="text"
|
||||
placeholder="Cari Matkul, Dosen, Ruangan..."
|
||||
defaultValue={filters.current.search || ""}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full bg-white dark:bg-neutral-950/50 text-black dark:text-gray-200 border border-gray-300 dark:border-neutral-800 rounded-md px-4 py-2 focus:outline-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- Tabel --- */}
|
||||
<div className="overflow-auto rounded border border-gray-300 dark:border-neutral-800">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-100 dark:bg-neutral-950/50 uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-semibold text-center">Hari & Jam</th>
|
||||
<th className="px-4 py-3 font-semibold text-center">Mata Kuliah </th>
|
||||
<th className="px-4 py-3 font-semibold text-center">Dosen</th>
|
||||
<th className="px-4 py-3 font-semibold text-center">Smt/Gol</th>
|
||||
<th className="px-4 py-3 font-semibold text-center">Ruangan</th>
|
||||
<th className="px-4 py-3 font-semibold text-center">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-8 text-gray-400 dark:text-gray-300">
|
||||
Tidak ada jadwal ditemukan.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((jadwal) => (
|
||||
<tr
|
||||
key={jadwal.id}
|
||||
className="border-t border-gray-200 dark:border-neutral-800 hover:bg-gray-50 dark:hover:bg-neutral-950/40"
|
||||
>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<div className="font-semibold">{jadwal.hari}</div>
|
||||
<div className="text-xs font-mono">
|
||||
{jadwal.jam_mulai} - {jadwal.jam_selesai}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">{jadwal.mata_kuliah.name}</td>
|
||||
<td className="px-4 py-3 text-center">{jadwal?.dosen?.name}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{jadwal.semester.name} / {jadwal.golongans.map((g) => g.name).join(", ")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">{jadwal.ruangan.name}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<SubmitButton
|
||||
href={`/admin/manajemen-akademik/jadwal-kuliah/${jadwal.id}/edit`}
|
||||
text="Edit"
|
||||
className="bg-white w-18 dark:bg-neutral-950/40 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-md hover:bg-gray-200 dark:hover:bg-[#1A1A1A] hover:transition-all text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Hapus"
|
||||
onClick={() => handleOpenModal(jadwal)}
|
||||
className="bg-red-700 w-18 dark:bg-red-800/80 text-white dark:text-gray-200 px-4 py-2 rounded-md hover:bg-red-800 dark:hover:bg-red-950 hover:transition-all text-sm border-none"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<span className="text-sm">
|
||||
Halaman {currentPage} dari {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={createPageURL(currentPage - 1)}
|
||||
className={`px-3 py-1 text-sm rounded-md flex items-center gap-2 ${
|
||||
currentPage <= 1
|
||||
? "pointer-events-none bg-transparent dark:bg-black/80 border border-gray-300 dark:border-neutral-800 text-gray-400"
|
||||
: "bg-gray-100 dark:bg-gray-200 border border-gray-300 dark:border-neutral-800 text-gray-800 dark:text-gray-800 hover:bg-gray-200 dark:hover:bg-gray-300 hover:transition-all"
|
||||
}`}
|
||||
>
|
||||
<LuCircleArrowLeft /> Sebelumnya
|
||||
</Link>
|
||||
<Link
|
||||
href={createPageURL(currentPage + 1)}
|
||||
className={`px-3 py-1 text-sm rounded-md flex items-center gap-2 ${
|
||||
currentPage >= totalPages
|
||||
? "pointer-events-none bg-transparent dark:bg-black/80 border border-gray-300 dark:border-neutral-800 text-gray-400"
|
||||
: "bg-gray-100 dark:bg-gray-200 border border-gray-300 dark:border-neutral-800 text-gray-800 dark:text-gray-800 hover:bg-gray-200 dark:hover:bg-gray-300 hover:transition-all"
|
||||
}`}
|
||||
>
|
||||
Selanjutnya <LuCircleArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal Konfirmasi Hapus */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-neutral-950 dark:backdrop-blur-sm rounded-lg p-6 w-full max-w-2xl mx-4 shadow-[0_0_30px_1px_#C10007] dark:shadow-[0_0_34px_1px_#460809]">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<LuCircleAlert className="w-6 h-6 text-red-600" />
|
||||
Konfirmasi Hapus
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
Apakah Anda yakin ingin menghapus jadwal :
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 font-bold">
|
||||
{selectedJadwal?.mata_kuliah.name} - {selectedJadwal?.semester.name} -{" "}
|
||||
{selectedJadwal?.prodi.name} - Golongan{" "}
|
||||
{selectedJadwal?.golongans.map((g) => g.name).join(", ")}
|
||||
</p>
|
||||
<p className=" text-sm text-gray-600 dark:text-gray-300">
|
||||
Tindakan ini tidak dapat dibatalkan.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
onClick={handleCloseModal}
|
||||
className="bg-gray-100 dark:bg-neutral-900/50 border border-neutral-300 dark:border-neutral-800 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-md text-sm hover:bg-gray-300 dark:hover:bg-neutral-900 transition-all"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Ya, Hapus"
|
||||
isLoading={isLoading}
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 dark:bg-red-800 text-white px-4 py-2 rounded-md text-sm hover:bg-red-700 dark:hover:bg-red-950 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,394 @@
|
|||
"use client";
|
||||
|
||||
import { useState, FormEvent, ChangeEvent, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { Hari } from "@/generated/prisma/client";
|
||||
|
||||
interface FormOptions {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
interface RuanganOption {
|
||||
id: string;
|
||||
kode: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
type JadwalData = {
|
||||
id: string;
|
||||
hari: Hari;
|
||||
jam_mulai: string;
|
||||
jam_selesai: string;
|
||||
is_kelas_besar: boolean;
|
||||
matkulId: string;
|
||||
dosenId: string | null;
|
||||
semesterId: string;
|
||||
prodiId: string;
|
||||
ruanganId: string;
|
||||
golongans: { id: string }[];
|
||||
prodi: { id: string; name: string };
|
||||
};
|
||||
|
||||
interface EditJadwalFormProps {
|
||||
prodiId: string;
|
||||
jadwal: JadwalData;
|
||||
semesters: FormOptions[];
|
||||
dosens: FormOptions[];
|
||||
mataKuliahs: FormOptions[];
|
||||
ruangans: RuanganOption[];
|
||||
}
|
||||
|
||||
export default function EditJadwalForm({
|
||||
prodiId,
|
||||
jadwal,
|
||||
semesters,
|
||||
dosens,
|
||||
mataKuliahs,
|
||||
ruangans,
|
||||
}: EditJadwalFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
hari: jadwal.hari,
|
||||
jam_mulai: jadwal.jam_mulai,
|
||||
jam_selesai: jadwal.jam_selesai,
|
||||
matkulId: jadwal.matkulId,
|
||||
dosenId: jadwal.dosenId,
|
||||
semesterId: jadwal.semesterId.toString(),
|
||||
ruanganId: jadwal.ruanganId.toString(),
|
||||
golonganSelection: jadwal.is_kelas_besar ? "__KELAS_BESAR__" : jadwal.golongans?.[0]?.id || "",
|
||||
});
|
||||
|
||||
const [filteredGolongans, setFilteredGolongans] = useState<FormOptions[]>([]);
|
||||
const [isLoadingGolongan, setIsLoadingGolongan] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!form.semesterId) return;
|
||||
const fetchGolongans = async () => {
|
||||
setIsLoadingGolongan(true);
|
||||
try {
|
||||
const res = await fetch(`/api/golongan?semesterId=${form.semesterId}&prodiId=${prodiId}`);
|
||||
if (!res.ok) throw new Error("Gagal mengambil data golongan");
|
||||
const data: FormOptions[] = await res.json();
|
||||
setFilteredGolongans(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setFilteredGolongans([]);
|
||||
} finally {
|
||||
setIsLoadingGolongan(false);
|
||||
}
|
||||
};
|
||||
fetchGolongans();
|
||||
}, [form.semesterId, prodiId]);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
if (name === "semesterId") {
|
||||
setForm((prev) => ({ ...prev, semesterId: value, golonganSelection: "" }));
|
||||
} else {
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
if (form.jam_mulai >= form.jam_selesai) {
|
||||
setError("Jam mulai harus lebih awal daripada jam selesai.");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (!form.golonganSelection) {
|
||||
setError("Anda harus memilih Golongan atau Kelas Besar.");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let golonganIdsToSend: string[] = [];
|
||||
const isKelasBesar = form.golonganSelection === "__KELAS_BESAR__";
|
||||
|
||||
if (isKelasBesar) {
|
||||
golonganIdsToSend = filteredGolongans.map((g) => g.id);
|
||||
} else {
|
||||
golonganIdsToSend = [form.golonganSelection];
|
||||
}
|
||||
|
||||
try {
|
||||
function convertTimeToISO(time: string): string {
|
||||
const [hours, minutes] = time.split(":").map(Number);
|
||||
const date = new Date();
|
||||
|
||||
date.setHours(hours);
|
||||
date.setMinutes(minutes);
|
||||
date.setSeconds(0);
|
||||
date.setMilliseconds(0);
|
||||
|
||||
return date.toISOString();
|
||||
}
|
||||
const res = await fetch(`/api/jadwal-kuliah/${jadwal.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
hari: form.hari,
|
||||
jam_mulai: form.jam_mulai,
|
||||
jam_selesai: form.jam_selesai,
|
||||
matkulId: form.matkulId,
|
||||
dosenId: form.dosenId,
|
||||
semesterId: form.semesterId,
|
||||
ruanganId: form.ruanganId,
|
||||
prodiId,
|
||||
is_kelas_besar: isKelasBesar,
|
||||
golonganIds: golonganIdsToSend,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error || "Gagal menyimpan perubahan.");
|
||||
router.push("/admin/manajemen-akademik/jadwal-kuliah?jadwal-kuliah=update_success");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : "Terjadi kesalahan.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<div className="space-y-4 w-full">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="hari" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Hari
|
||||
</label>
|
||||
<select
|
||||
name="hari"
|
||||
value={form.hari}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900" disabled>
|
||||
Pilih Hari
|
||||
</option>
|
||||
{Object.values(Hari).map((hari) => (
|
||||
<option key={hari} value={hari} className="bg-white dark:bg-neutral-900">
|
||||
{hari}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="matkulId"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Mata Kuliah
|
||||
</label>
|
||||
<select
|
||||
name="matkulId"
|
||||
value={form.matkulId}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900" disabled>
|
||||
Pilih Mata Kuliah
|
||||
</option>
|
||||
{mataKuliahs.map((mk) => (
|
||||
<option key={mk.id} value={mk.id.toString()} className="bg-white dark:bg-neutral-900">
|
||||
{mk.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="ruanganId"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Ruangan
|
||||
</label>
|
||||
<select
|
||||
name="ruanganId"
|
||||
value={form.ruanganId}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900" disabled>
|
||||
Pilih Ruangan
|
||||
</option>
|
||||
{ruangans.map((r) => (
|
||||
<option key={r.id} value={r.id.toString()} className="bg-white dark:bg-neutral-900">
|
||||
{r.kode} - {r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="dosenId"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Dosen
|
||||
</label>
|
||||
<select
|
||||
name="dosenId"
|
||||
value={form.dosenId ?? ""}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900" disabled>
|
||||
Pilih Dosen
|
||||
</option>
|
||||
{dosens.map((dosen) => (
|
||||
<option key={dosen.id} value={dosen.id.toString()} className="bg-white dark:bg-neutral-900">
|
||||
{dosen.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="semesterId"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Semester
|
||||
</label>
|
||||
<select
|
||||
name="semesterId"
|
||||
value={form.semesterId}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900" disabled>
|
||||
Pilih Semester
|
||||
</option>
|
||||
{semesters.map((s) => (
|
||||
<option key={s.id} value={s.id.toString()} className="bg-white dark:bg-neutral-900">
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={prodiId}
|
||||
disabled
|
||||
hidden
|
||||
className="w-full border p-2 rounded bg-gray-100 cursor-not-allowed"
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="jam_mulai"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Jam mulai
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
name="jam_mulai"
|
||||
value={form.jam_mulai}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="golonganSelection" className="block text-sm font-medium mb-1">
|
||||
Golongan / Kelas
|
||||
</label>
|
||||
<select
|
||||
id="golonganSelection"
|
||||
name="golonganSelection"
|
||||
value={form.golonganSelection}
|
||||
onChange={handleChange}
|
||||
disabled={!form.semesterId || isLoadingGolongan || filteredGolongans.length === 0}
|
||||
className="w-full px-4 py-2 border rounded bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm disabled:cursor-not-allowed disabled:opacity-50 placeholder-gray-700/50 dark:placeholder-neutral-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
{/* Opsi saat loading */}
|
||||
{isLoadingGolongan && (
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Memuat golongan...
|
||||
</option>
|
||||
)}
|
||||
|
||||
{/* Opsi saat belum pilih semester */}
|
||||
{!isLoadingGolongan && !form.semesterId && (
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih semester terlebih dahulu
|
||||
</option>
|
||||
)}
|
||||
|
||||
{/* Opsi saat tidak ada golongan */}
|
||||
{!isLoadingGolongan && form.semesterId && filteredGolongans.length === 0 && (
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Tidak ada golongan di semester ini
|
||||
</option>
|
||||
)}
|
||||
|
||||
{/* Opsi default + data golongan */}
|
||||
{filteredGolongans.length > 0 && (
|
||||
<>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih Golongan
|
||||
</option>
|
||||
<option value="__KELAS_BESAR__" className="font-semibold bg-white dark:bg-neutral-900">
|
||||
Kelas Besar (Gabungan Semua Golongan)
|
||||
</option>
|
||||
{filteredGolongans.map((g) => (
|
||||
<option key={g.id} value={g.id} className="bg-white dark:bg-neutral-900">
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="jam_selesai"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Jam selesai
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
name="jam_selesai"
|
||||
value={form.jam_selesai}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
text="Simpan"
|
||||
isLoading={isSubmitting}
|
||||
className="bg-black dark:bg-white text-white dark:text-gray-900 dark:hover:bg-gray-200 hover:bg-black/80 px-6 py-2 rounded-md text-sm"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
href="/admin/manajemen-akademik/jadwal-kuliah"
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-900 dark:text-white dark:hover:bg-black/10 hover:bg-gray-200 px-6 py-2 rounded-md text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import { auth } from "@/auth";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import EditJadwalForm from "./EditJadwalForm";
|
||||
|
||||
async function getFormData(adminProdiId: string, jadwalId: string) {
|
||||
const [jadwal, semesters, dosens, mataKuliahs, ruangans] = await Promise.all([
|
||||
prisma.jadwalKuliah.findUnique({
|
||||
where: { id: jadwalId, prodiId: adminProdiId },
|
||||
include: {
|
||||
golongans: { select: { id: true } },
|
||||
prodi: true,
|
||||
},
|
||||
}),
|
||||
prisma.semester.findMany({ orderBy: { name: "asc" } }),
|
||||
prisma.user.findMany({
|
||||
where: { role: "DOSEN", prodiId: adminProdiId },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.mataKuliah.findMany({ orderBy: { name: "asc" } }),
|
||||
prisma.ruangan.findMany({ orderBy: { kode: "asc" } }),
|
||||
]);
|
||||
|
||||
if (!jadwal) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return { jadwal, semesters, dosens, mataKuliahs, ruangans };
|
||||
}
|
||||
|
||||
export default async function EditJadwalPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||
redirect("/login?error=unauthorized");
|
||||
}
|
||||
|
||||
const adminUser = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { prodiId: true },
|
||||
});
|
||||
|
||||
if (!adminUser || !adminUser.prodiId) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-xl text-red-500">
|
||||
Akses ditolak: Akun admin tidak terasosiasi dengan Program Studi.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { jadwal, ...formData } = await getFormData(adminUser.prodiId, id);
|
||||
|
||||
// Serialisasi objek Date sebelum dikirim ke client
|
||||
const serializableJadwal = {
|
||||
...jadwal,
|
||||
jam_mulai: jadwal.jam_mulai,
|
||||
jam_selesai: jadwal.jam_selesai,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mt-10 mx-auto p-8 bg-white dark:bg-black/20 rounded-md shadow">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Edit Jadwal Kuliah</h1>
|
||||
<EditJadwalForm prodiId={adminUser.prodiId} jadwal={serializableJadwal} {...formData} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,379 @@
|
|||
"use client";
|
||||
|
||||
import { useState, FormEvent, ChangeEvent, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { Hari } from "@/generated/prisma/client";
|
||||
|
||||
interface FormOptions {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface RuanganOption {
|
||||
id: string;
|
||||
kode: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CreateJadwalFormProps {
|
||||
prodiId: string;
|
||||
semesters: FormOptions[];
|
||||
dosens: FormOptions[];
|
||||
mataKuliahs: FormOptions[];
|
||||
ruangans: RuanganOption[];
|
||||
golongans: FormOptions[];
|
||||
}
|
||||
|
||||
export default function CreateJadwalForm({
|
||||
prodiId,
|
||||
semesters,
|
||||
dosens,
|
||||
mataKuliahs,
|
||||
ruangans,
|
||||
}: CreateJadwalFormProps) {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState({
|
||||
hari: "",
|
||||
jam_mulai: "",
|
||||
jam_selesai: "",
|
||||
matkulId: "",
|
||||
dosenId: "",
|
||||
semesterId: "",
|
||||
golonganSelection: "",
|
||||
ruanganId: "",
|
||||
});
|
||||
|
||||
const [filteredGolongans, setFilteredGolongans] = useState<FormOptions[]>([]);
|
||||
const [isLoadingGolongan, setIsLoadingGolongan] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!form.semesterId) {
|
||||
setFilteredGolongans([]);
|
||||
setForm((prev) => ({ ...prev, golonganSelection: "" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchGolongans = async () => {
|
||||
setIsLoadingGolongan(true);
|
||||
try {
|
||||
const res = await fetch(`/api/golongan?semesterId=${form.semesterId}&prodiId=${prodiId}`);
|
||||
if (!res.ok) throw new Error("Gagal mengambil data golongan");
|
||||
const data: FormOptions[] = await res.json();
|
||||
setFilteredGolongans(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setFilteredGolongans([]);
|
||||
} finally {
|
||||
setIsLoadingGolongan(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchGolongans();
|
||||
setForm((prev) => ({ ...prev, golonganSelection: "" }));
|
||||
}, [form.semesterId, prodiId]);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
if (!form.semesterId || !form.ruanganId || !form.golonganSelection) {
|
||||
setError("Semua pilihan dropdown harus diisi.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.jam_mulai >= form.jam_selesai) {
|
||||
setError("Jam mulai harus lebih awal daripada jam selesai.");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let golonganIdsToSend: string[] = [];
|
||||
let isKelasBesar = false;
|
||||
|
||||
if (form.golonganSelection === "__KELAS_BESAR__") {
|
||||
isKelasBesar = true;
|
||||
golonganIdsToSend = filteredGolongans.map((g) => g.id);
|
||||
} else if (form.golonganSelection) {
|
||||
isKelasBesar = false;
|
||||
golonganIdsToSend = [form.golonganSelection];
|
||||
}
|
||||
|
||||
if (golonganIdsToSend.length === 0) {
|
||||
setError("Anda harus memilih Golongan atau Kelas Besar.");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/jadwal-kuliah", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
hari: form.hari,
|
||||
jam_mulai: form.jam_mulai,
|
||||
jam_selesai: form.jam_selesai,
|
||||
matkulId: form.matkulId,
|
||||
dosenId: form.dosenId,
|
||||
semesterId: form.semesterId,
|
||||
ruanganId: form.ruanganId,
|
||||
prodiId,
|
||||
is_kelas_besar: isKelasBesar,
|
||||
golonganIds: golonganIdsToSend,
|
||||
}),
|
||||
});
|
||||
console.log("res", res);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "Gagal menambahkan jadwal.");
|
||||
|
||||
router.push("/admin/manajemen-akademik/jadwal-kuliah?jadwal-kuliah=create_success");
|
||||
router.refresh();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Terjadi kesalahan.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<div className="space-y-4 w-full">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="hari" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Hari
|
||||
</label>
|
||||
<select
|
||||
name="hari"
|
||||
value={form.hari}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900" disabled>
|
||||
Pilih Hari
|
||||
</option>
|
||||
{Object.values(Hari).map((hari) => (
|
||||
<option key={hari} value={hari} className="bg-white dark:bg-neutral-900">
|
||||
{hari}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="matkulId"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Mata Kuliah
|
||||
</label>
|
||||
<select
|
||||
name="matkulId"
|
||||
value={form.matkulId}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900" disabled>
|
||||
Pilih Mata Kuliah
|
||||
</option>
|
||||
{mataKuliahs.map((mk) => (
|
||||
<option key={mk.id} value={mk.id.toString()} className="bg-white dark:bg-neutral-900">
|
||||
{mk.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="ruanganId"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Ruangan
|
||||
</label>
|
||||
<select
|
||||
name="ruanganId"
|
||||
value={form.ruanganId}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900" disabled>
|
||||
Pilih Ruangan
|
||||
</option>
|
||||
{ruangans.map((r) => (
|
||||
<option key={r.id} value={r.id.toString()} className="bg-white dark:bg-neutral-900">
|
||||
{r.kode} - {r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="dosenId"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Dosen
|
||||
</label>
|
||||
<select
|
||||
name="dosenId"
|
||||
value={form.dosenId}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900" disabled>
|
||||
Pilih Dosen
|
||||
</option>
|
||||
{dosens.map((dosen) => (
|
||||
<option key={dosen.id} value={dosen.id.toString()} className="bg-white dark:bg-neutral-900">
|
||||
{dosen.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="semesterId"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Semester
|
||||
</label>
|
||||
<select
|
||||
name="semesterId"
|
||||
value={form.semesterId}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900" disabled>
|
||||
Pilih Semester
|
||||
</option>
|
||||
{semesters.map((s) => (
|
||||
<option key={s.id} value={s.id.toString()} className="bg-white dark:bg-neutral-900">
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={prodiId}
|
||||
disabled
|
||||
hidden
|
||||
className="w-full border p-2 rounded bg-gray-100 cursor-not-allowed"
|
||||
/>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="jam_mulai"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Jam mulai
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
name="jam_mulai"
|
||||
value={form.jam_mulai}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="golonganSelection" className="block text-sm font-medium mb-1">
|
||||
Golongan / Kelas
|
||||
</label>
|
||||
<select
|
||||
id="golonganSelection"
|
||||
name="golonganSelection"
|
||||
value={form.golonganSelection}
|
||||
onChange={handleChange}
|
||||
disabled={!form.semesterId || isLoadingGolongan || filteredGolongans.length === 0}
|
||||
className="w-full px-4 py-2 border rounded bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm disabled:cursor-not-allowed disabled:opacity-50 placeholder-gray-700/50 dark:placeholder-neutral-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
{/* Opsi saat loading */}
|
||||
{isLoadingGolongan && (
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Memuat golongan...
|
||||
</option>
|
||||
)}
|
||||
|
||||
{/* Opsi saat belum pilih semester */}
|
||||
{!isLoadingGolongan && !form.semesterId && (
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih semester terlebih dahulu
|
||||
</option>
|
||||
)}
|
||||
|
||||
{/* Opsi saat tidak ada golongan */}
|
||||
{!isLoadingGolongan && form.semesterId && filteredGolongans.length === 0 && (
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Tidak ada golongan di semester ini
|
||||
</option>
|
||||
)}
|
||||
|
||||
{/* Opsi default + data golongan */}
|
||||
{filteredGolongans.length > 0 && (
|
||||
<>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih Golongan
|
||||
</option>
|
||||
<option value="__KELAS_BESAR__" className="font-semibold bg-white dark:bg-neutral-900">
|
||||
Kelas Besar (Gabungan Semua Golongan)
|
||||
</option>
|
||||
{filteredGolongans.map((g) => (
|
||||
<option key={g.id} value={g.id} className="bg-white dark:bg-neutral-900">
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="jam_selesai"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Jam selesai
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
name="jam_selesai"
|
||||
value={form.jam_selesai}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
text="Tambah"
|
||||
isLoading={isSubmitting}
|
||||
className="bg-black dark:bg-white text-white dark:text-gray-900 dark:hover:bg-gray-200 hover:bg-black/80 px-6 py-2 rounded-md text-sm"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
href="/admin/manajemen-akademik/jadwal-kuliah"
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-900 dark:text-white dark:hover:bg-black/10 hover:bg-gray-200 px-6 py-2 rounded-md text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import CreateJadwalForm from "./CreateJadwalForm";
|
||||
|
||||
async function getFormData(adminProdiId: string) {
|
||||
const [semesters, dosens, mataKuliahs, golongans, ruangans] = await Promise.all([
|
||||
prisma.semester.findMany({ orderBy: { name: "asc" } }),
|
||||
prisma.user.findMany({
|
||||
where: { role: "DOSEN", prodiId: adminProdiId },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.mataKuliah.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.golongan.findMany({
|
||||
where: { prodiId: adminProdiId },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.ruangan.findMany({ orderBy: { kode: "asc" } }),
|
||||
]);
|
||||
|
||||
return { semesters, dosens, mataKuliahs, golongans, ruangans };
|
||||
}
|
||||
|
||||
export default async function CreateJadwalPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||
redirect("/login?error=unauthorized");
|
||||
}
|
||||
|
||||
const adminUser = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { prodiId: true },
|
||||
});
|
||||
|
||||
if (!adminUser || !adminUser.prodiId) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-xl text-red-500">
|
||||
Akses ditolak: Akun admin Anda tidak terasosiasi dengan Program Studi.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formData = await getFormData(adminUser.prodiId);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mt-10 mx-auto p-8 bg-white dark:bg-black/20 rounded-md shadow-[0_0_10px_1px_#1a1a1a1a] dark:shadow-[0_0_20px_1px_#ffffff1a]">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Buat Jadwal Kuliah Baru</h1>
|
||||
<CreateJadwalForm
|
||||
prodiId={adminUser.prodiId}
|
||||
semesters={formData.semesters.map((s) => ({ id: s.id.toString(), name: s.name }))}
|
||||
dosens={formData.dosens.map((d) => ({ id: d.id, name: d.name }))}
|
||||
mataKuliahs={formData.mataKuliahs.map((mk) => ({ id: mk.id, name: mk.name }))}
|
||||
golongans={formData.golongans.map((g) => ({ id: g.id, name: g.name }))}
|
||||
ruangans={formData.ruangans.map((r) => ({ id: r.id.toString(), kode: r.kode, name: r.name }))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,173 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { BsPlusCircleDotted } from "react-icons/bs";
|
||||
import JadwalKuliahTable from "./JadwalKuliahTable";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
|
||||
const ITEMS_PER_PAGE = 6;
|
||||
|
||||
async function getJadwalData(
|
||||
adminProdiId: string,
|
||||
filters: { semester?: string; golongan?: string; search?: string },
|
||||
page: number
|
||||
) {
|
||||
const where: Prisma.JadwalKuliahWhereInput = {
|
||||
prodiId: adminProdiId,
|
||||
};
|
||||
|
||||
if (filters.semester && !isNaN(parseInt(filters.semester))) {
|
||||
where.semesterId = filters.semester;
|
||||
}
|
||||
if (filters.golongan) {
|
||||
where.golongans = {
|
||||
some: {
|
||||
id: filters.golongan,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ mata_kuliah: { name: { contains: filters.search, mode: "insensitive" } } },
|
||||
{ dosen: { name: { contains: filters.search, mode: "insensitive" } } },
|
||||
{ ruangan: { kode: { contains: filters.search, mode: "insensitive" } } },
|
||||
];
|
||||
}
|
||||
|
||||
const [jadwalKuliah, totalCount] = await Promise.all([
|
||||
prisma.jadwalKuliah.findMany({
|
||||
where,
|
||||
take: ITEMS_PER_PAGE,
|
||||
skip: (page - 1) * ITEMS_PER_PAGE,
|
||||
include: {
|
||||
dosen: { select: { name: true } },
|
||||
mata_kuliah: { select: { name: true } },
|
||||
semester: { select: { name: true } },
|
||||
golongans: { select: { name: true } },
|
||||
ruangan: { select: { name: true, kode: true } },
|
||||
prodi: { select: { name: true } },
|
||||
},
|
||||
orderBy: [{ hari: "asc" }, { jam_mulai: "asc" }],
|
||||
}),
|
||||
prisma.jadwalKuliah.count({ where }),
|
||||
]);
|
||||
|
||||
const [semesters, golongans] = await Promise.all([
|
||||
prisma.semester.findMany({ orderBy: { name: "asc" } }),
|
||||
prisma.golongan.findMany({
|
||||
where: {
|
||||
prodiId: adminProdiId,
|
||||
...(filters.semester && !isNaN(parseInt(filters.semester)) ? { semesterId: filters.semester } : {}),
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||
|
||||
return { jadwalKuliah, semesters, golongans, totalPages };
|
||||
}
|
||||
|
||||
// Komponen Halaman Utama
|
||||
export default async function ManajemenJadwalPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
semester?: string;
|
||||
golongan?: string;
|
||||
search?: string;
|
||||
page?: string;
|
||||
prodi?: string;
|
||||
}>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
const { semester, golongan, search, page } = await searchParams;
|
||||
|
||||
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||
redirect("/login?error=unauthorized");
|
||||
}
|
||||
|
||||
const adminUser = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { prodiId: true },
|
||||
});
|
||||
|
||||
if (!adminUser || !adminUser.prodiId) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-xl text-red-500">
|
||||
Akses ditolak: Akun admin Anda tidak terasosiasi dengan Program Studi manapun.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const adminProdiId = adminUser.prodiId;
|
||||
|
||||
const currentPage = Number(page) || 1;
|
||||
|
||||
const { jadwalKuliah, semesters, golongans, totalPages } = await getJadwalData(
|
||||
adminProdiId,
|
||||
{ semester, golongan, search },
|
||||
currentPage
|
||||
);
|
||||
|
||||
const serializableJadwalKuliah = jadwalKuliah.map((jadwal) => ({
|
||||
...jadwal,
|
||||
jam_mulai: jadwal.jam_mulai,
|
||||
jam_selesai: jadwal.jam_selesai,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Breadcrumb className="ml-12 mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/dashboard">Admin</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="#">Manajemen Akademik</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Mata Kuliah</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Manajemen Jadwal Kuliah</h1>
|
||||
<SubmitButton
|
||||
text="Tambah Jadwal Kuliah"
|
||||
href="/admin/manajemen-akademik/jadwal-kuliah/create"
|
||||
icon={<BsPlusCircleDotted />}
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-full hover:bg-gray-100 dark:hover:bg-black/10 hover:transition-all text-sm border border-gray-300 dark:border-neutral-800 flex items-center gap-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<JadwalKuliahTable
|
||||
data={serializableJadwalKuliah}
|
||||
totalPages={totalPages}
|
||||
currentPage={currentPage}
|
||||
prodiId={adminProdiId}
|
||||
filters={{
|
||||
semesters: semesters.map((s) => ({ id: s.id.toString(), name: s.name })),
|
||||
golongans: golongans.map((g) => ({ id: g.id.toString(), name: g.name })),
|
||||
current: { semester, golongan, search },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,413 @@
|
|||
"use client";
|
||||
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { LuCircleAlert, LuCircleArrowLeft, LuCircleArrowRight } from "react-icons/lu";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
interface MahasiswaData {
|
||||
id: string;
|
||||
nim?: string | null;
|
||||
name: string;
|
||||
prodi?: { name: string } | null;
|
||||
semester?: { name: string } | null;
|
||||
golongan?: { name: string; prodiId?: string } | null;
|
||||
}
|
||||
|
||||
interface FilterOption {
|
||||
id: string | number;
|
||||
name: string;
|
||||
prodiId?: string;
|
||||
semesterId?: string;
|
||||
}
|
||||
interface FilterState {
|
||||
semester?: string;
|
||||
prodi?: string;
|
||||
golongan?: string;
|
||||
nim?: string;
|
||||
}
|
||||
|
||||
export default function MahasiswaTable({
|
||||
data,
|
||||
filters,
|
||||
totalPages,
|
||||
currentPage,
|
||||
}: {
|
||||
data: MahasiswaData[];
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
filters: {
|
||||
semesters: FilterOption[];
|
||||
prodis: FilterOption[];
|
||||
golongans: FilterOption[];
|
||||
current: FilterState;
|
||||
};
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedMahasiswa, setSelectedMahasiswa] = useState<MahasiswaData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [isResetConfirmOpen, setIsResetConfirmOpen] = useState(false);
|
||||
const [resetInput, setResetInput] = useState("");
|
||||
const [isResetValid, setIsResetValid] = useState(false);
|
||||
|
||||
const [filteredGolongans, setFilteredGolongans] = useState<FilterOption[]>([]);
|
||||
|
||||
const { current, golongans } = filters;
|
||||
useEffect(() => {
|
||||
const prodiFilter = current.prodi;
|
||||
const semesterFilter = current.semester;
|
||||
|
||||
if (prodiFilter && semesterFilter) {
|
||||
setFilteredGolongans(
|
||||
golongans.filter((g) => g.prodiId === prodiFilter && g.semesterId === semesterFilter)
|
||||
);
|
||||
} else {
|
||||
setFilteredGolongans([]);
|
||||
}
|
||||
}, [current, golongans]);
|
||||
|
||||
const updateFilter = useDebouncedCallback((key: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
params.set("page", "1"); // Selalu reset ke halaman 1
|
||||
|
||||
if (key === "prodi" || key === "semester") {
|
||||
params.delete("golongan");
|
||||
}
|
||||
|
||||
router.push(`?${params.toString()}`);
|
||||
}, 300);
|
||||
|
||||
const handleOpenModal = (mahasiswa: MahasiswaData) => {
|
||||
setSelectedMahasiswa(mahasiswa);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!selectedMahasiswa) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/mahasiswa/${selectedMahasiswa.id}`, { method: "DELETE" });
|
||||
if (!response.ok) throw new Error("Gagal menghapus mahasiswa.");
|
||||
setIsModalOpen(false);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("mahasiswa", "delete_success");
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) alert(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPasswordModal = (mahasiswa: MahasiswaData) => {
|
||||
setSelectedMahasiswa(mahasiswa);
|
||||
setResetInput("");
|
||||
setIsResetValid(false);
|
||||
setIsResetModalOpen(true);
|
||||
};
|
||||
|
||||
const displayValue = (val?: string | null) => (val && val.trim() !== "" ? val : "-");
|
||||
|
||||
const createPageURL = (pageNumber: number) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", pageNumber.toString());
|
||||
return `?${params.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<select
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/50 dark:text-white border-gray-300 dark:border-neutral-800 text-sm focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
value={filters.current.semester || ""}
|
||||
onChange={(e) => updateFilter("semester", e.target.value)}
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900">
|
||||
Semua Semester
|
||||
</option>
|
||||
{filters.semesters.map((s) => (
|
||||
<option key={s.id} value={s.id} className="bg-white dark:bg-neutral-900">
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/50 dark:text-white border-gray-300 dark:border-neutral-800 text-sm focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
value={filters.current.prodi || ""}
|
||||
onChange={(e) => updateFilter("prodi", e.target.value)}
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900">
|
||||
Semua Prodi
|
||||
</option>
|
||||
{filters.prodis.map((p) => (
|
||||
<option key={p.id} value={p.id} className="bg-white dark:bg-neutral-900">
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/50 dark:text-white border-gray-300 dark:border-neutral-800 text-sm focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={filters.current.golongan || ""}
|
||||
onChange={(e) => updateFilter("golongan", e.target.value)}
|
||||
disabled={!filters.current.prodi || !filters.current.semester}
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900">
|
||||
{!filters.current.semester
|
||||
? "Pilih salah satu semester"
|
||||
: !filters.current.prodi
|
||||
? "Pilih salah satu prodi"
|
||||
: "Semua Golongan"}
|
||||
</option>
|
||||
{filteredGolongans.map((g) => (
|
||||
<option key={g.id} value={g.id.toString()} className="bg-white dark:bg-neutral-900">
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari Mahasiswa berdasarkan NIM..."
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/50 dark:text-white border-gray-300 dark:border-neutral-800 text-sm focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
defaultValue={filters.current.nim || ""}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => updateFilter("nim", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-auto rounded border border-gray-300 dark:border-neutral-800 shadow-sm">
|
||||
<table className="min-w-full text-sm text-left text-black dark:text-gray-200">
|
||||
<thead className="bg-neutral-100 dark:bg-neutral-950/50 uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-semibold">NIM</th>
|
||||
<th className="px-6 py-3 font-semibold">Nama</th>
|
||||
<th className="px-6 py-3 font-semibold">Prodi</th>
|
||||
<th className="px-6 py-3 font-semibold">Semester</th>
|
||||
<th className="px-6 py-3 font-semibold text-center">Golongan</th>
|
||||
<th className="px-6 py-3 font-semibold text-center w-px whitespace-nowrap">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-8 text-gray-400 dark:text-gray-300">
|
||||
Tidak ada mahasiswa ditemukan.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((m) => (
|
||||
<tr
|
||||
key={m.id}
|
||||
className="border-t border-gray-200 dark:border-neutral-800 hover:bg-gray-50 dark:hover:bg-neutral-900 bg-white dark:bg-neutral-800/10"
|
||||
>
|
||||
<td className="px-6 py-4 font-mono">{displayValue(m.nim)}</td>
|
||||
<td className="px-6 py-4">{displayValue(m.name)}</td>
|
||||
<td className="px-6 py-4">{displayValue(m.prodi?.name)}</td>
|
||||
<td className="px-6 py-4">{displayValue(m.semester?.name)}</td>
|
||||
<td className="px-6 py-4 text-center">{displayValue(m.golongan?.name)}</td>
|
||||
<td className="px-6 py-4 flex items-center gap-4 justify-center">
|
||||
<SubmitButton
|
||||
text="Edit"
|
||||
href={`/admin/manajemen-akademik/mahasiswa/${m.id}/edit`}
|
||||
className="bg-white w-18 dark:bg-neutral-950/50 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-md hover:bg-gray-200 dark:hover:bg-[#1A1A1A] hover:transition-all text-sm border border-gray-300 dark:border-neutral-800 flex items-center justify-center gap-2 cursor-pointer"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Reset Password"
|
||||
onClick={() => handleResetPasswordModal(m)}
|
||||
className="bg-emerald-700/80 w-36 dark:bg-emerald-900/50 text-white dark:text-emerald-200 px-4 py-2 rounded-md hover:bg-emerald-700 dark:hover:bg-emerald-950 hover:transition-all text-sm border-none flex items-center justify-center gap-2 cursor-pointer"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Hapus"
|
||||
onClick={() => handleOpenModal(m)}
|
||||
className="bg-red-700/80 w-18 dark:bg-red-800/80 text-white dark:text-gray-200 px-4 py-2 rounded-md hover:bg-red-700 dark:hover:bg-red-950 hover:transition-all text-sm border-none flex items-center justify-center gap-2 cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<span className="text-sm">
|
||||
Halaman {currentPage} dari {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={createPageURL(currentPage - 1)}
|
||||
className={`px-3 py-1 text-sm rounded-md flex items-center gap-2 ${
|
||||
currentPage <= 1
|
||||
? "pointer-events-none bg-transparent dark:bg-black/80 border border-gray-300 dark:border-neutral-800 text-gray-400"
|
||||
: "bg-gray-100 dark:bg-gray-200 border border-gray-300 dark:border-neutral-800 text-gray-800 dark:text-gray-800 hover:bg-gray-200 dark:hover:bg-gray-300 hover:transition-all"
|
||||
}`}
|
||||
>
|
||||
<LuCircleArrowLeft /> Sebelumnya
|
||||
</Link>
|
||||
<Link
|
||||
href={createPageURL(currentPage + 1)}
|
||||
className={`px-3 py-1 text-sm rounded-md flex items-center gap-2 ${
|
||||
currentPage >= totalPages
|
||||
? "pointer-events-none bg-transparent dark:bg-black/80 border border-gray-300 dark:border-neutral-800 text-gray-400"
|
||||
: "bg-gray-100 dark:bg-gray-200 border border-gray-300 dark:border-neutral-800 text-gray-800 dark:text-gray-800 hover:bg-gray-200 dark:hover:bg-gray-300 hover:transition-all"
|
||||
}`}
|
||||
>
|
||||
Selanjutnya <LuCircleArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-neutral-950 dark:backdrop-blur-sm rounded-lg p-6 w-full max-w-lg mx-4 shadow-[0_0_30px_1px_#C10007] dark:shadow-[0_0_34px_1px_#460809]">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<LuCircleAlert className="w-6 h-6 text-red-600" /> Konfirmasi Hapus
|
||||
</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
Anda yakin ingin menghapus mahasiswa :
|
||||
</p>
|
||||
<p className="font-bold text-sm text-gray-600 dark:text-gray-300">{selectedMahasiswa?.name}</p>
|
||||
<p></p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Semua data yang berkaitan dengan mahasiswa ini akan dihapus. Tindakan ini tidak dapat
|
||||
dibatalkan.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="bg-gray-200 dark:bg-neutral-900/50 dark:border dark:border-neutral-800 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-md text-sm hover:bg-gray-300 dark:hover:bg-neutral-700 transition-all"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Ya, Hapus"
|
||||
isLoading={isLoading}
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 dark:bg-red-800 text-white px-4 py-2 rounded-md text-sm hover:bg-red-700 dark:hover:bg-red-950 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isResetModalOpen && selectedMahasiswa && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-neutral-950 rounded-lg p-6 w-full max-w-lg mx-4 shadow-lg">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<LuCircleAlert className="w-6 h-6 text-yellow-500" /> Konfirmasi Reset Password
|
||||
</h2>
|
||||
<div className="mt-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
<p>
|
||||
Anda akan mereset password mahasiswa{" "}
|
||||
<span className="font-bold">{selectedMahasiswa.name}</span> menjadi password default. Anda
|
||||
yakin ingin melanjutkan?
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
onClick={() => setIsResetModalOpen(false)}
|
||||
className="bg-gray-200 dark:bg-neutral-900/50 dark:border dark:border-neutral-800 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-md text-sm hover:bg-gray-300 dark:hover:bg-neutral-700 transition-all"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Lanjut"
|
||||
onClick={() => {
|
||||
setIsResetModalOpen(false);
|
||||
setIsResetConfirmOpen(true);
|
||||
}}
|
||||
className="bg-emerald-700 dark:bg-emerald-800 text-white px-4 py-2 rounded-md text-sm hover:bg-emerald-800 dark:hover:bg-emerald-950 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isResetConfirmOpen && selectedMahasiswa && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-neutral-950 rounded-lg p-6 w-full max-w-lg mx-4 shadow-lg">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<LuCircleAlert className="w-6 h-6 text-yellow-500" /> Verifikasi NIM
|
||||
</h2>
|
||||
<div className="mt-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
<p>
|
||||
Silakan ketikkan NIM mahasiswa <span className="font-bold">{selectedMahasiswa.name}</span>{" "}
|
||||
untuk mengonfirmasi reset password.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
className={`w-full mt-4 px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-900/50 dark:text-white border-gray-300 dark:border-neutral-800 text-sm focus:outline-none focus:ring-2
|
||||
${
|
||||
isResetValid
|
||||
? "focus:ring-emerald-600 dark:focus:ring-emerald-500"
|
||||
: "focus:ring-red-600 dark:focus:ring-red-500"
|
||||
}`}
|
||||
placeholder="Ketik NIM mahasiswa di sini..."
|
||||
value={resetInput}
|
||||
onChange={(e) => {
|
||||
setResetInput(e.target.value);
|
||||
setIsResetValid(e.target.value.trim() === selectedMahasiswa.nim);
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className={`mt-2 text-sm ${
|
||||
resetInput === "" ? "text-gray-500" : isResetValid ? "text-emerald-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{resetInput === ""
|
||||
? "Ketikkan NIM mahasiswa."
|
||||
: isResetValid
|
||||
? "NIM cocok, Anda bisa mereset password."
|
||||
: "NIM tidak cocok."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
onClick={() => setIsResetConfirmOpen(false)}
|
||||
className="bg-gray-200 dark:bg-neutral-900/50 dark:border dark:border-neutral-800 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-md text-sm hover:bg-gray-300 dark:hover:bg-neutral-700 transition-all"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Reset"
|
||||
disabled={!isResetValid}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/mahasiswa/${selectedMahasiswa.id}/reset-password`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) throw new Error("Gagal mereset password.");
|
||||
setIsResetConfirmOpen(false);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("mahasiswa", "reset_success");
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) alert(`Error: ${error.message}`);
|
||||
}
|
||||
}}
|
||||
className={`${
|
||||
isResetValid
|
||||
? "bg-emerald-700 dark:bg-emerald-800 hover:bg-emerald-800 dark:hover:bg-emerald-950"
|
||||
: "bg-gray-400 dark:bg-neutral-700 cursor-not-allowed"
|
||||
} text-white px-4 py-2 rounded-md text-sm transition-all`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,357 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, ChangeEvent, FormEvent, useMemo, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
interface GolonganOption extends Option {
|
||||
semesterId: string;
|
||||
prodiId: string;
|
||||
}
|
||||
interface MahasiswaData {
|
||||
id: string;
|
||||
name: string;
|
||||
nim: string | null;
|
||||
email: string;
|
||||
no_hp: string | null;
|
||||
alamat: string | null;
|
||||
gender: string | null;
|
||||
foto: string | null;
|
||||
semesterId: string | null;
|
||||
prodiId: string | null;
|
||||
golonganId: string | null;
|
||||
}
|
||||
|
||||
interface EditMahasiswaFormProps {
|
||||
mahasiswa: MahasiswaData;
|
||||
semesters: Option[];
|
||||
prodis: Option[];
|
||||
golongans: GolonganOption[];
|
||||
}
|
||||
|
||||
export default function EditMahasiswaForm({
|
||||
mahasiswa,
|
||||
semesters,
|
||||
prodis,
|
||||
golongans,
|
||||
}: EditMahasiswaFormProps) {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState({
|
||||
name: mahasiswa.name || "",
|
||||
nim: mahasiswa.nim || "",
|
||||
email: mahasiswa.email || "",
|
||||
no_hp: mahasiswa.no_hp || "",
|
||||
alamat: mahasiswa.alamat || "",
|
||||
semester: mahasiswa.semesterId || "",
|
||||
prodi: mahasiswa.prodiId || "",
|
||||
golongan: mahasiswa.golonganId || "",
|
||||
gender: mahasiswa.gender || "",
|
||||
foto: null as File | null,
|
||||
});
|
||||
|
||||
const [preview, setPreview] = useState<string | null>(mahasiswa.foto);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [filteredGolongans, setFilteredGolongans] = useState<GolonganOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (form.prodi && form.semester) {
|
||||
const newFilteredGolongans = golongans.filter(
|
||||
(g) => g.prodiId === form.prodi && g.semesterId === form.semester
|
||||
);
|
||||
setFilteredGolongans(newFilteredGolongans);
|
||||
} else {
|
||||
setFilteredGolongans([]);
|
||||
}
|
||||
}, [form.prodi, form.semester, golongans]);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
if (name === "prodi") {
|
||||
setFilteredGolongans(golongans.filter((g) => g.prodiId === value));
|
||||
setForm((prev) => ({ ...prev, prodi: value, golongan: "" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "file" && e.target instanceof HTMLInputElement) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setIsUploading(true);
|
||||
setForm((prev) => ({ ...prev, foto: file }));
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPreview(reader.result as string);
|
||||
setIsUploading(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
} else {
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const imagePreview = useMemo(() => {
|
||||
if (!preview) return null;
|
||||
return (
|
||||
<Image
|
||||
key="preview"
|
||||
src={preview}
|
||||
alt="Preview Foto"
|
||||
width={200}
|
||||
height={200}
|
||||
className="object-cover"
|
||||
/>
|
||||
);
|
||||
}, [preview]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
const formData = new FormData();
|
||||
Object.entries(form).forEach(([key, value]) => {
|
||||
if (value) formData.append(key, value);
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/mahasiswa/${mahasiswa.id}`, {
|
||||
method: "PUT",
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Gagal menyimpan perubahan.");
|
||||
}
|
||||
router.push("/admin/manajemen-akademik/mahasiswa?mahasiswa=update_success");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) alert(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto px-8 py-6 bg-white dark:bg-neutral-900 rounded shadow-[0_0_10px_1px_#1a1a1a1a] dark:shadow-[0_0_20px_1px_#ffffff1a]">
|
||||
<Breadcrumb className="ml-10 mb-8">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/dashboard">Admin</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="#">Manajemen Akademik</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/manajemen-akademik/mahasiswa">Mahasiswa</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Edit Mahasiswa - {mahasiswa.name}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<form onSubmit={handleSubmit} className="flex items-start gap-8 w-full">
|
||||
{/* Kiri: Form */}
|
||||
<div className="space-y-4 w-3/2">
|
||||
{/* Nama dan NIM */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<input
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Nama Lengkap"
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
name="nim"
|
||||
value={form.nim}
|
||||
onChange={handleChange}
|
||||
placeholder="NIM"
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email dan No HP */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
placeholder="Email"
|
||||
autoComplete="off"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
name="no_hp"
|
||||
value={form.no_hp}
|
||||
onChange={handleChange}
|
||||
placeholder="No HP"
|
||||
autoComplete="new-password"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alamat */}
|
||||
<textarea
|
||||
name="alamat"
|
||||
value={form.alamat}
|
||||
onChange={handleChange}
|
||||
placeholder="Alamat"
|
||||
autoComplete="new-password"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<select
|
||||
name="semester"
|
||||
value={form.semester}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih Semester
|
||||
</option>
|
||||
{semesters.map((s) => (
|
||||
<option key={s.id} value={s.id} className="bg-white dark:bg-neutral-900">
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
name="prodi"
|
||||
value={form.prodi}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih Program Studi
|
||||
</option>
|
||||
{prodis.map((p) => (
|
||||
<option key={p.id} value={p.id} className="bg-white dark:bg-neutral-900">
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Gender */}
|
||||
<select
|
||||
name="gender"
|
||||
value={form.gender}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih Gender
|
||||
</option>
|
||||
<option value="LAKI-LAKI" className="bg-white dark:bg-neutral-900">
|
||||
Laki-laki
|
||||
</option>
|
||||
<option value="PEREMPUAN" className="bg-white dark:bg-neutral-900">
|
||||
Perempuan
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
name="golongan"
|
||||
value={form.golongan}
|
||||
onChange={handleChange}
|
||||
disabled={!form.prodi || filteredGolongans.length === 0}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm disabled:bg-gray-200 disabled:dark:bg-gray-800/50 disabled:cursor-not-allowed focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
{!form.prodi
|
||||
? "Pilih Prodi Terlebih Dahulu"
|
||||
: filteredGolongans.length === 0
|
||||
? "Tidak Ada Golongan"
|
||||
: "Pilih Golongan"}
|
||||
</option>
|
||||
{filteredGolongans.map((g) => (
|
||||
<option key={g.id} value={g.id} className="bg-white dark:bg-neutral-900">
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-6">
|
||||
{/* Submit */}
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
text="Simpan"
|
||||
isLoading={isSubmitting}
|
||||
className="bg-black dark:bg-white text-white dark:text-gray-900 dark:hover:bg-gray-200 hover:bg-black/80 px-6 py-2 rounded-md text-sm"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
href="/admin/manajemen-akademik/mahasiswa"
|
||||
className="bg-white dark:bg-neutral-950/50 text-text-gray-900 dark:white dark:hover:bg-black/10 hover:bg-gray-200 px-6 py-2 rounded-md text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kanan: Foto */}
|
||||
<div className="flex flex-col items-center gap-2 w-1/3">
|
||||
<div className="w-50 h-61 border border-dashed border-gray-400 dark:border-neutral-600 rounded-md flex items-center justify-center overflow-hidden bg-transparent transition-opacity duration-300 will-change-opacity">
|
||||
{imagePreview ?? (
|
||||
<span className="text-sm text-gray-400 dark:text-neutral-500 text-center">Belum ada foto</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SubmitButton
|
||||
type="button"
|
||||
text={form.foto ? "Ganti Foto" : "Upload Foto"}
|
||||
isLoading={isUploading}
|
||||
className="w-50 text-center mt-2 px-4 py-2 text-sm rounded-md border bg-gray-100 dark:bg-neutral-950/50 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-neutral-800 hover:bg-gray-200 dark:hover:bg-black/20"
|
||||
onClick={handleUploadClick}
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="foto"
|
||||
type="file"
|
||||
name="foto"
|
||||
accept="image/*"
|
||||
onChange={handleChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
import EditMahasiswaForm from "./EditMahasiswaForm";
|
||||
|
||||
async function getFormData(mahasiswaId: string) {
|
||||
const [user, semesters, prodis, golongans] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: mahasiswaId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nim: true,
|
||||
email: true,
|
||||
no_hp: true,
|
||||
alamat: true,
|
||||
gender: true,
|
||||
foto: true,
|
||||
semesterId: true,
|
||||
prodiId: true,
|
||||
golonganId: true,
|
||||
},
|
||||
}),
|
||||
prisma.semester.findMany({ orderBy: { name: "asc" } }),
|
||||
prisma.programStudi.findMany({ orderBy: { name: "asc" } }),
|
||||
prisma.golongan.findMany({
|
||||
select: { id: true, name: true, prodiId: true, semesterId: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!user) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return { user, semesters, prodis, golongans };
|
||||
}
|
||||
|
||||
export default async function EditMahasiswaPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const { user, semesters, prodis, golongans } = await getFormData(id);
|
||||
|
||||
return (
|
||||
<EditMahasiswaForm
|
||||
mahasiswa={user}
|
||||
semesters={semesters.map((s) => ({ id: s.id.toString(), name: s.name }))}
|
||||
prodis={prodis.map((p) => ({ id: p.id, name: p.name }))}
|
||||
golongans={golongans.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
prodiId: g.prodiId,
|
||||
semesterId: g.semesterId.toString(),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,370 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, ChangeEvent, FormEvent, useMemo } from "react";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import Image from "next/image";
|
||||
import { useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
|
||||
interface GolonganOption {
|
||||
id: string;
|
||||
name: string;
|
||||
prodiId: string;
|
||||
semesterId: string;
|
||||
}
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CreateMahasiswaFormProps {
|
||||
semesters: Option[];
|
||||
prodis: Option[];
|
||||
golongans: GolonganOption[];
|
||||
}
|
||||
|
||||
export default function CreateMahasiswaForm({ semesters, prodis, golongans }: CreateMahasiswaFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
nim: "",
|
||||
email: "",
|
||||
no_hp: "",
|
||||
alamat: "",
|
||||
semester: "",
|
||||
prodi: "",
|
||||
golongan: "",
|
||||
gender: "",
|
||||
foto: null as File | null,
|
||||
});
|
||||
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const [filteredGolongans, setFilteredGolongans] = useState<GolonganOption[]>([]);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (form.prodi && form.semester) {
|
||||
const newFilteredGolongans = golongans.filter(
|
||||
(g) => g.prodiId === form.prodi && g.semesterId === form.semester
|
||||
);
|
||||
setFilteredGolongans(newFilteredGolongans);
|
||||
} else {
|
||||
setFilteredGolongans([]);
|
||||
}
|
||||
}, [form.prodi, golongans, form.semester]);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
if (name === "prodi" || name === "semester") {
|
||||
setForm((prev) => ({ ...prev, [name]: value, golongan: "" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "file" && e.target instanceof HTMLInputElement) {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setForm((prev) => ({ ...prev, foto: file }));
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPreview(reader.result as string);
|
||||
setIsUploading(false);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
return;
|
||||
}
|
||||
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
setIsUploading(true);
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
setTimeout(() => {
|
||||
const file = fileInputRef.current?.files?.[0];
|
||||
if (!file) {
|
||||
setIsUploading(false);
|
||||
}
|
||||
window.removeEventListener("focus", handleWindowFocus);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
window.addEventListener("focus", handleWindowFocus);
|
||||
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
const imagePreview = useMemo(() => {
|
||||
if (!preview) return null;
|
||||
return (
|
||||
<Image
|
||||
key="preview"
|
||||
src={preview}
|
||||
alt="Preview Foto"
|
||||
width={200}
|
||||
height={200}
|
||||
className="object-cover"
|
||||
/>
|
||||
);
|
||||
}, [preview]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
const formData = new FormData();
|
||||
Object.entries(form).forEach(([key, value]) => {
|
||||
if (value) formData.append(key, value);
|
||||
});
|
||||
console.log("data yang dikirim", formData);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/mahasiswa", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Gagal menambahkan mahasiswa.");
|
||||
}
|
||||
|
||||
router.push("/admin/manajemen-akademik/mahasiswa?mahasiswa=create_success");
|
||||
router.refresh();
|
||||
localStorage.removeItem("foto-preview");
|
||||
localStorage.removeItem("foto-preview-expiry");
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto px-8 py-6 bg-white dark:bg-neutral-900 rounded shadow-[0_0_10px_1px_#1a1a1a1a] dark:shadow-[0_0_20px_1px_#ffffff1a]">
|
||||
<Breadcrumb className="ml-10 mb-8">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/dashboard">Admin</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="#">Manajemen Akademik</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/manajemen-akademik/dosen">Mahasiswa</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Tambah Mahasiswa</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex items-start gap-8 w-full">
|
||||
{/* Kiri: Form */}
|
||||
<div className="space-y-4 w-3/2">
|
||||
{/* Nama dan NIM */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<input
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Nama Lengkap"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
name="nim"
|
||||
value={form.nim}
|
||||
onChange={handleChange}
|
||||
placeholder="NIM"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email dan No HP */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
placeholder="Email"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
name="no_hp"
|
||||
value={form.no_hp}
|
||||
onChange={handleChange}
|
||||
placeholder="No HP"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alamat */}
|
||||
<textarea
|
||||
name="alamat"
|
||||
value={form.alamat}
|
||||
onChange={handleChange}
|
||||
placeholder="Alamat"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<select
|
||||
name="semester"
|
||||
value={form.semester}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih Semester
|
||||
</option>
|
||||
{semesters.map((s) => (
|
||||
<option key={s.id} value={s.id} className="bg-white dark:bg-neutral-900">
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
name="prodi"
|
||||
value={form.prodi}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih Program Studi
|
||||
</option>
|
||||
{prodis.map((p) => (
|
||||
<option key={p.id} value={p.id} className="bg-white dark:bg-neutral-900">
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Gender */}
|
||||
<select
|
||||
name="gender"
|
||||
value={form.gender}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih Gender
|
||||
</option>
|
||||
<option value="LAKI-LAKI" className="bg-white dark:bg-neutral-900">
|
||||
Laki-laki
|
||||
</option>
|
||||
<option value="PEREMPUAN" className="bg-white dark:bg-neutral-900">
|
||||
Perempuan
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
name="golongan"
|
||||
value={form.golongan}
|
||||
onChange={handleChange}
|
||||
disabled={!form.prodi || !form.semester}
|
||||
className="w-full px-4 py-2 border rounded bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
required
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
{!form.semester
|
||||
? "Pilih Semester Dulu"
|
||||
: !form.prodi
|
||||
? "Pilih Prodi Dulu"
|
||||
: filteredGolongans.length === 0
|
||||
? "Tidak ada golongan"
|
||||
: "Pilih Golongan"}
|
||||
</option>
|
||||
{filteredGolongans.map((g) => (
|
||||
<option key={g.id} value={g.id} className="bg-white dark:bg-neutral-900">
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mt-6">
|
||||
{/* Submit */}
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
text="Tambah"
|
||||
isLoading={isSubmitting}
|
||||
className="bg-black dark:bg-white text-white dark:text-gray-900 dark:hover:bg-gray-200 hover:bg-black/80 px-6 py-2 rounded-md text-sm"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
href="/admin/manajemen-akademik/mahasiswa"
|
||||
className="bg-white dark:bg-neutral-950/50 text-text-gray-900 dark:white dark:hover:bg-black/10 hover:bg-gray-200 px-6 py-2 rounded-md text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kanan: Foto */}
|
||||
<div className="flex flex-col items-center gap-2 w-1/3">
|
||||
<div className="w-50 h-61 border border-dashed border-gray-400 dark:border-neutral-600 rounded-md flex items-center justify-center overflow-hidden bg-transparent transition-opacity duration-300 will-change-opacity">
|
||||
{imagePreview ?? (
|
||||
<span className="text-sm text-gray-400 dark:text-neutral-500 text-center">Belum ada foto</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SubmitButton
|
||||
type="button"
|
||||
text={form.foto ? "Ganti Foto" : "Upload Foto"}
|
||||
isLoading={isUploading}
|
||||
className="w-50 text-center mt-2 px-4 py-2 text-sm rounded-md border bg-gray-100 dark:bg-neutral-950/50 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-neutral-800 hover:bg-gray-200 dark:hover:bg-black/20"
|
||||
onClick={handleUploadClick}
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="foto"
|
||||
type="file"
|
||||
name="foto"
|
||||
accept="image/*"
|
||||
onChange={handleChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import CreateMahasiswaForm from "@/app/(admins)/admin/manajemen-akademik/mahasiswa/create/CreateMahasiswaForm";
|
||||
|
||||
export default async function CreateMahasiswaPage() {
|
||||
const [semesters, prodis, golongans] = await Promise.all([
|
||||
prisma.semester.findMany({ orderBy: { name: "asc" } }),
|
||||
prisma.programStudi.findMany({ orderBy: { name: "asc" } }),
|
||||
prisma.golongan.findMany({
|
||||
select: { id: true, name: true, prodiId: true, semesterId: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CreateMahasiswaForm
|
||||
semesters={semesters.map((s) => ({ id: s.id.toString(), name: s.name }))}
|
||||
prodis={prodis.map((p) => ({ id: p.id.toString(), name: p.name }))}
|
||||
golongans={golongans.map((g) => ({
|
||||
id: g.id.toString(),
|
||||
name: g.name,
|
||||
prodiId: g.prodiId.toString(),
|
||||
semesterId: g.semesterId.toString(),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,131 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { BsPlusCircleDotted } from "react-icons/bs";
|
||||
import MahasiswaTable from "./MahasiswaTable";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
|
||||
const ITEMS_PER_PAGE = 6;
|
||||
|
||||
// Fungsi ini sekarang menerima parameter halaman (page)
|
||||
async function getMahasiswaData(
|
||||
filters: { semester?: string; prodi?: string; golongan?: string; nim?: string },
|
||||
page: number
|
||||
) {
|
||||
const where: Prisma.UserWhereInput = {
|
||||
role: "MAHASISWA",
|
||||
nim: { not: null },
|
||||
};
|
||||
|
||||
if (filters.semester) where.semesterId = filters.semester;
|
||||
if (filters.prodi) where.prodiId = filters.prodi;
|
||||
if (filters.golongan) where.golonganId = filters.golongan;
|
||||
if (filters.nim) where.nim = { contains: filters.nim, mode: "insensitive" };
|
||||
|
||||
const [mahasiswa, totalCount] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where,
|
||||
take: ITEMS_PER_PAGE,
|
||||
skip: (page - 1) * ITEMS_PER_PAGE, // Lewati item dari halaman sebelumnya
|
||||
include: {
|
||||
prodi: true,
|
||||
golongan: true,
|
||||
semester: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||
|
||||
return { mahasiswa, totalPages };
|
||||
}
|
||||
|
||||
async function getFilterOptions() {
|
||||
const [semesters, prodis, golongans] = await Promise.all([
|
||||
prisma.semester.findMany({ orderBy: { name: "asc" } }),
|
||||
prisma.programStudi.findMany({ orderBy: { name: "asc" } }),
|
||||
prisma.golongan.findMany({
|
||||
select: { id: true, name: true, prodiId: true, semesterId: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
return { semesters, prodis, golongans };
|
||||
}
|
||||
|
||||
export default async function ManageMahasiswaPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Promise<{
|
||||
semester?: string;
|
||||
prodi?: string;
|
||||
golongan?: string;
|
||||
nim?: string;
|
||||
page?: string;
|
||||
}>;
|
||||
}) {
|
||||
const { semester, prodi, golongan, nim, page } = (await searchParams) || {};
|
||||
const currentPage = Number(page) || 1;
|
||||
|
||||
// Panggil fungsi dengan filter dan halaman saat ini
|
||||
const { mahasiswa, totalPages } = await getMahasiswaData({ semester, prodi, golongan, nim }, currentPage);
|
||||
|
||||
const { semesters, prodis, golongans } = await getFilterOptions();
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Breadcrumb className="ml-12 mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/dashboard">Admin</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="#">Manajemen Akademik</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Mahasiswa</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Manajemen Mahasiswa</h1>
|
||||
<SubmitButton
|
||||
text="Tambah Mahasiswa"
|
||||
href="/admin/manajemen-akademik/mahasiswa/create"
|
||||
icon={<BsPlusCircleDotted />}
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-full text-sm border border-gray-300 dark:border-neutral-800 hover:bg-gray-200 dark:hover:bg-black/20 flex items-center gap-2 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MahasiswaTable
|
||||
data={mahasiswa}
|
||||
totalPages={totalPages}
|
||||
currentPage={currentPage}
|
||||
filters={{
|
||||
semesters: semesters.map((s) => ({ id: s.id.toString(), name: s.name })),
|
||||
prodis: prodis.map((p) => ({ id: p.id.toString(), name: p.name })),
|
||||
golongans: golongans.map((g) => ({
|
||||
id: g.id.toString(),
|
||||
name: g.name,
|
||||
prodiId: g.prodiId.toString(),
|
||||
semesterId: g.semesterId.toString(),
|
||||
})),
|
||||
current: { semester, prodi, golongan, nim },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,183 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { MataKuliah } from "@/generated/prisma/client";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { LuCircleAlert, LuCircleArrowLeft, LuCircleArrowRight } from "react-icons/lu";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import Link from "next/link";
|
||||
import { BsPlusCircleDotted } from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
data: MataKuliah[];
|
||||
initialSearch?: string;
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
export default function MatkulTable({ data, initialSearch, totalPages, currentPage }: Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedMatkul, setSelectedMatkul] = useState<MataKuliah | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSearch = useDebouncedCallback((term: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (term) {
|
||||
params.set("search", term);
|
||||
} else {
|
||||
params.delete("search");
|
||||
}
|
||||
router.replace(`?${params.toString()}`);
|
||||
}, 300);
|
||||
|
||||
const handleOpenModal = (matkul: MataKuliah) => {
|
||||
setSelectedMatkul(matkul);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!setSelectedMatkul) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/mata-kuliah/${selectedMatkul?.id}`, { method: "DELETE" });
|
||||
if (!response.ok) throw new Error("Gagal menghapus mata kuliah.");
|
||||
setIsModalOpen(false);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("mata_kuliah", "delete_success");
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) alert(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari berdasarkan kode atau nama mata kuliah..."
|
||||
defaultValue={initialSearch}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="bg-white dark:bg-neutral-950/50 text-black dark:text-gray-200 border border-gray-300 dark:border-neutral-800 rounded-md px-4 py-2 focus:outline-none text-sm w-88"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Tambah Mata Kuliah"
|
||||
href="/admin/manajemen-akademik/mata-kuliah/create"
|
||||
icon={<BsPlusCircleDotted />}
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-full hover:bg-gray-100 dark:hover:bg-black/10 hover:transition-all text-sm border border-gray-300 dark:border-neutral-800 flex items-center gap-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto rounded border border-gray-300 dark:border-neutral-800 shadow-sm">
|
||||
<table className="min-w-full text-sm text-left text-black dark:text-gray-200">
|
||||
<thead className="bg-gray-100 dark:bg-neutral-950/50 uppercase tracking-wide">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-semibold">Kode</th>
|
||||
<th className="px-6 py-3 font-semibold">Nama Mata Kuliah</th>
|
||||
<th className="px-6 py-3 font-semibold text-center w-px whitespace-nowrap">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-center py-8">
|
||||
Tidak ada ruangan ditemukan.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((matkul) => (
|
||||
<tr
|
||||
key={matkul.id}
|
||||
className="border-t border-gray-200 dark:border-neutral-800 hover:bg-gray-50 dark:hover:bg-neutral-950/40"
|
||||
>
|
||||
<td className="px-6 py-4 font-mono">{matkul.kode}</td>
|
||||
<td className="px-6 py-4">{matkul.name}</td>
|
||||
<td className="px-6 py-4 flex items-center gap-4 justify-center">
|
||||
<SubmitButton
|
||||
text="Edit"
|
||||
href={`/admin/manajemen-akademik/mata-kuliah/${matkul.id}/edit`}
|
||||
className="bg-white w-18 dark:bg-neutral-950/40 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-md hover:bg-gray-200 dark:hover:bg-[#1A1A1A] hover:transition-all text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Hapus"
|
||||
onClick={() => handleOpenModal(matkul)}
|
||||
className="bg-red-700 w-18 dark:bg-red-800/80 text-white dark:text-gray-200 px-4 py-2 rounded-md hover:bg-red-800 dark:hover:bg-red-950 hover:transition-all text-sm border-none"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-400">
|
||||
Halaman {currentPage} dari {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`?page=${currentPage - 1}`}
|
||||
className={`px-3 py-1 text-sm rounded-md flex items-center gap-2 ${
|
||||
currentPage <= 1
|
||||
? "pointer-events-none bg-transparent dark:bg-black/80 border border-gray-300 dark:border-neutral-800 text-gray-400"
|
||||
: "bg-gray-100 dark:bg-gray-200 border border-gray-300 dark:border-neutral-800 text-gray-800 dark:text-gray-800 hover:bg-gray-200 dark:hover:bg-gray-300 hover:transition-all"
|
||||
}`}
|
||||
>
|
||||
<LuCircleArrowLeft />
|
||||
Sebelumnya
|
||||
</Link>
|
||||
<Link
|
||||
href={`?page=${currentPage + 1}`}
|
||||
className={`px-3 py-1 text-sm rounded-md flex items-center gap-2 ${
|
||||
currentPage >= totalPages
|
||||
? "pointer-events-none bg-transparent dark:bg-black/80 border border-gray-300 dark:border-neutral-800 text-gray-400"
|
||||
: "bg-gray-100 dark:bg-gray-200 border border-gray-300 dark:border-neutral-800 text-gray-800 dark:text-gray-800 hover:bg-gray-200 dark:hover:bg-gray-300 hover:transition-all"
|
||||
}`}
|
||||
>
|
||||
Selanjutnya
|
||||
<LuCircleArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-neutral-950 dark:backdrop-blur-sm rounded-lg p-6 w-full max-w-lg mx-4 shadow-[0_0_30px_1px_#C10007] dark:shadow-[0_0_34px_1px_#460809]">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<LuCircleAlert className="w-6 h-6 text-red-600" /> Konfirmasi Hapus
|
||||
</h2>
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
Apakah Anda yakin ingin menghapus mata kuliah :
|
||||
<p className="font-bold text-sm mt-2 mb-2">{selectedMatkul?.name} ?</p>
|
||||
Tindakan ini tidak dapat dibatalkan.
|
||||
</p>
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="bg-gray-100 dark:bg-neutral-900/50 border border-neutral-300 dark:border-neutral-800 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-md text-sm hover:bg-gray-300 dark:hover:bg-neutral-900 transition-all"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Ya, Hapus"
|
||||
isLoading={isLoading}
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 dark:bg-red-800 text-white px-4 py-2 rounded-md text-sm hover:bg-red-700 dark:hover:bg-red-950 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, ChangeEvent, FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { MataKuliah } from "@/generated/prisma/client";
|
||||
|
||||
interface EditMatkulFormProps {
|
||||
matkul: MataKuliah;
|
||||
}
|
||||
|
||||
export default function EditMatkulForm({ matkul }: EditMatkulFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: matkul.name,
|
||||
kode: matkul.kode,
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const prefix = "MK";
|
||||
const nameFormatted = form.name.trimStart();
|
||||
|
||||
const initials = nameFormatted
|
||||
.split(" ")
|
||||
.map((word) => word.charAt(0).toUpperCase())
|
||||
.join("");
|
||||
|
||||
const generatedKode = initials ? `${prefix}-${initials}` : prefix;
|
||||
|
||||
setForm((prev) => ({ ...prev, kode: generatedKode }));
|
||||
}, [form.name]);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/mata-kuliah/${matkul.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
kode: form.kode,
|
||||
name: form.name,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Gagal menyimpan perubahan.");
|
||||
|
||||
router.push("/admin/manajemen-akademik/mata-kuliah?mata-kuliah=update_success");
|
||||
router.refresh();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<div className="space-y-4 w-full">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Nama Mata Kuliah
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
autoComplete="off"
|
||||
placeholder="Contoh: Pemrograman Web"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="kode" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Kode Mata Kuliah (Otomatis)
|
||||
</label>
|
||||
<input
|
||||
id="kode"
|
||||
name="kode"
|
||||
value={form.kode}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-200 dark:bg-neutral-950 dark:text-white border-gray-300 dark:border-neutral-800 text-sm focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none cursor-not-allowed"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500 mt-2">{error}</p>}
|
||||
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
text="Simpan"
|
||||
isLoading={isLoading}
|
||||
className="bg-black dark:bg-white text-white dark:text-gray-900 dark:hover:bg-gray-200 hover:bg-black/80 px-6 py-2 rounded-md text-sm"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
href="/admin/manajemen-akademik/mata-kuliah"
|
||||
className="bg-white dark:bg-neutral-950/50 text-text-gray-900 dark:white dark:hover:bg-black/10 hover:bg-gray-200 px-6 py-2 rounded-md text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
import EditMatkulForm from "./EditMatkulForm";
|
||||
|
||||
async function getMatkulById(id: string) {
|
||||
const matkul = await prisma.mataKuliah.findUnique({
|
||||
where: { id: id },
|
||||
});
|
||||
if (!matkul) {
|
||||
notFound();
|
||||
}
|
||||
return matkul;
|
||||
}
|
||||
|
||||
export default async function EditMatkulPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
|
||||
const matkul = await getMatkulById(id);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mt-10 mx-auto p-8 bg-white dark:bg-black/20 rounded-md shadow-[0_0_10px_1px_#1a1a1a1a] dark:shadow-[0_0_20px_1px_#ffffff1a]">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Edit Mata Kuliah</h1>
|
||||
<EditMatkulForm matkul={matkul} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, ChangeEvent, FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
|
||||
export default function CreateMatkulForm() {
|
||||
const router = useRouter();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
kode: "AA",
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const prefix = "MK";
|
||||
const nameFormatted = form.name.trimStart();
|
||||
|
||||
const initials = nameFormatted
|
||||
.split(" ")
|
||||
.map((word) => word.charAt(0).toUpperCase())
|
||||
.join("");
|
||||
|
||||
const generatedKode = `${prefix}-${initials}`;
|
||||
|
||||
setForm((prev) => ({ ...prev, kode: generatedKode }));
|
||||
}, [form.name]);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/mata-kuliah", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
kode: form.kode,
|
||||
name: form.name,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Gagal menambahkan mata kuliah.");
|
||||
|
||||
router.push("/admin/manajemen-akademik/mata-kuliah?mata-kuliah=create_success");
|
||||
router.refresh();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<div className="space-y-4 w-full">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Nama Mata Kuliah
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
autoComplete="off"
|
||||
placeholder="Contoh: Pemrograman Web"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="kode" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Kode Mata Kuliah (Otomatis)
|
||||
</label>
|
||||
<input
|
||||
id="kode"
|
||||
name="kode"
|
||||
value={form.kode}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-200 dark:bg-neutral-950 dark:text-white border-gray-300 dark:border-neutral-800 text-sm focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none cursor-not-allowed"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500 dark:text-red-400 mt-2">{error}</p>}
|
||||
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
text="Tambah"
|
||||
isLoading={isLoading}
|
||||
className="bg-black dark:bg-white text-white dark:text-gray-900 dark:hover:bg-gray-200 hover:bg-black/80 px-6 py-2 rounded-md text-sm"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
href="/admin/manajemen-akademik/mata-kuliah"
|
||||
className="bg-white dark:bg-neutral-950/50 text-text-gray-900 dark:white dark:hover:bg-black/10 hover:bg-gray-200 px-6 py-2 rounded-md text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import CreateMatkulForm from "./CreateMatkulForm";
|
||||
|
||||
export default function CreateMatkulPage() {
|
||||
return (
|
||||
<div className="w-full max-w-2xl mt-10 mx-auto p-8 bg-white dark:bg-black/20 rounded-md shadow-[0_0_10px_1px_#1a1a1a1a] dark:shadow-[0_0_20px_1px_#ffffff1a]">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Tambah Mata Kuliah Baru</h1>
|
||||
<CreateMatkulForm />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { BsPlusCircleDotted } from "react-icons/bs";
|
||||
import RuanganTable from "./MatkulTable";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
|
||||
const ITEMS_PER_PAGE = 5;
|
||||
|
||||
async function getMatkul(search?: string, page: number = 1) {
|
||||
const whereClause = search
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: "insensitive" as const } },
|
||||
{ kode: { contains: search, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
const [data, totalCount] = await Promise.all([
|
||||
prisma.mataKuliah.findMany({
|
||||
where: whereClause,
|
||||
take: ITEMS_PER_PAGE,
|
||||
skip: (page - 1) * ITEMS_PER_PAGE,
|
||||
orderBy: { kode: "asc" },
|
||||
}),
|
||||
prisma.mataKuliah.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||
return { data, totalPages, currentPage: page };
|
||||
}
|
||||
|
||||
export default async function ManajemenMatkulPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ search?: string; page?: string }>;
|
||||
}) {
|
||||
const { search = "", page = "1" } = await searchParams;
|
||||
const { data, totalPages, currentPage } = await getMatkul(search, Number(page));
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Breadcrumb className="ml-12 mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/dashboard">Admin</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="#">Manajemen Akademik</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Mata Kuliah</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Manajemen Mata Kuliah</h1>
|
||||
|
||||
<RuanganTable data={data} initialSearch={search} totalPages={totalPages} currentPage={currentPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,352 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { LuCircleAlert, LuCircleArrowLeft, LuCircleArrowRight } from "react-icons/lu";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface PesertaData {
|
||||
id: string;
|
||||
mahasiswa: {
|
||||
id: string;
|
||||
name: string;
|
||||
nim: string | null;
|
||||
semester: { id: string; name: string } | null;
|
||||
golongan: { id: string; name: string } | null;
|
||||
};
|
||||
jadwal_kuliah: {
|
||||
id: string;
|
||||
mata_kuliah: { id: string; name: string };
|
||||
ruangan: { id: string; name: string } | null;
|
||||
semester: { id: string; name: string };
|
||||
golongans: { id: string; name: string }[];
|
||||
};
|
||||
}
|
||||
|
||||
interface FilterOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
interface PesertaKuliahTableProps {
|
||||
data: PesertaData[];
|
||||
filters: {
|
||||
semesters: FilterOption[];
|
||||
mataKuliahs: FilterOption[];
|
||||
ruangans: FilterOption[];
|
||||
};
|
||||
prodiId: string;
|
||||
}
|
||||
|
||||
export default function PesertaKuliahTable({ data, filters, prodiId }: PesertaKuliahTableProps) {
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedPeserta, setSelectedPeserta] = useState<PesertaData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [golonganOptions, setGolonganOptions] = useState<FilterOption[]>([]);
|
||||
|
||||
const [localFilters, setLocalFilters] = useState<{
|
||||
semester?: string;
|
||||
golongan?: string;
|
||||
matkul?: string;
|
||||
ruangan?: string;
|
||||
search?: string;
|
||||
}>({
|
||||
semester: "",
|
||||
golongan: "",
|
||||
matkul: "",
|
||||
ruangan: "",
|
||||
search: "",
|
||||
});
|
||||
|
||||
const ITEMS_PER_PAGE = 6;
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!localFilters.semester) {
|
||||
setGolonganOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchGolongan = async () => {
|
||||
const res = await fetch(`/api/golongan?semesterId=${localFilters.semester}&prodiId=${prodiId}`);
|
||||
const data = await res.json();
|
||||
setGolonganOptions(Array.isArray(data) ? data : []);
|
||||
};
|
||||
|
||||
fetchGolongan();
|
||||
}, [localFilters.semester, prodiId]);
|
||||
|
||||
const handleSearch = useDebouncedCallback((term: string) => {
|
||||
setLocalFilters((f) => ({ ...f, search: term || undefined }));
|
||||
setCurrentPage(1);
|
||||
}, 500);
|
||||
|
||||
const handleOpenModal = (peserta: PesertaData) => {
|
||||
setSelectedPeserta(peserta);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedPeserta(null);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!selectedPeserta) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/peserta-kuliah/${selectedPeserta.id}`, { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Gagal menghapus peserta.");
|
||||
}
|
||||
router.push("/admin/manajemen-akademik/peserta-kuliah?peserta-kuliah=delete_success");
|
||||
router.refresh();
|
||||
handleCloseModal();
|
||||
} catch (err) {
|
||||
if (err instanceof Error) alert(`Error: ${err.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return data.filter((p) => {
|
||||
if (localFilters.semester && p.mahasiswa.semester?.id !== localFilters.semester) return false;
|
||||
if (localFilters.golongan && !p.jadwal_kuliah.golongans.some((g) => g.id === localFilters.golongan))
|
||||
return false;
|
||||
if (localFilters.matkul && p.jadwal_kuliah.mata_kuliah.id !== localFilters.matkul) return false;
|
||||
if (localFilters.ruangan && p.jadwal_kuliah.ruangan?.id !== localFilters.ruangan) return false;
|
||||
if (
|
||||
localFilters.search &&
|
||||
!`${p.mahasiswa.name} ${p.mahasiswa.nim}`.toLowerCase().includes(localFilters.search.toLowerCase())
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}, [data, localFilters]);
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
return filteredData.slice(start, start + ITEMS_PER_PAGE);
|
||||
}, [filteredData, currentPage]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredData.length / ITEMS_PER_PAGE));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<select
|
||||
value={localFilters.semester || ""}
|
||||
onChange={(e) => {
|
||||
const selectedSemester = e.target.value || undefined;
|
||||
setLocalFilters((f) => ({
|
||||
...f,
|
||||
semester: selectedSemester,
|
||||
golongan: undefined,
|
||||
}));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/50 dark:text-white border-gray-300 dark:border-neutral-800 text-sm focus:outline-none"
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900">
|
||||
Semua Semester
|
||||
</option>
|
||||
{filters.semesters.map((s) => (
|
||||
<option key={s.id} value={s.id} className="bg-white dark:bg-neutral-900">
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={localFilters.golongan || ""}
|
||||
onChange={(e) => {
|
||||
const selectedGolongan = e.target.value || undefined;
|
||||
setLocalFilters((f) => ({
|
||||
...f,
|
||||
golongan: selectedGolongan,
|
||||
}));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
disabled={!localFilters.semester}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/50 dark:text-white border-gray-300 dark:border-neutral-800 text-sm focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{!localFilters.semester ? (
|
||||
<option value="">Pilih semester dulu</option>
|
||||
) : golonganOptions.length === 0 ? (
|
||||
<option value="">Tidak ada golongan</option>
|
||||
) : (
|
||||
<>
|
||||
<option value="" className="bg-white dark:bg-neutral-900">
|
||||
Semua Golongan
|
||||
</option>
|
||||
{golonganOptions.map((g) => (
|
||||
<option key={g.id} value={g.id} className="bg-white dark:bg-neutral-900">
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={localFilters.matkul || ""}
|
||||
onChange={(e) => {
|
||||
setLocalFilters((f) => ({ ...f, matkul: e.target.value || undefined }));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/50 dark:text-white border-gray-300 dark:border-neutral-800 text-sm focus:outline-none"
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900">
|
||||
Semua Mata Kuliah
|
||||
</option>
|
||||
{filters.mataKuliahs.map((m) => (
|
||||
<option key={m.id} value={m.id} className="bg-white dark:bg-neutral-900">
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={localFilters.ruangan || ""}
|
||||
onChange={(e) => {
|
||||
setLocalFilters((f) => ({ ...f, ruangan: e.target.value || undefined }));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/50 dark:text-white border-gray-300 dark:border-neutral-800 text-sm focus:outline-none"
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900">
|
||||
Semua Ruangan
|
||||
</option>
|
||||
{filters.ruangans.map((r) => (
|
||||
<option key={r.id} value={r.id} className="bg-white dark:bg-neutral-900">
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari Nama / NIM..."
|
||||
defaultValue={localFilters.search || ""}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full bg-white dark:bg-neutral-950/50 text-black dark:text-gray-200 border border-gray-300 dark:border-neutral-800 rounded-md px-4 py-2 focus:outline-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-auto rounded border border-gray-300 dark:border-neutral-800">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-gray-100 dark:bg-neutral-950/50 text-gray-600 dark:text-gray-300 uppercase tracking-wide">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-center">Nama Mahasiswa</th>
|
||||
<th className="px-4 py-3 text-center">NIM</th>
|
||||
<th className="px-4 py-3 text-center">Semester/Gol</th>
|
||||
<th className="px-4 py-3 text-center">Mata Kuliah</th>
|
||||
<th className="px-4 py-3 text-center">Ruangan</th>
|
||||
<th className="px-4 py-3 text-center">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-8 text-gray-400 dark:text-gray-300">
|
||||
Tidak ada peserta ditemukan.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedData.map((p) => (
|
||||
<tr
|
||||
key={p.id}
|
||||
className="border-t border-gray-200 dark:border-neutral-800 hover:bg-gray-50 dark:hover:bg-neutral-950/40"
|
||||
>
|
||||
<td className="px-4 py-3 text-center">{p.mahasiswa.name}</td>
|
||||
<td className="px-4 py-3 text-center">{p.mahasiswa.nim}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{p.mahasiswa.semester?.name || "-"} / {p.mahasiswa.golongan?.name || "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">{p.jadwal_kuliah.mata_kuliah.name}</td>
|
||||
<td className="px-4 py-3 text-center">{p.jadwal_kuliah.ruangan?.name || "-"}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<SubmitButton
|
||||
text="Hapus"
|
||||
onClick={() => handleOpenModal(p)}
|
||||
className="bg-red-700 dark:bg-red-800/80 text-white px-4 py-2 rounded-md text-sm hover:bg-red-800 dark:hover:bg-red-950 transition-all"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<span className="text-sm">
|
||||
Halaman {currentPage} dari {totalPages}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
||||
disabled={currentPage <= 1}
|
||||
className={`px-3 py-1 text-sm rounded-md flex items-center gap-2 ${
|
||||
currentPage <= 1
|
||||
? "pointer-events-none bg-transparent border border-gray-300 dark:border-neutral-800 text-gray-400"
|
||||
: "bg-gray-100 dark:bg-gray-200 border border-gray-300 dark:border-neutral-800 text-gray-800 hover:bg-gray-200 dark:hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
<LuCircleArrowLeft /> Sebelumnya
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(p + 1, totalPages))}
|
||||
disabled={currentPage >= totalPages}
|
||||
className={`px-3 py-1 text-sm rounded-md flex items-center gap-2 ${
|
||||
currentPage >= totalPages
|
||||
? "pointer-events-none bg-transparent border border-gray-300 dark:border-neutral-800 text-gray-400"
|
||||
: "bg-gray-100 dark:bg-gray-200 border border-gray-300 dark:border-neutral-800 text-gray-800 hover:bg-gray-200 dark:hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
Selanjutnya <LuCircleArrowRight />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal Konfirmasi Hapus */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-neutral-950 rounded-lg p-6 w-full max-w-md mx-4">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<LuCircleAlert className="w-6 h-6 text-red-600" />
|
||||
Konfirmasi Hapus Peserta Kuliah
|
||||
</h2>
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-300">Yakin ingin menghapus :</p>
|
||||
<p className="font-semibold text-sm mt-1 text-gray-800 dark:text-gray-100">
|
||||
{selectedPeserta?.mahasiswa.name} ({selectedPeserta?.mahasiswa.nim})
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">Pada Mata Kuliah:</p>
|
||||
<p className="font-semibold text-sm mt-1 text-gray-800 dark:text-gray-100">
|
||||
{selectedPeserta?.jadwal_kuliah.mata_kuliah.name} ?
|
||||
</p>
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
onClick={handleCloseModal}
|
||||
className="bg-gray-100 dark:bg-neutral-900/50 border border-neutral-300 dark:border-neutral-800 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-md text-sm hover:bg-gray-300 dark:hover:bg-neutral-900 transition-all"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Ya, Hapus"
|
||||
isLoading={isLoading}
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 dark:bg-red-800 text-white px-4 py-2 rounded-md text-sm hover:bg-red-700 dark:hover:bg-red-950 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,255 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, ChangeEvent, FormEvent } from "react";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Semester } from "@/generated/prisma/client";
|
||||
|
||||
interface CreatePesertaFormProps {
|
||||
prodiId: string;
|
||||
semesters: Semester[];
|
||||
}
|
||||
|
||||
export default function CreatePesertaForm({ prodiId, semesters }: CreatePesertaFormProps) {
|
||||
const router = useRouter();
|
||||
const [semesterId, setSemesterId] = useState<string>("");
|
||||
const [golonganId, setGolonganId] = useState<string>("");
|
||||
const [mahasiswaId, setMahasiswaId] = useState<string>("");
|
||||
const [jadwalId, setJadwalId] = useState<string>("");
|
||||
|
||||
const [golonganOptions, setGolonganOptions] = useState<{ id: string; name: string }[]>([]);
|
||||
const [mahasiswaOptions, setMahasiswaOptions] = useState<
|
||||
{ id: string; name: string; nim: string | null }[]
|
||||
>([]);
|
||||
const [jadwalOptions, setJadwalOptions] = useState<{ id: string; name: string }[]>([]);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!semesterId) {
|
||||
setGolonganOptions([]);
|
||||
setGolonganId("");
|
||||
setMahasiswaOptions([]);
|
||||
setMahasiswaId("");
|
||||
setJadwalOptions([]);
|
||||
setJadwalId("");
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [golonganRes, jadwalRes] = await Promise.all([
|
||||
fetch(`/api/golongan?semesterId=${semesterId}&prodiId=${prodiId}`),
|
||||
fetch(`/api/jadwal-kuliah?semesterId=${semesterId}&prodiId=${prodiId}&golonganId=${golonganId}`),
|
||||
]);
|
||||
const [golonganData, jadwalData] = await Promise.all([golonganRes.json(), jadwalRes.json()]);
|
||||
setGolonganOptions(Array.isArray(golonganData) ? golonganData : []);
|
||||
setJadwalOptions(
|
||||
Array.isArray(jadwalData)
|
||||
? jadwalData.map((j) => ({
|
||||
id: j.id,
|
||||
name: `${j.mata_kuliah?.name || "(No Matkul)"} - ${j.hari} - ${j.jam_mulai}-${j.jam_selesai}`,
|
||||
}))
|
||||
: []
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setGolonganOptions([]);
|
||||
setJadwalOptions([]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [semesterId, prodiId, golonganId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!semesterId || !golonganId) {
|
||||
setMahasiswaOptions([]);
|
||||
setMahasiswaId("");
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchMahasiswa = async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/mahasiswa?semesterId=${semesterId}&golonganId=${golonganId}&prodiId=${prodiId}`
|
||||
);
|
||||
const data = await res.json();
|
||||
setMahasiswaOptions(Array.isArray(data) ? data : []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setMahasiswaOptions([]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMahasiswa();
|
||||
}, [semesterId, golonganId, prodiId]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!semesterId || !golonganId || !mahasiswaId || !jadwalId) {
|
||||
setError("Semua field harus diisi.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/peserta-kuliah", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mahasiswaId,
|
||||
jadwalKuliahId: jadwalId,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
router.push(`?peserta-kuliah=${data.redirectParam || "error"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/admin/manajemen-akademik/peserta-kuliah?peserta-kuliah=create_success");
|
||||
router.refresh();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Terjadi kesalahan.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Semester</label>
|
||||
<select
|
||||
value={semesterId}
|
||||
onChange={(e) => setSemesterId(e.target.value)}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900">
|
||||
Pilih Semester
|
||||
</option>
|
||||
{semesters.map((s) => (
|
||||
<option key={s.id} value={s.id} className="bg-white dark:bg-neutral-900">
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Golongan</label>
|
||||
<select
|
||||
value={golonganId}
|
||||
onChange={(e) => setGolonganId(e.target.value)}
|
||||
disabled={!semesterId || golonganOptions.length === 0}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/50 dark:text-white border-gray-300 dark:border-neutral-800 text-sm focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
required
|
||||
>
|
||||
{!semesterId ? (
|
||||
<option value="" className="bg-white dark:bg-neutral-900" disabled>
|
||||
Pilih Semester dulu
|
||||
</option>
|
||||
) : golonganOptions.length === 0 ? (
|
||||
<option value="" className="bg-white dark:bg-neutral-900" disabled>
|
||||
Tidak ada golongan
|
||||
</option>
|
||||
) : (
|
||||
<>
|
||||
<option value="" className="bg-white dark:bg-neutral-900">
|
||||
Pilih Golongan
|
||||
</option>
|
||||
{golonganOptions.map((g) => (
|
||||
<option key={g.id} value={g.id} className="bg-white dark:bg-neutral-900">
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Mahasiswa</label>
|
||||
<select
|
||||
value={mahasiswaId}
|
||||
onChange={(e) => setMahasiswaId(e.target.value)}
|
||||
disabled={!semesterId || !golonganId || mahasiswaOptions.length === 0}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/50 dark:text-white border-gray-300 dark:border-neutral-800 text-sm focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
required
|
||||
>
|
||||
{!semesterId || !golonganId ? (
|
||||
<option value="" className="bg-white dark:bg-neutral-900" disabled>
|
||||
Pilih Semester & Golongan dulu
|
||||
</option>
|
||||
) : mahasiswaOptions.length === 0 ? (
|
||||
<option value="" className="bg-white dark:bg-neutral-900" disabled>
|
||||
Tidak ada mahasiswa
|
||||
</option>
|
||||
) : (
|
||||
<>
|
||||
<option value="" className="bg-white dark:bg-neutral-900">
|
||||
Pilih Mahasiswa
|
||||
</option>
|
||||
{mahasiswaOptions.map((m) => (
|
||||
<option key={m.id} value={m.id} className="bg-white dark:bg-neutral-900">
|
||||
{m.name} ({m.nim || "-"})
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Jadwal Kuliah</label>
|
||||
<select
|
||||
value={jadwalId}
|
||||
onChange={(e) => setJadwalId(e.target.value)}
|
||||
disabled={!semesterId || jadwalOptions.length === 0}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/50 dark:text-white border-gray-300 dark:border-neutral-800 text-sm focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
required
|
||||
>
|
||||
{!semesterId ? (
|
||||
<option value="" className="bg-white dark:bg-neutral-900" disabled>
|
||||
Pilih Semester dulu
|
||||
</option>
|
||||
) : jadwalOptions.length === 0 ? (
|
||||
<option value="" className="bg-white dark:bg-neutral-900" disabled>
|
||||
Tidak ada jadwal kuliah
|
||||
</option>
|
||||
) : (
|
||||
<>
|
||||
<option value="" className="bg-white dark:bg-neutral-900">
|
||||
Pilih Jadwal Kuliah
|
||||
</option>
|
||||
{jadwalOptions.map((j) => (
|
||||
<option key={j.id} value={j.id} className="bg-white dark:bg-neutral-900">
|
||||
{j.name}
|
||||
</option>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
text="Tambah Peserta"
|
||||
isLoading={isLoading}
|
||||
className="bg-black dark:bg-white text-white dark:text-gray-900 dark:hover:bg-gray-200 hover:bg-black/80 px-6 py-2 rounded-md text-sm"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
href="/admin/manajemen-akademik/peserta-kuliah"
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-900 dark:text-white dark:hover:bg-neutral-900 hover:bg-gray-200 px-6 py-2 rounded-md text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import CreatePesertaForm from "./CreatePesertaForm";
|
||||
|
||||
async function getInitialData(adminProdiId: string) {
|
||||
const semesters = await prisma.semester.findMany({ orderBy: { name: "asc" } });
|
||||
return { semesters };
|
||||
}
|
||||
|
||||
export default async function CreatePesertaPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||
redirect("/login?error=unauthorized");
|
||||
}
|
||||
|
||||
const adminUser = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { prodiId: true },
|
||||
});
|
||||
|
||||
if (!adminUser || !adminUser.prodiId) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-xl text-red-500">
|
||||
Akses ditolak: Akun admin Anda tidak terasosiasi dengan Program Studi.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formData = await getInitialData(adminUser.prodiId);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mt-10 mx-auto p-8 bg-white dark:bg-black/20 rounded-md shadow-[0_0_10px_1px_#1a1a1a1a] dark:shadow-[0_0_20px_1px_#ffffff1a]">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Tambah Peserta Kuliah</h1>
|
||||
<CreatePesertaForm prodiId={adminUser.prodiId} semesters={formData.semesters} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { BsPlusCircleDotted } from "react-icons/bs";
|
||||
import PesertaKuliahTable from "./PesertaKuliahTable";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export default async function ManajemenPesertaPage() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||
redirect("/login?error=unauthorized");
|
||||
}
|
||||
|
||||
const adminUser = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { prodiId: true },
|
||||
});
|
||||
|
||||
if (!adminUser || !adminUser.prodiId) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-xl text-red-500">
|
||||
Akses ditolak: Akun admin Anda tidak terasosiasi dengan Program Studi manapun.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const adminProdiId = adminUser.prodiId;
|
||||
|
||||
const [pesertaList, semesters, mataKuliahs, ruangans] = await Promise.all([
|
||||
prisma.pesertaKuliah.findMany({
|
||||
where: { jadwal_kuliah: { is: { prodiId: adminProdiId } }, mahasiswa: { role: "MAHASISWA" } },
|
||||
include: {
|
||||
mahasiswa: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nim: true,
|
||||
semester: { select: { id: true, name: true } },
|
||||
golongan: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
jadwal_kuliah: {
|
||||
select: {
|
||||
id: true,
|
||||
mata_kuliah: { select: { name: true, id: true } },
|
||||
semester: { select: { name: true, id: true } },
|
||||
ruangan: { select: { name: true, id: true } },
|
||||
golongans: { select: { name: true, id: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
}),
|
||||
prisma.semester.findMany({ orderBy: { name: "asc" } }),
|
||||
prisma.mataKuliah.findMany({
|
||||
where: { jadwal_kuliah: { some: { prodiId: adminProdiId } } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.ruangan.findMany({
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Breadcrumb className="ml-12 mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/dashboard">Admin</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="#">Manajemen Akademik</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Peserta Kuliah</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Manajemen Peserta Kuliah</h1>
|
||||
<SubmitButton
|
||||
text="Tambah Peserta Kuliah"
|
||||
href="/admin/manajemen-akademik/peserta-kuliah/create"
|
||||
icon={<BsPlusCircleDotted />}
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-full hover:bg-gray-100 dark:hover:bg-black/10 hover:transition-all text-sm border border-gray-300 dark:border-neutral-800 flex items-center gap-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PesertaKuliahTable
|
||||
data={pesertaList}
|
||||
prodiId={adminProdiId}
|
||||
filters={{
|
||||
semesters: semesters.map((s) => ({ id: s.id.toString(), name: s.name })),
|
||||
mataKuliahs: mataKuliahs.map((m) => ({ id: m.id.toString(), name: m.name })),
|
||||
ruangans: ruangans.map((r) => ({ id: r.id.toString(), name: r.name })),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,120 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ProgramStudi } from "@/generated/prisma/client";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
|
||||
interface ProdiTableProps {
|
||||
data: ProgramStudi[];
|
||||
}
|
||||
|
||||
const ProdiTable = ({ data }: ProdiTableProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedProdi, setSelectedProdi] = useState<ProgramStudi | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleOpenModal = (prodi: ProgramStudi) => {
|
||||
setSelectedProdi(prodi);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedProdi(null);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!selectedProdi) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/prodi/${selectedProdi.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Gagal menghapus program studi.");
|
||||
}
|
||||
handleCloseModal();
|
||||
router.push("/admin/manajemen-akademik/program-studi?program-studi=delete_success");
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) alert(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-auto rounded border border-gray-300 dark:border-neutral-800 shadow-sm">
|
||||
<table className="min-w-full text-sm text-left text-black dark:text-gray-200">
|
||||
<thead className="bg-gray-100 dark:bg-neutral-950/50 uppercase tracking-wide text-gray-600 dark:text-gray-300">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-semibold text-center">Nama Program Studi</th>
|
||||
<th className="px-6 py-3 font-semibold text-center whitespace-nowrap">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-center py-8 text-gray-400 dark:text-gray-300">
|
||||
Belum ada data program studi.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((prodi) => (
|
||||
<tr
|
||||
key={prodi.id}
|
||||
className="border-t border-gray-200 dark:border-neutral-800 hover:bg-gray-50 dark:hover:bg-neutral-950/40"
|
||||
>
|
||||
<td className="px-6 py-4 text-center">{prodi.name}</td>
|
||||
<td className="px-6 py-4 flex items-center gap-4 justify-center">
|
||||
<SubmitButton
|
||||
href={`/admin/manajemen-akademik/program-studi/${prodi.id}/edit`}
|
||||
text="Edit"
|
||||
className="bg-white w-18 dark:bg-neutral-950/40 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-md hover:bg-gray-200 dark:hover:bg-[#1A1A1A] hover:transition-all text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Hapus"
|
||||
onClick={() => handleOpenModal(prodi)}
|
||||
className="bg-red-700 w-18 dark:bg-red-800/80 text-white dark:text-gray-200 px-4 py-2 rounded-md hover:bg-red-800 dark:hover:bg-red-950 hover:transition-all text-sm border-none"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-neutral-950 dark:backdrop-blur-sm rounded-lg p-6 w-full max-w-lg mx-4 shadow-[0_0_30px_1px_#C10007] dark:shadow-[0_0_34px_1px_#460809]">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Konfirmasi Hapus</h2>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
Apakah Anda yakin ingin menghapus <span className="font-semibold">{selectedProdi?.name}</span>?
|
||||
Tindakan ini tidak dapat dibatalkan.
|
||||
</p>
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
onClick={handleCloseModal}
|
||||
className="bg-gray-100 dark:bg-neutral-900/50 border border-neutral-300 dark:border-neutral-800 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-md text-sm hover:bg-gray-300 dark:hover:bg-neutral-900 transition-all"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Ya, Hapus"
|
||||
isLoading={isLoading}
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 dark:bg-red-800 text-white px-4 py-2 rounded-md text-sm hover:bg-red-700 dark:hover:bg-red-950 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProdiTable;
|
|
@ -0,0 +1,68 @@
|
|||
"use client";
|
||||
|
||||
import { useState, FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ProgramStudi } from "@/generated/prisma/client";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
|
||||
export default function EditProdiForm({ prodi }: { prodi: ProgramStudi }) {
|
||||
const [name, setName] = useState(prodi.name);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/prodi/${prodi.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Gagal menyimpan perubahan.");
|
||||
router.push("/admin/manajemen-akademik/program-studi?program-studi=update_success");
|
||||
router.refresh();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<div className="space-y-4 w-full">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Nama Program Studi
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500 dark:text-red-400">{error}</p>}
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
text="Simpan"
|
||||
isLoading={isLoading}
|
||||
className="bg-black dark:bg-white text-white dark:text-gray-900 px-6 py-2 rounded text-sm"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
href="/admin/manajemen-akademik/program-studi"
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-900 dark:text-white dark:hover:bg-black/10 hover:bg-gray-200 px-6 py-2 rounded-md text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import EditProdiForm from "./EditProdiForm";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
async function getProdiById(id: string) {
|
||||
const prodi = await prisma.programStudi.findUnique({ where: { id: id } });
|
||||
if (!prodi) notFound();
|
||||
return prodi;
|
||||
}
|
||||
|
||||
export default async function EditProdiPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const prodi = await getProdiById(id);
|
||||
return (
|
||||
<div className="w-full max-w-2xl mt-10 mx-auto p-8 bg-white dark:bg-black/20 rounded-md shadow-[0_0_10px_1px_#1a1a1a1a] dark:shadow-[0_0_20px_1px_#ffffff1a]">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Edit Program Studi</h1>
|
||||
<EditProdiForm prodi={prodi} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
import { useState, FormEvent } from "react";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function CreateProdiPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/prodi", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Gagal menambahkan prodi.");
|
||||
router.push("/admin/manajemen-akademik/program-studi?program-studi=create_success");
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mt-10 mx-auto px-8 py-6 bg-white dark:bg-neutral-900 rounded shadow-[0_0_10px_1px_#1a1a1a1a] dark:shadow-[0_0_20px_1px_#ffffff1a]">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Tambah Program Studi</h1>
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<div className="space-y-4 w-full">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Nama Program Studi
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value={name}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Teknik Komputer"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500 dark:text-red-400">{error}</p>}
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
text="Tambah"
|
||||
isLoading={isLoading}
|
||||
className="bg-black dark:bg-white text-white dark:text-gray-900 px-6 py-2 rounded text-sm"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
href="/admin/manajemen-akademik/program-studi"
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-900 dark:text-white dark:hover:bg-black/10 hover:bg-gray-200 px-6 py-2 rounded-md text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import ProdiTable from "./ProdiTable";
|
||||
import { BsPlusCircleDotted } from "react-icons/bs";
|
||||
import prisma from "@/lib/prisma";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
|
||||
async function getProdi() {
|
||||
const prodi = await prisma.programStudi.findMany({
|
||||
orderBy: {
|
||||
id: "asc",
|
||||
},
|
||||
});
|
||||
return prodi;
|
||||
}
|
||||
|
||||
export default async function ManajemenProdiPage() {
|
||||
const data = await getProdi();
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto p-6 rounded-lg shadow">
|
||||
<Breadcrumb className="ml-12 mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/dashboard">Admin</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="#">Manajemen Akademik</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Program Studi</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Manajemen Program Studi</h1>
|
||||
<SubmitButton
|
||||
text="Tambah Program Studi"
|
||||
href="/admin/manajemen-akademik/program-studi/create"
|
||||
icon={<BsPlusCircleDotted />}
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-full hover:bg-gray-100 dark:hover:bg-black/10 hover:transition-all text-sm border border-gray-300 dark:border-neutral-800 flex items-center gap-2 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<ProdiTable data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,188 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Ruangan } from "@/generated/prisma/client";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { LuCircleAlert, LuCircleArrowLeft, LuCircleArrowRight } from "react-icons/lu";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import axios from "axios";
|
||||
import Link from "next/link";
|
||||
import { BsPlusCircleDotted } from "react-icons/bs";
|
||||
|
||||
interface Props {
|
||||
data: Ruangan[];
|
||||
initialSearch?: string;
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
export default function RuanganTable({ data, initialSearch, totalPages, currentPage }: Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedRuangan, setSelectedRuangan] = useState<Ruangan | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSearch = useDebouncedCallback((term: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (term) {
|
||||
params.set("search", term);
|
||||
} else {
|
||||
params.delete("search");
|
||||
}
|
||||
router.replace(`?${params.toString()}`);
|
||||
}, 300);
|
||||
|
||||
const handleOpenModal = (ruangan: Ruangan) => {
|
||||
setSelectedRuangan(ruangan);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!selectedRuangan) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { data } = await axios.delete(`/api/ruangan/${selectedRuangan.id}`);
|
||||
console.log(data);
|
||||
setIsModalOpen(false);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("ruangan", "delete_success");
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) alert(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari berdasarkan kode atau nama ruangan..."
|
||||
defaultValue={initialSearch}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="bg-white dark:bg-neutral-950/50 text-black dark:text-gray-200 border border-gray-300 dark:border-neutral-800 rounded-md px-4 py-2 focus:outline-none text-sm w-88"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Tambah Ruangan"
|
||||
href="/admin/manajemen-akademik/ruangan/create"
|
||||
icon={<BsPlusCircleDotted />}
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-full hover:bg-gray-100 dark:hover:bg-black/10 hover:transition-all text-sm border border-gray-300 dark:border-neutral-800 flex items-center gap-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto rounded border border-gray-300 dark:border-neutral-800 shadow-sm">
|
||||
<table className="min-w-full text-sm text-left text-black dark:text-gray-200">
|
||||
<thead className="bg-gray-100 dark:bg-neutral-950/50 uppercase tracking-wide">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-semibold">Kode</th>
|
||||
<th className="px-6 py-3 font-semibold">Nama Ruangan</th>
|
||||
<th className="px-6 py-3 font-semibold">Tipe</th>
|
||||
<th className="px-6 py-3 font-semibold text-center w-px whitespace-nowrap">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center py-8">
|
||||
Tidak ada ruangan ditemukan.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((ruangan) => (
|
||||
<tr
|
||||
key={ruangan.id}
|
||||
className="border-t border-gray-200 dark:border-neutral-800 hover:bg-gray-50 dark:hover:bg-neutral-950/40"
|
||||
>
|
||||
<td className="px-6 py-4 font-mono">{ruangan.kode}</td>
|
||||
<td className="px-6 py-4">{ruangan.name}</td>
|
||||
<td className="px-6 py-4">{ruangan.type}</td>
|
||||
<td className="px-6 py-4 flex items-center gap-4 justify-center">
|
||||
<SubmitButton
|
||||
text="Edit"
|
||||
href={`/admin/manajemen-akademik/ruangan/${ruangan.id}/edit`}
|
||||
className="bg-white w-18 dark:bg-neutral-950/40 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-md hover:bg-gray-200 dark:hover:bg-[#1A1A1A] hover:transition-all text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Hapus"
|
||||
onClick={() => handleOpenModal(ruangan)}
|
||||
className="bg-red-700 w-18 dark:bg-red-800/80 text-white dark:text-gray-200 px-4 py-2 rounded-md hover:bg-red-800 dark:hover:bg-red-950 hover:transition-all text-sm border-none"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-400">
|
||||
Halaman {currentPage} dari {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`?page=${currentPage - 1}`}
|
||||
className={`px-3 py-1 text-sm rounded-md flex items-center gap-2 ${
|
||||
currentPage <= 1
|
||||
? "pointer-events-none bg-transparent dark:bg-black/80 border border-gray-300 dark:border-neutral-800 text-gray-400"
|
||||
: "bg-gray-100 dark:bg-gray-200 border border-gray-300 dark:border-neutral-800 text-gray-800 dark:text-gray-800 hover:bg-gray-200 dark:hover:bg-gray-300 hover:transition-all"
|
||||
}`}
|
||||
>
|
||||
<LuCircleArrowLeft />
|
||||
Sebelumnya
|
||||
</Link>
|
||||
<Link
|
||||
href={`?page=${currentPage + 1}`}
|
||||
className={`px-3 py-1 text-sm rounded-md flex items-center gap-2 ${
|
||||
currentPage >= totalPages
|
||||
? "pointer-events-none bg-transparent dark:bg-black/80 border border-gray-300 dark:border-neutral-800 text-gray-400"
|
||||
: "bg-gray-100 dark:bg-gray-200 border border-gray-300 dark:border-neutral-800 text-gray-800 dark:text-gray-800 hover:bg-gray-200 dark:hover:bg-gray-300 hover:transition-all"
|
||||
}`}
|
||||
>
|
||||
Selanjutnya
|
||||
<LuCircleArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-neutral-950 dark:backdrop-blur-sm rounded-lg p-6 w-full max-w-lg mx-4 shadow-[0_0_30px_1px_#C10007] dark:shadow-[0_0_34px_1px_#460809]">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<LuCircleAlert className="w-6 h-6 text-red-600" /> Konfirmasi Hapus
|
||||
</h2>
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
Apakah Anda yakin ingin menghapus ruangan :
|
||||
<p className="font-bold text-sm mt-2 mb-2">
|
||||
{selectedRuangan?.name} ({selectedRuangan?.kode}) ?
|
||||
</p>
|
||||
Tindakan ini tidak dapat dibatalkan.
|
||||
</p>
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="bg-gray-100 dark:bg-neutral-900/50 border border-neutral-300 dark:border-neutral-800 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-md text-sm hover:bg-gray-300 dark:hover:bg-neutral-900 transition-all"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Ya, Hapus"
|
||||
isLoading={isLoading}
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 dark:bg-red-800 text-white px-4 py-2 rounded-md text-sm hover:bg-red-700 dark:hover:bg-red-950 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,179 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, ChangeEvent, FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { Ruangan } from "@/generated/prisma/client";
|
||||
|
||||
const parseKodeRuangan = (kode: string): { lantai: string; ruang: string } => {
|
||||
const parts = kode.split("-");
|
||||
if (parts.length === 3) {
|
||||
const lantai = parseInt(parts[1], 10).toString();
|
||||
const ruang = parseInt(parts[2], 10).toString();
|
||||
return { lantai, ruang };
|
||||
}
|
||||
return { lantai: "", ruang: "" };
|
||||
};
|
||||
|
||||
export default function EditRuanganForm({ ruangan }: { ruangan: Ruangan }) {
|
||||
const router = useRouter();
|
||||
|
||||
const [form, setForm] = useState(() => {
|
||||
const { lantai, ruang } = parseKodeRuangan(ruangan.kode);
|
||||
return {
|
||||
name: ruangan.name,
|
||||
type: ruangan.type,
|
||||
lantai: lantai,
|
||||
ruang: ruang,
|
||||
kode: ruangan.kode,
|
||||
};
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const prefix = "JTI";
|
||||
const lantaiFormatted = form.lantai.toString().padStart(2, "0");
|
||||
const ruangFormatted = form.ruang.toString().padStart(2, "0");
|
||||
const generatedKode = `${prefix}-${lantaiFormatted}-${ruangFormatted}`;
|
||||
setForm((prev) => ({ ...prev, kode: generatedKode }));
|
||||
}, [form.lantai, form.ruang]);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/ruangan/${ruangan.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
kode: form.kode,
|
||||
name: form.name,
|
||||
type: form.type,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Gagal menyimpan perubahan.");
|
||||
|
||||
router.push("/admin/manajemen-akademik/ruangan?ruangan=update_success");
|
||||
router.refresh();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<div className="space-y-4 w-full">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium mb-1">
|
||||
Nama Ruangan
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
autoComplete="off"
|
||||
placeholder="Contoh: Laboratorium Jaringan Komputer"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="type" className="block text-sm font-medium mb-1">
|
||||
Tipe Ruangan
|
||||
</label>
|
||||
<select
|
||||
id="type"
|
||||
name="type"
|
||||
value={form.type}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="TEORI" className="bg-white dark:bg-neutral-900">
|
||||
TEORI
|
||||
</option>
|
||||
<option value="PRAKTIKUM" className="bg-white dark:bg-neutral-900">
|
||||
PRAKTIKUM
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="lantai" className="block text-sm font-medium mb-1">
|
||||
Lantai
|
||||
</label>
|
||||
<input
|
||||
id="lantai"
|
||||
name="lantai"
|
||||
value={form.lantai}
|
||||
onChange={handleChange}
|
||||
type="number"
|
||||
placeholder="Contoh: 2"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ruang" className="block text-sm font-medium mb-1">
|
||||
Nomor Ruang
|
||||
</label>
|
||||
<input
|
||||
id="ruang"
|
||||
name="ruang"
|
||||
value={form.ruang}
|
||||
onChange={handleChange}
|
||||
type="number"
|
||||
placeholder="Contoh: 1"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="kode" className="block text-sm font-medium mb-1">
|
||||
Kode Ruangan (Otomatis)
|
||||
</label>
|
||||
<input
|
||||
id="kode"
|
||||
name="kode"
|
||||
value={form.kode}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-200 dark:bg-black/10 dark:text-white border-gray-300 dark:border-gray-800 text-sm focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500 mt-2">{error}</p>}
|
||||
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
text="Simpan"
|
||||
isLoading={isLoading}
|
||||
className="bg-black dark:bg-white text-white dark:text-gray-900 dark:hover:bg-gray-200 hover:bg-black/80 px-6 py-2 rounded-md text-sm"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
href="/admin/manajemen-akademik/ruangan"
|
||||
className="bg-white dark:bg-neutral-950/50 text-text-gray-900 dark:white dark:hover:bg-black/10 hover:bg-gray-200 px-6 py-2 rounded-md text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
import EditRuanganForm from "./EditRuanganForm";
|
||||
|
||||
async function getRuanganById(id: string) {
|
||||
const ruangan = await prisma.ruangan.findUnique({
|
||||
where: { id: id },
|
||||
});
|
||||
if (!ruangan) {
|
||||
notFound();
|
||||
}
|
||||
return ruangan;
|
||||
}
|
||||
|
||||
export default async function EditRuanganPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
|
||||
const ruangan = await getRuanganById(id);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mt-10 mx-auto p-8 bg-white dark:bg-black/20 rounded-md shadow-[0_0_10px_1px_#1a1a1a1a] dark:shadow-[0_0_20px_1px_#ffffff1a]">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Edit Ruangan</h1>
|
||||
<EditRuanganForm ruangan={ruangan} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,178 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, ChangeEvent, FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
|
||||
export default function CreateRuanganForm() {
|
||||
const router = useRouter();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
type: "",
|
||||
lantai: "",
|
||||
ruang: "",
|
||||
kode: "JTI-00-00",
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const prefix = "JTI";
|
||||
const lantaiFormatted = form.lantai.toString().padStart(2, "0");
|
||||
const ruangFormatted = form.ruang.toString().padStart(2, "0");
|
||||
|
||||
const generatedKode = `${prefix}-${lantaiFormatted}-${ruangFormatted}`;
|
||||
|
||||
setForm((prev) => ({ ...prev, kode: generatedKode }));
|
||||
}, [form.lantai, form.ruang]); // Array dependensi: efek ini berjalan jika form.lantai atau form.ruang berubah
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/ruangan", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
kode: form.kode,
|
||||
name: form.name,
|
||||
type: form.type,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Gagal menambahkan ruangan.");
|
||||
|
||||
router.push("/admin/manajemen-akademik/ruangan?ruangan=create_success");
|
||||
router.refresh();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<div className="space-y-4 w-full">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Nama Ruangan
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
autoComplete="off"
|
||||
placeholder="Contoh: Arsitektur Jaringan Komputer"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Tipe Ruangan
|
||||
</label>
|
||||
|
||||
<select
|
||||
id="type"
|
||||
name="type"
|
||||
value={form.type}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih Tipe
|
||||
</option>
|
||||
<option value="TEORI" className="bg-white dark:bg-neutral-900">
|
||||
TEORI
|
||||
</option>
|
||||
<option value="PRAKTIKUM" className="bg-white dark:bg-neutral-900">
|
||||
PRAKTIKUM
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="lantai"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Lantai
|
||||
</label>
|
||||
<input
|
||||
id="lantai"
|
||||
name="lantai"
|
||||
value={form.lantai}
|
||||
onChange={handleChange}
|
||||
type="number"
|
||||
placeholder="Contoh: 2"
|
||||
autoComplete="new-password"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="ruang"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Nomor Ruang
|
||||
</label>
|
||||
<input
|
||||
id="ruang"
|
||||
name="ruang"
|
||||
value={form.ruang}
|
||||
onChange={handleChange}
|
||||
type="number"
|
||||
placeholder="Contoh: 1"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="kode" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Kode Ruangan (Otomatis)
|
||||
</label>
|
||||
<input
|
||||
id="kode"
|
||||
name="kode"
|
||||
value={form.kode}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-200 dark:bg-black/10 dark:text-white border-gray-300 dark:border-gray-800 text-sm focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500 dark:text-red-400 mt-2">{error}</p>}
|
||||
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
text="Tambah"
|
||||
isLoading={isLoading}
|
||||
className="bg-black dark:bg-white text-white dark:text-gray-900 dark:hover:bg-gray-200 hover:bg-black/80 px-6 py-2 rounded-md text-sm"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
href="/admin/manajemen-akademik/ruangan"
|
||||
className="bg-white dark:bg-neutral-950/50 text-text-gray-900 dark:white dark:hover:bg-black/10 hover:bg-gray-200 px-6 py-2 rounded-md text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import CreateRuanganForm from "./CreateRuanganForm";
|
||||
|
||||
export default function CreateRuanganPage() {
|
||||
return (
|
||||
<div className="w-full max-w-2xl mt-10 mx-auto p-8 bg-white dark:bg-black/20 rounded-md shadow-[0_0_10px_1px_#1a1a1a1a] dark:shadow-[0_0_20px_1px_#ffffff1a]">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Tambah Ruangan Baru</h1>
|
||||
<CreateRuanganForm />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { BsPlusCircleDotted } from "react-icons/bs";
|
||||
import RuanganTable from "./RuanganTable";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
|
||||
const ITEMS_PER_PAGE = 5;
|
||||
|
||||
async function getData(search?: string, page: number = 1) {
|
||||
const whereClause = search
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: "insensitive" as const } },
|
||||
{ kode: { contains: search, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
const [data, totalCount] = await Promise.all([
|
||||
prisma.ruangan.findMany({
|
||||
where: whereClause,
|
||||
take: ITEMS_PER_PAGE,
|
||||
skip: (page - 1) * ITEMS_PER_PAGE,
|
||||
orderBy: { kode: "asc" },
|
||||
}),
|
||||
prisma.ruangan.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||
return { data, totalPages, currentPage: page };
|
||||
}
|
||||
|
||||
export default async function ManajemenRuanganPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ search?: string; page?: string }>;
|
||||
}) {
|
||||
const { search = "", page = "1" } = await searchParams;
|
||||
|
||||
const { data, totalPages, currentPage } = await getData(search, Number(page));
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Breadcrumb className="ml-12 mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/dashboard">Admin</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="#">Manajemen Akademik</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Ruangan</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Manajemen Ruangan</h1>
|
||||
</div>
|
||||
<RuanganTable data={data} initialSearch={search} totalPages={totalPages} currentPage={currentPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
"use client";
|
||||
|
||||
import { useState, ChangeEvent, FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Semester } from "@/generated/prisma/client";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
|
||||
interface EditSemesterFormProps {
|
||||
semester: Semester;
|
||||
}
|
||||
|
||||
export default function EditSemesterForm({ semester }: EditSemesterFormProps) {
|
||||
const [form, setForm] = useState({
|
||||
name: semester.name,
|
||||
tipe: semester.tipe,
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/semester/${semester.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Gagal menyimpan perubahan.");
|
||||
}
|
||||
|
||||
router.push("/admin/manajemen-akademik/semester?semester=update_success");
|
||||
router.refresh();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("Terjadi kesalahan yang tidak diketahui.");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<div className="space-y-4 w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<input
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Nama Semester"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
<select
|
||||
name="tipe"
|
||||
value={form.tipe}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="GANJIL" className="bg-white dark:bg-neutral-900">
|
||||
Ganjil
|
||||
</option>
|
||||
<option value="GENAP" className="bg-white dark:bg-neutral-900">
|
||||
Genap
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500 dark:text-red-400">{error}</p>}
|
||||
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
text="Simpan"
|
||||
isLoading={isLoading}
|
||||
className="bg-black dark:bg-white text-white dark:text-gray-900 dark:hover:bg-gray-200 hover:bg-black/80 px-6 py-2 rounded-md text-sm"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
href="/admin/manajemen-akademik/semester"
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-900 dark:text-white dark:hover:bg-black/10 hover:bg-gray-200 px-6 py-2 rounded-md text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
import EditSemesterForm from "./EditSemesterForm";
|
||||
|
||||
async function getSemesterById(id: string) {
|
||||
// const numericId = parseInt(id, 10);
|
||||
|
||||
// if (isNaN(numericId)) {
|
||||
// notFound();
|
||||
// }
|
||||
|
||||
const semester = await prisma.semester.findUnique({
|
||||
where: { id: id },
|
||||
});
|
||||
|
||||
if (!semester) notFound();
|
||||
return semester;
|
||||
}
|
||||
|
||||
export default async function EditSemesterPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const semester = await getSemesterById(id);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mt-10 mx-auto p-8 bg-white dark:bg-black/20 rounded-md shadow-[0_0_10px_1px_#1a1a1a1a] dark:shadow-[0_0_20px_1px_#ffffff1a]">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Edit {semester.name}</h1>
|
||||
<EditSemesterForm semester={semester} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
"use client";
|
||||
|
||||
import { useState, ChangeEvent, FormEvent } from "react";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function CreateSemesterPage() {
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
tipe: "",
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/semester", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Gagal menyimpan data.");
|
||||
}
|
||||
|
||||
router.push("/admin/manajemen-akademik/semester?semester=create_success");
|
||||
} catch (err: unknown) {
|
||||
console.error(err);
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("Terjadi kesalahan yang tidak diketahui.");
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mt-10 mx-auto p-8 bg-white dark:bg-black/20 rounded-md shadow-[0_0_10px_1px_#1a1a1a1a] dark:shadow-[0_0_20px_1px_#ffffff1a]">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Tambah Semester</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<div className="space-y-4 w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<input
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Semester 1"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/60 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
<select
|
||||
name="tipe"
|
||||
value={form.tipe}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih Tipe Semester
|
||||
</option>
|
||||
<option value="GANJIL" className="bg-white dark:bg-neutral-900">
|
||||
Ganjil
|
||||
</option>
|
||||
<option value="GENAP" className="bg-white dark:bg-neutral-900">
|
||||
Genap
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
{/* Submit */}
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
text="Tambah"
|
||||
isLoading={isLoading}
|
||||
className="bg-black dark:bg-white text-white dark:text-gray-900 dark:hover:bg-gray-200 hover:bg-black/80 px-6 py-2 rounded-md text-sm"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
href="/admin/manajemen-akademik/semester"
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-900 dark:text-white dark:hover:bg-black/10 hover:bg-gray-200 px-6 py-2 rounded-md text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import { auth } from "@/auth"; // Untuk autentikasi admin
|
||||
import { redirect } from "next/navigation"; // Untuk redirect
|
||||
import { getAllSemesters } from "@/lib/data"; // Fungsi baru untuk ambil data semester
|
||||
|
||||
import SemesterTable from "@/components/admin/manajemen-akademik/semester/SemesterTable";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { BsPlusCircleDotted } from "react-icons/bs";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
|
||||
const ManageSemesterPage = async () => {
|
||||
const session = await auth();
|
||||
if (!session || !session.user || session.user.role !== "ADMIN") {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const semesters = await getAllSemesters();
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Breadcrumb className="ml-12 mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/dashboard">Admin</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="#">Manajemen Akademik</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Semester</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Manajemen Semester</h1>
|
||||
<SubmitButton
|
||||
text="Tambah Semester"
|
||||
href="/admin/manajemen-akademik/semester/create"
|
||||
icon={<BsPlusCircleDotted />}
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-full hover:bg-gray-100 dark:hover:bg-black/10 hover:transition-all text-sm border border-gray-300 dark:border-neutral-800 flex items-center gap-2 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SemesterTable data={semesters} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageSemesterPage;
|
|
@ -0,0 +1,223 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { LuCircleAlert } from "react-icons/lu";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { AlatMode, AlatStatus } from "@/generated/prisma/client";
|
||||
import useSWR from "swr";
|
||||
import { BsPlusCircleDotted } from "react-icons/bs";
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
type AlatPresensiData = {
|
||||
id: string;
|
||||
name: string;
|
||||
mode: AlatMode;
|
||||
status: AlatStatus;
|
||||
jadwal_nyala: string | null;
|
||||
jadwal_mati: string | null;
|
||||
ruangan: { kode: string; name: string };
|
||||
};
|
||||
|
||||
export interface AlatPresensiTableProps {
|
||||
data: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: AlatStatus;
|
||||
ruanganId: string;
|
||||
mode: AlatMode;
|
||||
targetMahasiswaId: string | null;
|
||||
jadwal_nyala: string | null;
|
||||
jadwal_mati: string | null;
|
||||
ruangan: {
|
||||
kode: string;
|
||||
name: string;
|
||||
};
|
||||
}>;
|
||||
initialSearch?: string;
|
||||
}
|
||||
|
||||
export default function AlatPresensiTable({ data, initialSearch }: AlatPresensiTableProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const search = searchParams.get("search") || "";
|
||||
|
||||
const {
|
||||
data: fetchedData,
|
||||
error,
|
||||
isLoading,
|
||||
} = useSWR<AlatPresensiData[]>(`/api/alat-presensi${search ? `?search=${search}` : ""}`, fetcher, {
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedAlat, setSelectedAlat] = useState<AlatPresensiData | null>(null);
|
||||
const [isLoadingDelete, setIsLoadingDelete] = useState(false);
|
||||
|
||||
const handleSearch = useDebouncedCallback((term: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (term) {
|
||||
params.set("search", term);
|
||||
} else {
|
||||
params.delete("search");
|
||||
}
|
||||
router.replace(`?${params.toString()}`);
|
||||
}, 500);
|
||||
|
||||
const handleOpenModal = (alat: AlatPresensiData) => {
|
||||
setSelectedAlat(alat);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!selectedAlat) return;
|
||||
setIsLoadingDelete(true);
|
||||
try {
|
||||
const response = await fetch(`/api/alat-presensi/${selectedAlat.id}`, { method: "DELETE" });
|
||||
if (!response.ok) throw new Error("Gagal menghapus alat.");
|
||||
setIsModalOpen(false);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("alat_deleted", "success");
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) alert(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setIsLoadingDelete(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatJadwal = (dateString: string | null) => {
|
||||
if (!dateString) return "-";
|
||||
return format(new Date(dateString), "HH:mm");
|
||||
};
|
||||
|
||||
const getStatusColor = (status: AlatStatus) => {
|
||||
switch (status) {
|
||||
case "AKTIF":
|
||||
return "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300";
|
||||
case "NONAKTIF":
|
||||
return "bg-gray-100 text-gray-800 dark:bg-gray-800/80 dark:text-gray-300";
|
||||
case "ERROR":
|
||||
return "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300";
|
||||
default:
|
||||
return "bg-gray-100";
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading || !fetchedData) return <p className="text-sm">Memuat data...</p>;
|
||||
if (error) return <p className="text-sm text-red-500">Gagal memuat data alat presensi.</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cari berdasarkan nama alat atau kode ruangan..."
|
||||
defaultValue={initialSearch}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="bg-white dark:bg-neutral-950/50 text-black dark:text-gray-200 border border-gray-300 dark:border-neutral-800 rounded-md px-4 py-2 focus:outline-none text-sm w-100"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Tambah Alat"
|
||||
href="/admin/manajemen-presensi/alat-presensi/create"
|
||||
icon={<BsPlusCircleDotted />}
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-full hover:bg-gray-100 dark:hover:bg-black/10 hover:transition-all text-sm border border-gray-300 dark:border-neutral-800 flex items-center gap-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto rounded border border-gray-300 dark:border-neutral-800 shadow-sm">
|
||||
<table className="min-w-full text-sm text-left text-black dark:text-gray-200">
|
||||
<thead className="bg-gray-100 dark:bg-neutral-950/50 uppercase tracking-wide">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-semibold text-center">Nama Alat</th>
|
||||
<th className="px-6 py-3 font-semibold text-center">Lokasi (Ruangan)</th>
|
||||
<th className="px-6 py-3 font-semibold text-center">Mode</th>
|
||||
<th className="px-6 py-3 font-semibold text-center">Jadwal Aktif</th>
|
||||
<th className="px-6 py-3 font-semibold text-center">Status</th>
|
||||
<th className="px-6 py-3 font-semibold text-center w-px whitespace-nowrap">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fetchedData?.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-8">
|
||||
Tidak ada alat ditemukan.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
fetchedData?.map((alat) => (
|
||||
<tr
|
||||
key={alat.id}
|
||||
className="border-t border-gray-200 dark:border-neutral-800 hover:bg-gray-50 dark:hover:bg-neutral-950/40"
|
||||
>
|
||||
<td className="px-6 py-4 font-semibold text-center">{alat.name}</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
{alat.ruangan.name} ({alat.ruangan.kode})
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">{alat.mode}</td>
|
||||
<td className="px-6 py-4 font-mono text-center">
|
||||
{formatJadwal(alat.jadwal_nyala)} - {formatJadwal(alat.jadwal_mati)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(alat.status)}`}
|
||||
>
|
||||
{alat.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 flex items-center gap-4 justify-center">
|
||||
<SubmitButton
|
||||
text="Edit"
|
||||
href={`/admin/manajemen-presensi/alat-presensi/${alat.id}/edit`}
|
||||
className="bg-white w-18 dark:bg-neutral-950/40 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-md hover:bg-gray-200 dark:hover:bg-[#1A1A1A] hover:transition-all text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Hapus"
|
||||
onClick={() => handleOpenModal(alat)}
|
||||
className="bg-red-700 w-18 dark:bg-red-800/80 text-white dark:text-gray-200 px-4 py-2 rounded-md hover:bg-red-800 dark:hover:bg-red-950 hover:transition-all text-sm border-none"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-neutral-950 dark:backdrop-blur-sm rounded-lg p-6 w-full max-w-lg mx-4 shadow-[0_0_30px_1px_#C10007] dark:shadow-[0_0_34px_1px_#460809]">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<LuCircleAlert className="w-6 h-6 text-red-600" />
|
||||
Konfirmasi Hapus
|
||||
</h2>
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-300">
|
||||
Apakah Anda yakin ingin menghapus :
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-bold text-gray-900 dark:text-white">{selectedAlat?.name} ?</p>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Data yang telah dihapus tidak dapat dikembalikan.
|
||||
</p>
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="bg-gray-100 dark:bg-neutral-900/50 border border-neutral-300 dark:border-neutral-800 text-gray-800 dark:text-gray-200 px-4 py-2 rounded-md text-sm hover:bg-gray-300 dark:hover:bg-neutral-900 transition-all"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Ya, Hapus"
|
||||
isLoading={isLoadingDelete}
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 dark:bg-red-800 text-white px-4 py-2 rounded-md text-sm hover:bg-red-700 dark:hover:bg-red-950 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,183 @@
|
|||
"use client";
|
||||
|
||||
import { useState, FormEvent, ChangeEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { Ruangan, AlatMode, AlatStatus } from "@/generated/prisma/client";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
type AlatPresensiData = {
|
||||
id: string;
|
||||
name: string;
|
||||
mode: AlatMode;
|
||||
status: AlatStatus;
|
||||
jadwal_nyala: string | null;
|
||||
jadwal_mati: string | null;
|
||||
ruanganId: string;
|
||||
};
|
||||
|
||||
interface EditAlatFormProps {
|
||||
alat: AlatPresensiData;
|
||||
ruangans: Ruangan[];
|
||||
}
|
||||
|
||||
export default function EditAlatForm({ alat, ruangans }: EditAlatFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: alat.name,
|
||||
ruanganId: alat.ruanganId,
|
||||
jadwal_nyala: alat.jadwal_nyala ? format(parseISO(alat.jadwal_nyala), "HH:mm") : "",
|
||||
jadwal_mati: alat.jadwal_mati ? format(parseISO(alat.jadwal_mati), "HH:mm") : "",
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (form.jadwal_nyala && form.jadwal_mati && form.jadwal_nyala >= form.jadwal_mati) {
|
||||
setError("Jadwal Nyala harus lebih awal dari Jadwal Mati.");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
function convertTimeToISO(time: string): string {
|
||||
const [hours, minutes] = time.split(":").map(Number);
|
||||
const date = new Date();
|
||||
|
||||
date.setHours(hours);
|
||||
date.setMinutes(minutes);
|
||||
date.setSeconds(0);
|
||||
date.setMilliseconds(0);
|
||||
|
||||
return date.toISOString();
|
||||
}
|
||||
console.log(convertTimeToISO(form.jadwal_nyala), convertTimeToISO(form.jadwal_mati));
|
||||
const response = await fetch(`/api/alat-presensi/${alat.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...form,
|
||||
jadwal_nyala: form.jadwal_nyala ? convertTimeToISO(form.jadwal_nyala) : null,
|
||||
jadwal_mati: form.jadwal_mati ? convertTimeToISO(form.jadwal_mati) : null,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Gagal menyimpan perubahan.");
|
||||
|
||||
router.push("/admin/manajemen-presensi/alat-presensi?alat_updated=success");
|
||||
router.refresh();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<div className="space-y-4 w-full">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Nama Alat Presensi
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="ruanganId"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Lokasi Ruangan
|
||||
</label>
|
||||
<select
|
||||
id="ruanganId"
|
||||
name="ruanganId"
|
||||
value={form.ruanganId}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih Ruangan
|
||||
</option>
|
||||
{ruangans.map((r) => (
|
||||
<option key={r.id} value={r.id.toString()} className="bg-white dark:bg-neutral-900">
|
||||
{r.kode} - {r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="jadwal_nyala"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Jadwal Nyala (Opsional)
|
||||
</label>
|
||||
<input
|
||||
id="jadwal_nyala"
|
||||
name="jadwal_nyala"
|
||||
value={form.jadwal_nyala}
|
||||
onChange={handleChange}
|
||||
type="time"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="jadwal_mati"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Jadwal Mati (Opsional)
|
||||
</label>
|
||||
<input
|
||||
id="jadwal_mati"
|
||||
name="jadwal_mati"
|
||||
value={form.jadwal_mati}
|
||||
onChange={handleChange}
|
||||
type="time"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500 mt-2">{error}</p>}
|
||||
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
text="Simpan"
|
||||
isLoading={isLoading}
|
||||
className="bg-black dark:bg-white text-white dark:text-gray-900 hover:bg-slate-900 dark:hover:bg-gray-200 px-6 py-2 rounded-md text-sm"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
href="/admin/manajemen-presensi/alat-presensi"
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-900 dark:text-white dark:hover:bg-black/10 hover:bg-gray-200 px-6 py-2 rounded-md text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
import EditAlatForm from "./EditAlatForm";
|
||||
|
||||
async function getFormData(alatId: string) {
|
||||
const [alat, ruangans] = await Promise.all([
|
||||
prisma.alatPresensi.findUnique({
|
||||
where: { id: alatId },
|
||||
}),
|
||||
prisma.ruangan.findMany({ orderBy: { kode: "asc" } }),
|
||||
]);
|
||||
|
||||
if (!alat) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const serializableAlat = {
|
||||
...alat,
|
||||
jadwal_nyala: alat.jadwal_nyala?.toISOString() || null,
|
||||
jadwal_mati: alat.jadwal_mati?.toISOString() || null,
|
||||
};
|
||||
|
||||
return { alat: serializableAlat, ruangans };
|
||||
}
|
||||
|
||||
export default async function EditAlatPresensiPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const { alat, ruangans } = await getFormData(id);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mt-10 mx-auto p-8 bg-white dark:bg-black/20 rounded-md shadow-[0_0_10px_1px_#1a1a1a1a] dark:shadow-[0_0_20px_1px_#ffffff1a]">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Edit Alat Presensi</h1>
|
||||
<EditAlatForm alat={alat} ruangans={ruangans} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,173 @@
|
|||
"use client";
|
||||
|
||||
import { useState, FormEvent, ChangeEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { Ruangan } from "@/generated/prisma/client";
|
||||
|
||||
interface CreateAlatFormProps {
|
||||
ruangans: Ruangan[];
|
||||
}
|
||||
|
||||
export default function CreateAlatForm({ ruangans }: CreateAlatFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
jadwal_nyala: "",
|
||||
jadwal_mati: "",
|
||||
ruanganId: "",
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Validasi jam di frontend
|
||||
if (form.jadwal_nyala && form.jadwal_mati && form.jadwal_nyala >= form.jadwal_mati) {
|
||||
setError("Jadwal Nyala harus lebih awal dari Jadwal Mati.");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
function convertTimeToISO(time: string): string {
|
||||
const [hours, minutes] = time.split(":").map(Number);
|
||||
const date = new Date();
|
||||
|
||||
date.setHours(hours);
|
||||
date.setMinutes(minutes);
|
||||
date.setSeconds(0);
|
||||
date.setMilliseconds(0);
|
||||
|
||||
return date.toISOString();
|
||||
}
|
||||
const response = await fetch("/api/alat-presensi", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: form.name,
|
||||
ruanganId: form.ruanganId,
|
||||
jadwal_nyala: form.jadwal_nyala ? convertTimeToISO(form.jadwal_nyala) : null,
|
||||
jadwal_mati: form.jadwal_mati ? convertTimeToISO(form.jadwal_mati) : null,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || "Gagal menambahkan alat.");
|
||||
|
||||
router.push("/admin/manajemen-presensi/alat-presensi?alat-presensi=create_success");
|
||||
router.refresh();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-full">
|
||||
<div className="space-y-4 w-full">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Nama Alat Presensi
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Contoh: Alat Presensi Lab Jaringan"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="ruanganId"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Lokasi Ruangan
|
||||
</label>
|
||||
<select
|
||||
id="ruanganId"
|
||||
name="ruanganId"
|
||||
value={form.ruanganId}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="" disabled className="bg-white dark:bg-neutral-900">
|
||||
Pilih Ruangan
|
||||
</option>
|
||||
{ruangans.map((r) => (
|
||||
<option key={r.id} value={r.id.toString()} className="bg-white dark:bg-neutral-900">
|
||||
{r.kode} - {r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="jadwal_nyala"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Jadwal Nyala (Opsional)
|
||||
</label>
|
||||
<input
|
||||
id="jadwal_nyala"
|
||||
name="jadwal_nyala"
|
||||
value={form.jadwal_nyala}
|
||||
onChange={handleChange}
|
||||
type="time"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="jadwal_mati"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Jadwal Mati (Opsional)
|
||||
</label>
|
||||
<input
|
||||
id="jadwal_mati"
|
||||
name="jadwal_mati"
|
||||
value={form.jadwal_mati}
|
||||
onChange={handleChange}
|
||||
type="time"
|
||||
className="w-full px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/40 dark:text-white border-gray-300 dark:border-neutral-800 text-sm placeholder-gray-700/50 dark:placeholder-gray-400/50 focus:shadow-[0_0_10px_1px_#1a1a1a1a] dark:focus:shadow-[0_0_10px_1px_#ffffff1a] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500 dark:text-red-400 mt-2">{error}</p>}
|
||||
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
text="Tambah"
|
||||
isLoading={isLoading}
|
||||
className="bg-black dark:bg-white text-white dark:text-gray-900 hover:bg-slate-900 dark:hover:bg-gray-200 px-6 py-2 rounded-md text-sm"
|
||||
/>
|
||||
<SubmitButton
|
||||
text="Batal"
|
||||
href="/admin/manajemen-presensi/alat-presensi"
|
||||
className="bg-white dark:bg-neutral-950/50 text-gray-900 dark:text-white dark:hover:bg-black/10 hover:bg-gray-200 px-6 py-2 rounded-md text-sm border border-gray-300 dark:border-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import CreateAlatForm from "./CreateAlatForm";
|
||||
|
||||
// Fungsi untuk mengambil semua ruangan yang tersedia
|
||||
async function getRuanganOptions() {
|
||||
const ruangans = await prisma.ruangan.findMany({
|
||||
orderBy: { kode: "asc" },
|
||||
});
|
||||
return ruangans;
|
||||
}
|
||||
|
||||
export default async function CreateAlatPresensiPage() {
|
||||
const ruangans = await getRuanganOptions();
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mt-10 mx-auto p-8 bg-white dark:bg-black/20 rounded-md shadow-[0_0_10px_1px_#1a1a1a1a] dark:shadow-[0_0_20px_1px_#ffffff1a]">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Tambah Alat Presensi Baru</h1>
|
||||
<CreateAlatForm ruangans={ruangans} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { BsPlusCircleDotted } from "react-icons/bs";
|
||||
import AlatPresensiTable from "./AlatPresensiTable";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
|
||||
async function getAlatPresensi(search?: string) {
|
||||
const where: Prisma.AlatPresensiWhereInput = {};
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search, mode: "insensitive" } },
|
||||
{ ruangan: { kode: { contains: search, mode: "insensitive" } } },
|
||||
];
|
||||
}
|
||||
|
||||
const data = await prisma.alatPresensi.findMany({
|
||||
where,
|
||||
include: {
|
||||
ruangan: { select: { kode: true, name: true } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export default async function ManajemenAlatPresensiPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ search?: string }>;
|
||||
}) {
|
||||
const { search } = await searchParams;
|
||||
const searchParms = search;
|
||||
const data = await getAlatPresensi(searchParms);
|
||||
|
||||
const serializableData = data.map((item) => ({
|
||||
...item,
|
||||
jadwal_nyala: item.jadwal_nyala?.toISOString() || null,
|
||||
jadwal_mati: item.jadwal_mati?.toISOString() || null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Breadcrumb className="ml-12 mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/dashboard">Admin</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="#">Manajemen Presensi</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Alat Presensi</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Manajemen Alat Presensi</h1>
|
||||
<AlatPresensiTable data={serializableData} initialSearch={search} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,292 @@
|
|||
"use client";
|
||||
|
||||
import React, { Fragment, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { LuCircleArrowLeft, LuCircleArrowRight, LuLoader } from "react-icons/lu";
|
||||
import axios from "axios";
|
||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react";
|
||||
|
||||
type AlatType = { id: string; name: string; mode: string; status: "AKTIF" | "NONAKTIF" };
|
||||
type MahasiswaData = {
|
||||
id: string;
|
||||
nim: string | null;
|
||||
name: string | null;
|
||||
uid: string | null;
|
||||
semester: { name: string } | null;
|
||||
golongan: { name: string } | null;
|
||||
};
|
||||
|
||||
export default function RfidTable({
|
||||
data,
|
||||
totalPages,
|
||||
currentPage,
|
||||
initialSearch,
|
||||
initialStatusFilter,
|
||||
}: {
|
||||
data: MahasiswaData[];
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
initialSearch?: string;
|
||||
initialStatusFilter?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [alatList, setAlatList] = useState<AlatType[]>([]);
|
||||
const [loadingAlat, setLoadingAlat] = useState(false);
|
||||
const [selectedMahasiswa, setSelectedMahasiswa] = useState<MahasiswaData | null>(null);
|
||||
|
||||
const openModal = async (mahasiswa: MahasiswaData) => {
|
||||
setSelectedMahasiswa(mahasiswa);
|
||||
setLoadingAlat(true);
|
||||
setIsModalOpen(true);
|
||||
try {
|
||||
const res = await axios.get("/api/alat-presensi");
|
||||
setAlatList(res.data);
|
||||
} catch (e) {
|
||||
console.error("Gagal mengambil daftar alat:", e);
|
||||
alert("Gagal mengambil daftar alat presensi.");
|
||||
setIsModalOpen(false);
|
||||
} finally {
|
||||
setLoadingAlat(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChooseAlat = (alatId: string, mahasiswaId: string) => {
|
||||
setIsModalOpen(false);
|
||||
router.push(`/admin/manajemen-presensi/registrasi-rfid/${mahasiswaId}?alatId=${alatId}`);
|
||||
};
|
||||
|
||||
const updateFilter = (key: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
router.replace(`?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleSearch = useDebouncedCallback((term: string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", "1");
|
||||
if (term) {
|
||||
params.set("search", term);
|
||||
} else {
|
||||
params.delete("search");
|
||||
}
|
||||
router.replace(`?${params.toString()}`);
|
||||
}, 500);
|
||||
|
||||
const displayValue = (val?: string | null) => (val && val.trim() !== "" ? val : "-");
|
||||
const createPageURL = (pageNumber: number | string) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.set("page", pageNumber.toString());
|
||||
return `?${params.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<input
|
||||
id="search"
|
||||
type="text"
|
||||
placeholder="Cari berdasarkan nama atau NIM mahasiswa..."
|
||||
defaultValue={initialSearch}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="bg-white dark:bg-neutral-950/50 text-black dark:text-gray-200 border border-gray-300 dark:border-neutral-800 rounded-md px-4 py-2 focus:outline-none text-sm w-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
id="status"
|
||||
value={initialStatusFilter || ""}
|
||||
onChange={(e) => updateFilter("statusRfid", e.target.value)}
|
||||
className=" px-4 py-2 border rounded-md bg-gray-50 dark:bg-neutral-950/50 dark:text-white border-gray-300 dark:border-neutral-800 text-sm focus:outline-none w-80"
|
||||
>
|
||||
<option value="" className="bg-white dark:bg-neutral-900">
|
||||
Semua Status
|
||||
</option>
|
||||
<option value="terdaftar" className="bg-white dark:bg-neutral-900">
|
||||
Sudah Terdaftar
|
||||
</option>
|
||||
<option value="belum_terdaftar" className="bg-white dark:bg-neutral-900">
|
||||
Belum Terdaftar
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto rounded border border-gray-300 dark:border-neutral-800 shadow-sm">
|
||||
<table className="min-w-full text-sm text-left text-black dark:text-gray-200">
|
||||
<thead className="bg-gray-100 dark:bg-neutral-950/50 uppercase tracking-wide">
|
||||
<tr>
|
||||
<th className="px-6 py-3 font-semibold">NIM</th>
|
||||
<th className="px-6 py-3 font-semibold">Nama Mahasiswa</th>
|
||||
<th className="px-6 py-3 font-semibold text-center">UID Kartu</th>
|
||||
<th className="px-6 py-3 font-semibold text-center">Smt/Gol</th>
|
||||
<th className="px-6 py-3 font-semibold text-center w-px whitespace-nowrap">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-8">
|
||||
Tidak ada mahasiswa ditemukan.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((mhs) => (
|
||||
<tr
|
||||
key={mhs.id}
|
||||
className="border-t border-gray-200 dark:border-neutral-800 hover:bg-gray-50 dark:hover:bg-neutral-950/40"
|
||||
>
|
||||
<td className="px-6 py-4 font-mono">{displayValue(mhs.nim)}</td>
|
||||
<td className="px-6 py-4 font-semibold">{displayValue(mhs.name)}</td>
|
||||
<td className="px-6 py-4 font-mono text-center">{displayValue(mhs.uid)}</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
{displayValue(mhs.semester?.name)} / {displayValue(mhs.golongan?.name)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
{mhs.uid ? (
|
||||
<SubmitButton
|
||||
text="Edit"
|
||||
onClick={() => openModal(mhs)}
|
||||
className="w-22 px-4 py-2 bg-black dark:bg-neutral-700 text-neutral-100 dark:text-neutral-100 rounded-md hover:bg-black/70 dark:hover:bg-neutral-800 hover:transition-all"
|
||||
/>
|
||||
) : (
|
||||
<SubmitButton
|
||||
text="Registrasi"
|
||||
onClick={() => openModal(mhs)}
|
||||
className="w-22 px-4 py-2 bg-green-700 dark:bg-emerald-700 text-green-100 dark:text-emerald-100 rounded-md hover:bg-green-800 dark:hover:bg-emerald-800 hover:transition-all"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Transition appear show={isModalOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={() => setIsModalOpen(false)}>
|
||||
{/* Backdrop */}
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
</TransitionChild>
|
||||
|
||||
{/* Konten Modal */}
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel className="w-full max-w-md transform overflow-hidden rounded-lg bg-white dark:bg-[#111111] dark:border dark:border-neutral-800 p-6 text-left align-middle shadow-xl transition-all">
|
||||
<DialogTitle
|
||||
as="h3"
|
||||
className="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-50"
|
||||
>
|
||||
Pilih Alat Presensi
|
||||
</DialogTitle>
|
||||
<div className="mt-1">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Pilih alat yang akan digunakan untuk meregistrasi kartu RFID {""}
|
||||
<strong>{selectedMahasiswa?.name}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 max-h-60 overflow-auto border-t border-b border-gray-200 dark:border-neutral-800 py-2">
|
||||
{loadingAlat ? (
|
||||
<div className="flex items-center justify-center gap-2 py-4">
|
||||
<LuLoader className="animate-spin" /> Memuat daftar alat...
|
||||
</div>
|
||||
) : alatList.length === 0 ? (
|
||||
<p className="text-center text-sm text-gray-500 py-4">Tidak ada alat yang tersedia.</p>
|
||||
) : (
|
||||
alatList.map((alat) => (
|
||||
<div
|
||||
key={alat.id}
|
||||
className="flex justify-between items-center p-3 rounded-md hover:bg-gray-100 dark:hover:bg-neutral-800/80 cursor-pointer transition-colors"
|
||||
onClick={() => selectedMahasiswa && handleChooseAlat(alat.id, selectedMahasiswa.id)}
|
||||
>
|
||||
<div>
|
||||
<p className="font-semibold">{alat.name}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Mode: {alat.mode}</p>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium`}>
|
||||
{alat.status}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 sm:mt-6 text-right">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-md border border-transparent bg-gray-100 px-4 py-2 text-sm font-medium text-gray-800 hover:bg-gray-200 dark:bg-neutral-800 dark:text-gray-200 dark:hover:bg-neutral-700 cursor-pointer"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<span className="text-sm">
|
||||
Halaman {currentPage} dari {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={createPageURL(currentPage - 1)}
|
||||
className={`px-3 py-1 text-sm rounded-md flex items-center gap-2 ${
|
||||
currentPage <= 1
|
||||
? "pointer-events-none bg-transparent dark:bg-black/80 border border-gray-300 dark:border-neutral-800 text-gray-400"
|
||||
: "bg-gray-100 dark:bg-gray-200 border border-gray-300 dark:border-neutral-800 text-gray-800 dark:text-gray-800 hover:bg-gray-200 dark:hover:bg-gray-300 hover:transition-all"
|
||||
}`}
|
||||
>
|
||||
<LuCircleArrowLeft /> Sebelumnya
|
||||
</Link>
|
||||
<Link
|
||||
href={createPageURL(currentPage + 1)}
|
||||
className={`px-3 py-1 text-sm rounded-md flex items-center gap-2 ${
|
||||
currentPage >= totalPages
|
||||
? "pointer-events-none bg-transparent dark:bg-black/80 border border-gray-300 dark:border-neutral-800 text-gray-400"
|
||||
: "bg-gray-100 dark:bg-gray-200 border border-gray-300 dark:border-neutral-800 text-gray-800 dark:text-gray-800 hover:bg-gray-200 dark:hover:bg-gray-300 hover:transition-all"
|
||||
}`}
|
||||
>
|
||||
Selanjutnya <LuCircleArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,211 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
import { LuCircleCheck, LuCircleX, LuInfo, LuLoader } from "react-icons/lu";
|
||||
|
||||
type MahasiswaData = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
nim: string | null;
|
||||
uid: string | null;
|
||||
};
|
||||
|
||||
interface RegistrasiProps {
|
||||
initialMahasiswa: MahasiswaData;
|
||||
alatId: string;
|
||||
}
|
||||
|
||||
export default function RegistrasiRfidPageClient({ initialMahasiswa, alatId }: RegistrasiProps) {
|
||||
const [currentUid, setCurrentUid] = useState<string | null>(initialMahasiswa.uid);
|
||||
const [status, setStatus] = useState<"viewing" | "waiting" | "success" | "used" | "error">(() =>
|
||||
initialMahasiswa.uid ? "viewing" : "waiting"
|
||||
);
|
||||
const [usedByName, setUsedByName] = useState<string | null>(null);
|
||||
|
||||
const registrationModeActivated = useRef(false);
|
||||
|
||||
const handleStartNewRegistration = () => {
|
||||
setStatus("waiting");
|
||||
// setCurrentUid(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "waiting") {
|
||||
registrationModeActivated.current = true;
|
||||
console.log("Memulai sesi registrasi, mengubah mode alat...");
|
||||
fetch(`/api/alat-presensi/${alatId}/set-mode`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode: "REGISTRASI", mahasiswaId: initialMahasiswa.id }),
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (registrationModeActivated.current) {
|
||||
console.log("Meninggalkan halaman, mengembalikan mode alat...");
|
||||
const payload = JSON.stringify({ mode: "PRESENSI" });
|
||||
const url = `/api/alat-presensi/${alatId}/set-mode`;
|
||||
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon(url, new Blob([payload], { type: "application/json" }));
|
||||
} else {
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
keepalive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [status, alatId, initialMahasiswa.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "waiting") return;
|
||||
|
||||
const socket = new WebSocket("wss://sikma-websocket-server.onrender.com");
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log("Browser terhubung ke server WebSocket!");
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const messageData = JSON.parse(event.data);
|
||||
console.log("Menerima pesan dari server:", messageData);
|
||||
|
||||
if (messageData.event === "registration-result" && messageData.mahasiswaId === initialMahasiswa.id) {
|
||||
if (messageData.status === "success") {
|
||||
setCurrentUid(messageData.uid);
|
||||
setStatus("success");
|
||||
} else if (messageData.status === "used") {
|
||||
setStatus("used");
|
||||
setUsedByName(messageData.usedByName || null);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Gagal mem-parsing pesan WebSocket:", e);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
console.log("Koneksi WebSocket browser ditutup.");
|
||||
};
|
||||
|
||||
socket.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.close();
|
||||
}
|
||||
};
|
||||
}, [status, initialMahasiswa.id]);
|
||||
|
||||
const getPageContainerClass = () => {
|
||||
const baseClass =
|
||||
"w-full max-w-2xl mt-10 mb-10 mx-auto p-8 bg-white dark:bg-black/20 rounded-xl transition-all duration-500";
|
||||
switch (status) {
|
||||
case "waiting":
|
||||
return `${baseClass} shadow-[0_0_30px_1px] shadow-sky-500/80 dark:shadow-sky-800`;
|
||||
case "success":
|
||||
return `${baseClass} shadow-[0_0_40px_1px] shadow-emerald-400 dark:shadow-emerald-800/80`;
|
||||
case "used":
|
||||
return `${baseClass} shadow-[0_0_40px_1px] shadow-red-400 dark:shadow-red-800/80`;
|
||||
default:
|
||||
return `${baseClass} shadow-lg`;
|
||||
}
|
||||
};
|
||||
|
||||
const InfoField = ({ label, value }: { label: string; value: string | null | undefined }) => (
|
||||
<div className="py-3 px-2 border-b border-gray-400 dark:border-neutral-800 last:border-b-0">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{label}</p>
|
||||
<p className="font-semibold text-lg">{value || "-"}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={getPageContainerClass()}>
|
||||
<h1 className="text-2xl font-bold mb-2">Registrasi Kartu RFID</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
{status === "waiting" && "Sistem sedang menunggu pemindaian kartu RFID baru..."}
|
||||
{status === "success" && (
|
||||
<span className="text-green-500">Registrasi UID baru berhasil dilakukan!</span>
|
||||
)}
|
||||
{status === "viewing" && (
|
||||
<span className="text-amber-500">Mahasiswa ini sudah memiliki UID terdaftar.</span>
|
||||
)}
|
||||
{status === "used" && (
|
||||
<span className="text-red-500">
|
||||
Gagal: UID yang dipindai sudah digunakan oleh {usedByName || "-"}.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
<div className="p-4 bg-gray-50 dark:bg-neutral-900 rounded-lg">
|
||||
<InfoField label="Nama Mahasiswa" value={initialMahasiswa.name} />
|
||||
<InfoField label="NIM" value={initialMahasiswa.nim} />
|
||||
<InfoField label="UID Kartu Terdaftar" value={currentUid} />
|
||||
</div>
|
||||
|
||||
<div className="p-6 text-center border-2 border-dashed dark:border-neutral-700 rounded-lg min-h-[160px] flex flex-col justify-center">
|
||||
{status === "viewing" && (
|
||||
<>
|
||||
<LuInfo className="h-12 w-12 text-amber-500 mx-auto" />
|
||||
<h2 className="mt-4 text-xl font-semibold text-amber-500">UID Sudah Terdaftar</h2>
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
Klik tombol di bawah jika Anda ingin mengganti dengan kartu baru.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<SubmitButton
|
||||
text="Daftarkan UID Baru"
|
||||
onClick={handleStartNewRegistration}
|
||||
className="px-4 py-2 bg-black dark:bg-gray-200 border rounded-lg text-sm text-gray-200 dark:text-black hover:opacity-80 hover:transition-all"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{status === "waiting" && (
|
||||
<>
|
||||
<LuLoader className="animate-spin h-12 w-12 text-sky-500 mx-auto" />
|
||||
<h2 className="mt-4 text-xl font-semibold">Menunggu Kartu...</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Silakan tempelkan kartu RFID baru pada alat presensi.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{status === "success" && (
|
||||
<>
|
||||
<LuCircleCheck className="h-12 w-12 text-green-500 mx-auto" />
|
||||
<h2 className="mt-4 text-xl font-semibold text-green-500">Registrasi Berhasil!</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500">Kartu RFID baru telah berhasil didaftarkan.</p>
|
||||
</>
|
||||
)}
|
||||
{status === "used" && (
|
||||
<>
|
||||
<LuCircleX className="h-12 w-12 text-red-500 mx-auto" />
|
||||
<h2 className="mt-4 text-xl font-semibold text-red-500">UID sudah digunakan</h2>
|
||||
<p className="mt-2 text-sm text-gray-500">UID ini telah didaftarkan oleh mahasiswa lain</p>
|
||||
<div className="mt-4">
|
||||
<SubmitButton
|
||||
text="Coba Lagi"
|
||||
onClick={handleStartNewRegistration}
|
||||
className="px-4 py-2 bg-black dark:bg-gray-200 border rounded-lg text-sm text-gray-200 dark:text-black hover:opacity-80 hover:transition-all "
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<SubmitButton
|
||||
text="Kembali ke Daftar"
|
||||
href="/admin/manajemen-presensi/registrasi-rfid"
|
||||
className="px-4 py-2 bg-black dark:bg-gray-200 border rounded-lg text-sm text-gray-200 dark:text-black hover:opacity-80 hover:transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
import RegistrasiRfidClient from "./RegistrasiRfidClient";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { auth } from "@/auth";
|
||||
import { SubmitButton } from "@/components/auth/SubmitButton";
|
||||
|
||||
async function getInitialData(mahasiswaId: string, adminProdiId: string) {
|
||||
const mahasiswa = await prisma.user.findFirst({
|
||||
where: { id: mahasiswaId, prodiId: adminProdiId, role: "MAHASISWA" },
|
||||
select: { id: true, name: true, nim: true, uid: true },
|
||||
});
|
||||
|
||||
if (!mahasiswa) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const alatPresensi = await prisma.alatPresensi.findFirst();
|
||||
if (!alatPresensi) {
|
||||
throw new Error("Tidak ada alat presensi yang ditemukan di sistem.");
|
||||
}
|
||||
|
||||
return { mahasiswa, alatId: alatPresensi.id };
|
||||
}
|
||||
|
||||
export default async function RegistrasiRfidPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||
redirect("/login?error=unauthorized");
|
||||
}
|
||||
|
||||
const adminUser = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { prodiId: true },
|
||||
});
|
||||
|
||||
if (!adminUser || !adminUser.prodiId) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-xl text-red-500">
|
||||
Akses ditolak: Akun admin tidak terasosiasi dengan Program Studi.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { mahasiswa, alatId } = await getInitialData(id, adminUser.prodiId);
|
||||
|
||||
return <RegistrasiRfidClient initialMahasiswa={mahasiswa} alatId={alatId} />;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
<div className="w-full max-w-2xl mt-10 mx-auto p-8 rounded-md shadow">
|
||||
<h1 className="text-2xl font-bold text-red-500 mb-6">Terjadi Kesalahan</h1>
|
||||
<p className="text-gray-600 dark:text-gray-300">{error.message}</p>
|
||||
<div className="mt-6">
|
||||
<SubmitButton href="/admin/manajemen-presensi/registrasi-rfid" text="Kembali" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,128 @@
|
|||
import prisma from "@/lib/prisma";
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import RfidTable from "./RfidTable";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
|
||||
const ITEMS_PER_PAGE = 6;
|
||||
|
||||
async function getMahasiswa(adminProdiId: string, search?: string, statusRfid?: string, page: number = 1) {
|
||||
const where: Prisma.UserWhereInput = {
|
||||
role: "MAHASISWA",
|
||||
prodiId: adminProdiId,
|
||||
};
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search, mode: "insensitive" } },
|
||||
{ nim: { contains: search, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
if (statusRfid === "terdaftar") {
|
||||
where.uid = {
|
||||
not: null,
|
||||
notIn: [""],
|
||||
};
|
||||
} else if (statusRfid === "belum_terdaftar") {
|
||||
where.OR = [{ uid: null }, { uid: "" }];
|
||||
}
|
||||
|
||||
const [data, totalCount] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where,
|
||||
take: ITEMS_PER_PAGE,
|
||||
skip: (page - 1) * ITEMS_PER_PAGE,
|
||||
select: {
|
||||
id: true,
|
||||
nim: true,
|
||||
name: true,
|
||||
uid: true,
|
||||
semester: { select: { name: true } },
|
||||
golongan: { select: { name: true } },
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
uid: {
|
||||
sort: "desc",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asc",
|
||||
},
|
||||
],
|
||||
}),
|
||||
prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||
return { data, totalPages, currentPage: page };
|
||||
}
|
||||
|
||||
export default async function RegistrasiRfidPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Promise<{ search?: string; page?: string; statusRfid?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||
redirect("/login?error=unauthorized");
|
||||
}
|
||||
|
||||
const adminUser = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { prodiId: true },
|
||||
});
|
||||
|
||||
if (!adminUser || !adminUser.prodiId) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-xl text-red-500">
|
||||
Akses ditolak: Akun admin tidak terasosiasi dengan Program Studi.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { search, statusRfid, page } = (await searchParams) || {};
|
||||
const currentPage = Number(page) || 1;
|
||||
const { data, totalPages } = await getMahasiswa(adminUser.prodiId, search, statusRfid, currentPage);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Breadcrumb className="ml-12 mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/admin/dashboard">Admin</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="#">Manajemen Presensi</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Registrasi RFID</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Registrasi Kartu RFID</h1>
|
||||
<RfidTable
|
||||
data={data}
|
||||
totalPages={totalPages}
|
||||
currentPage={currentPage}
|
||||
initialSearch={search}
|
||||
initialStatusFilter={statusRfid}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue