first commit

This commit is contained in:
rizkyromadhon 2025-07-30 12:00:38 +07:00
commit d522d39419
287 changed files with 32601 additions and 0 deletions

45
.gitignore vendored Normal file
View File

@ -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

View File

@ -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>

8
.idea/modules.xml Normal file
View File

@ -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>

View File

@ -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>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

84
.idea/workspace.xml Normal file
View File

@ -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">{
&quot;associatedIndex&quot;: 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>

36
README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

21
components.json Normal file
View File

@ -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"
}

33
eslint.config.mjs Normal file
View File

@ -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;

28
next.config.ts Normal file
View File

@ -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;

87
package.json Normal file
View File

@ -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"
}
}

13
postcss.config.mjs Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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");

View File

@ -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;

View File

@ -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';

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "AlatPresensi" ADD COLUMN "targetMahasiswaId" TEXT;

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "StatusPresensi" ADD VALUE 'TERLAMBAT';

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "PresensiKuliah" ALTER COLUMN "waktu_presensi" DROP NOT NULL;

View File

@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "user_uid_key";

View File

@ -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");

View File

@ -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"

327
prisma/schema.prisma Normal file
View File

@ -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])
}

43
prisma/seed.ts Normal file
View File

@ -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

1
public/file.svg Normal file
View File

@ -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

1
public/globe.svg Normal file
View File

@ -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

1
public/next.svg Normal file
View File

@ -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

1
public/vercel.svg Normal file
View File

@ -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

1
public/window.svg Normal file
View File

@ -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

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 }))} />;
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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(),
}))}
/>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}
}
}

View File

@ -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