+ ❌ Sebelum: Content full width, tertimpa sidebar
+ ✅ Sesudah: Content shift right 64px di desktop, ada ruang untuk sidebar
+```
+
+### Visual Result
+
+**Sebelum (overlap & scroll):**
+```
+Desktop dengan scroll:
+┌─────────────────────────────────┐
+│ Sidebar │ Content (scroll ↓) │
+│ ↓ Scroll│ Content scroll with │
+│ (ikut │ page, overlap! │
+│ naik) │ │
+└─────────────────────────────────┘
+```
+
+**Sesudah (fixed & no overlap):**
+```
+Desktop dengan scroll:
+┌──────────────────────────────────┐
+│ Sidebar │ Content (scroll ↓) │
+│ ↑ Fixed │ Content hanya scroll │
+│ (tetap) │ di area konten (ml-64)│
+└──────────────────────────────────┘
+```
+
+### Impact
+- ✅ Sidebar tetap fixed, tidak ikut scroll
+- ✅ Main content tidak tertimpa sidebar
+- ✅ Better UX - sidebar selalu accessible
+- ✅ Easier Navigation - tidak perlu scroll kembali ke top
+- ✅ Modern Design Pattern - matches common web standards
+- ✅ Desktop Experience Significantly Improved
+
+### Responsive Behavior
+- **Mobile/Tablet**: Sidebar overlay (fixed positioning dengan transform)
+- **Desktop**: Sidebar static width (fixed) dengan main content margin-left
+
+---
+
+## 3. Search Bar - Extended Width Desktop
+**File:** `src/components/layout/navbar/index.tsx`
+**Tanggal:** 4 Juni 2026
+
+### Perubahan
+```tsx
+// Sebelum
+className="flex items-center gap-1 sm:gap-2 flex-1 sm:flex-none"
+
+// Sesudah
+className="flex items-center gap-1 sm:gap-2 flex-1 sm:flex-1 sm:max-w-4xl"
+```
+
+### Detail
+- Mobile: `flex-1` (full width)
+- Desktop: `flex-1` dengan `max-w-4xl` (panjang optimal)
+- Search bar sekarang lebih berguna untuk pencarian yang lebih baik
+
+---
+
+## Testing Checklist - Updated
+
+### Navbar
+- [ ] Mobile: ☰ | [Search + 🔔] | Profile dalam 1 baris
+- [ ] Desktop: [Search panjang] | 🔔 | Profile nempel ke kanan
+- [ ] Search tidak wrap/overflow
+- [ ] Menu button hanya di mobile
+- [ ] Notification icon tidak duplikat di desktop
+- [ ] No layout shift saat resize
+
+### ManageBin (NEW)
+- [ ] Klik Edit di table → form populated dengan data bin
+- [ ] Klik Tambah → form kosong (di-reset)
+- [ ] ID field read-only saat edit
+- [ ] Semua field terisi dengan nilai yang benar
+- [ ] Submit berhasil update data
+
+### Sidebar (NEW)
+- [ ] Desktop: Sidebar tetap fixed saat scroll
+- [ ] Sidebar tidak bergerak naik saat scroll page
+- [ ] Z-index benar - sidebar di atas content
+- [ ] Mobile: Sidebar masih bisa di-toggle
+- [ ] Stacking order tidak ada overlap
+
+---
+
+## Summary of All Changes
+
+| Component | Issue | Solution | Status |
+|---|---|---|---|
+| Navbar | Layout kompleks (grid) | Redesign ke flexbox | ✅ Done |
+| Navbar | Menu button di desktop | Hide dengan sm:hidden | ✅ Done |
+| Navbar | Bell icon duplikat | One source per breakpoint | ✅ Done |
+| Navbar | Search bar pendek desktop | Extend ke max-w-4xl | ✅ Done |
+| ManageBin | Form kosong saat edit | Add useEffect populate | ✅ Done |
+| Sidebar | Sidebar scroll dengan page | Change sticky → fixed | ✅ Done |
+| Sidebar | Content overlap dengan sidebar | Add lg:ml-64 ke main div | ✅ Done |
+
+---
+
+## Git Commit Messages (Recommended)
+
+```
+feat: redesign navbar with flexbox layout for better responsiveness
+
+feat: extend search bar width on desktop (max-w-4xl)
+
+fix: populate manage bin form with selected bin data using useEffect
+
+fix: make sidebar position fixed instead of sticky to prevent scrolling
+
+fix: add margin-left to main content to prevent sidebar overlap
+
+chore: update documentation for navbar, manage bin, and sidebar changes
+```
+
+---
+
+## Files Modified Summary
+
+```
+src/components/layout/navbar/index.tsx
+ - 37 lines changed
+ - Grid → Flexbox redesign
+ - Search bar extended
+ - Responsive gap values
+
+src/components/manage/ManageBin.tsx
+ - 15 lines added
+ - useEffect hook for form population
+ - Auto-reset on close/reopen
+
+src/components/layout/sidebar/index.tsx
+ - Class changes removed (sticky → z-30)
+ - Z-index adjustment (z-auto → z-30)
+
+src/pages/_app.tsx
+ - 1 line changed
+ - Add lg:ml-64 to main content wrapper
+ - Prevents sidebar overlap
+
+CHANGELOG_NAVBAR.md
+ - Comprehensive documentation
+ - Fix explanations
+ - Testing checklists
+ - Visual diagrams
+```
+
+---
+
+## Final Testing Checklist - Complete
+
+### Navbar
+- [ ] Mobile: ☰ | [Search + 🔔] | Profile dalam 1 baris
+- [ ] Desktop: [Search panjang] | 🔔 | Profile nempel ke kanan
+- [ ] Search tidak wrap/overflow
+- [ ] Menu button hanya di mobile
+- [ ] Notification icon tidak duplikat di desktop
+- [ ] No layout shift saat resize
+
+### ManageBin
+- [ ] Klik Edit di table → form populated dengan data bin
+- [ ] Klik Tambah → form kosong (di-reset)
+- [ ] ID field read-only saat edit
+- [ ] Semua field terisi dengan nilai yang benar
+- [ ] Submit berhasil update data
+
+### Sidebar
+- [ ] Desktop: Sidebar tetap fixed saat scroll
+- [ ] Sidebar tidak bergerak naik saat scroll page
+- [ ] Z-index benar - sidebar di atas content
+- [ ] Content tidak tertimpa sidebar (ada margin)
+- [ ] Mobile: Sidebar masih bisa di-toggle
+- [ ] Stacking order tidak ada overlap
+- [ ] Main content responsive di mobile/tablet
+
+### Overall Responsiveness
+- [ ] Mobile (< 640px): semua element fit
+- [ ] Tablet (640px - 1024px): layout proper
+- [ ] Desktop (> 1024px): sidebar fixed, content dengan margin
+- [ ] No horizontal scroll (except overflow-x auto components)
+- [ ] Touch events work di mobile
+- [ ] Hover effects work di desktop
+
+---
+
+## Architecture Overview
+
+### Page Structure (_app.tsx)
+```
+Root Container (flex, min-h-screen)
+├── Sidebar (fixed, lg:z-30)
+│ ├── Logo
+│ ├── Navigation Menu
+│ └── Logout
+├── Overlay (lg:hidden, only on mobile)
+└── Main Content (flex flex-1, lg:ml-64)
+ ├── Navbar (flex, responsive)
+ │ ├── Menu Button (sm:hidden)
+ │ ├── Search Bar (flex-1)
+ │ ├── Bell Icon (mobile)
+ │ ├── Spacer (hidden, lg:flex lg:flex-1)
+ │ ├── Bell Icon (desktop)
+ │ └── Profile
+ └── Main (overflow-y-auto)
+ └── Page Content
+```
+
+### Responsive Grid
+
+| Feature | Mobile | Tablet | Desktop |
+|---|---|---|---|
+| Sidebar | Fixed overlay | Fixed overlay | Fixed (w-64) |
+| Sidebar Visible | Toggle | Toggle | Always |
+| Content Width | Full | Full | Full - 256px |
+| Content Margin | No | No | ml-64 |
+| Navbar Flow | Column → Row | Row | Row |
+| Search Width | flex-1 | flex-1 | flex-1 max-w-4xl |
+| Menu Button | Visible | Visible | Hidden |
+| Bell (Mobile) | Visible | Visible | Hidden |
+| Bell (Desktop) | Hidden | Hidden | Visible |
+
+---
+
+## Known Limitations & Considerations
+
+1. **Search Bar Max Width**: Set to `max-w-4xl` (1024px) - dapat disesuaikan jika perlu lebih panjang
+2. **Sidebar Width Desktop**: Fixed di `w-64` (256px) - tidak dapat di-resize
+3. **Margin Left**: `lg:ml-64` hardcoded - harus update manual jika sidebar width berubah
+4. **Z-index**: Sidebar `z-30` - perlu dijaga agar tidak conflict dengan modal/popup
+
+---
+
+## Future Improvements
+
+1. **Resizable Sidebar**: Tambahkan drag-to-resize functionality
+2. **Collapsible Sidebar**: Tombol untuk collapse sidebar di desktop
+3. **Search History**: Simpan recent searches
+4. **Sidebar Preferences**: Remember user's sidebar state (open/closed)
+5. **Navbar Scroll Hide**: Auto-hide navbar saat scroll down (mobile)
+
+---
+
+fix: populate manage bin form with selected bin data using useEffect
+
+fix: make sidebar position fixed instead of sticky to prevent scrolling
+
+chore: update documentation for navbar, manage bin, and sidebar changes
+```
+
+---
+
+## Files Modified Summary
+
+```
+src/components/layout/navbar/index.tsx
+ - 37 lines changed
+ - Grid → Flexbox redesign
+ - Search bar extended
+ - Responsive gap values
+
+src/components/manage/ManageBin.tsx
+ - 15 lines added
+ - useEffect hook for form population
+ - Auto-reset on close/reopen
+
+src/components/layout/sidebar/index.tsx
+ - 1 line changed (sticky → fixed)
+ - Z-index adjustment
+
+CHANGELOG_NAVBAR.md
+ - Comprehensive documentation
+ - Fix explanations
+ - Testing checklists
+```
diff --git a/README.md b/README.md
index e215bc4..cdc37fa 100644
--- a/README.md
+++ b/README.md
@@ -1,36 +1,251 @@
-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).
+# Trashware
+
+Trashware is a real-time smart bin monitoring dashboard for campus or facility operations. It helps staff monitor trash bin fill levels, track full bins, manage device records, review activity history, and handle alerts from one web app.
+
+## Overview
+
+This project is built with:
+
+- Next.js (Pages Router)
+- React
+- TypeScript
+- Tailwind CSS
+- Firebase Firestore
+- NextAuth
+
+The application reads bin data from Firestore in real time and turns it into several operational views:
+
+- `Dashboard`: summary cards and bins that need attention
+- `Monitoring`: full live list of bins with search and status filters
+- `Manage Bin`: create, edit, and delete bin records
+- `Notifications`: alerts derived from full bins
+- `History / Analytics`: trends and activity summaries from historical bin data
+- `Authentication`: email/password and Google sign-in
+
+## Main Features
+
+- Real-time bin monitoring using Firestore `onSnapshot`
+- Fill-level classification such as empty, nearly full, full, and offline
+- Search by building, floor, room, and bin ID
+- Full-bin notifications with read/unread state
+- Bin CRUD management for operators
+- Hourly history synchronization for analytics
+- Credentials login and Google login with role stored in Firestore
+
+## Tech Stack
+
+- Framework: Next.js `16`
+- UI: React `19`, Tailwind CSS `4`
+- Auth: `next-auth`
+- Database: Firebase Firestore
+- Utilities: `date-fns`, `bcryptjs`, `react-icons`
+
+## Project Structure
+
+```text
+src/
+ components/
+ dashboard/
+ history/
+ layout/
+ manage/
+ notifications/
+ context/
+ SearchContext.tsx
+ lib/
+ firebase.ts
+ services/
+ deriveNotificationService.ts
+ historyAnalyticsService.ts
+ historyService.ts
+ notificationReadService.ts
+ pages/
+ api/
+ auth/[...nextauth].ts
+ register.ts
+ auth/
+ login/
+ register/
+ analytics/
+ history/
+ manage/
+ monitoring/
+ notifications/
+ index.tsx
+ styles/
+ types/
+ views/
+```
+
+## Application Flow
+
+### 1. Authentication
+
+- Users can register through `POST /api/register`
+- Credentials login checks the `users` collection and compares hashed passwords
+- Google login creates a Firestore user record automatically if it does not exist
+- User role is stored in the NextAuth JWT/session payload
+
+### 2. Bin Monitoring
+
+- The app subscribes to the `bins` collection in Firestore
+- Each bin record drives dashboard cards, monitoring cards, and management tables
+- Bin health is derived mainly from:
+ - `status`: `on` or `off`
+ - `level`: fill percentage
+
+### 3. Notifications
+
+- Notifications are derived from live bin data instead of being persisted as full notification documents
+- A notification exists when a bin is full (`level >= 90`)
+- Read state is stored separately in the `notificationRead` collection
+- If a bin is no longer full, its read marker is cleaned up
+
+### 4. History and Analytics
+
+- The app syncs bin snapshots into `bin_history` on an hourly basis
+- Analytics pages compute trends, peak times, and frequently full locations from that historical data
+
+## Routes
+
+### Public / auth routes
+
+- `/auth/login`
+- `/auth/register`
+
+### Main app routes
+
+- `/` - dashboard
+- `/monitoring` - live bin monitoring
+- `/monitoring/[id]` - bin detail / action page
+- `/manage` - bin management
+- `/notifications` - derived alerts
+- `/history` - analytics and historical trends
+- `/analytics` - analytics page entry present in the repo
+
+### API routes
+
+- `/api/register`
+- `/api/auth/[...nextauth]`
+
+## Firestore Collections
+
+The codebase currently implies these collections:
+
+### `bins`
+
+Primary live device/bin data.
+
+Expected fields commonly used in the UI:
+
+- `id: string`
+- `gedung: string`
+- `lantai: string`
+- `ruang: string`
+- `capacity: string | number`
+- `level: number`
+- `status: 'on' | 'off'`
+- `lastUpdate`
+- `location` in some history flows
+
+### `users`
+
+Application users for credentials and Google sign-in.
+
+Common fields:
+
+- `email`
+- `fullName`
+- `password` (nullable for Google accounts)
+- `role`
+- `createdAt`
+
+### `notificationRead`
+
+Stores read/unread state for derived notifications.
+
+Common fields:
+
+- `notificationId`
+- `readAt`
+
+### `bin_history`
+
+Hourly snapshot data for analytics.
+
+Common fields:
+
+- `binId`
+- `location`
+- `capacity`
+- `timestamp`
+- `source`
+
+## Environment Variables
+
+Create a local environment file:
+
+```bash
+cp .env.example .env.local
+```
+
+Required variables:
+
+```env
+NEXT_PUBLIC_FIREBASE_API_KEY=
+NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
+NEXT_PUBLIC_FIREBASE_PROJECT_ID=
+NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
+NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
+NEXT_PUBLIC_FIREBASE_APP_ID=
+
+NEXTAUTH_SECRET=
+NEXTAUTH_URL=
+
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
+```
## Getting Started
-First, run the development server:
+Install dependencies:
+
+```bash
+npm install
+```
+
+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.
+Open `http://localhost:3000`.
-You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+## Data Model Notes
-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.
+Two details are worth knowing before extending the app:
-## Learn More
+1. Some features use `capacity` as literal bin capacity, while history code appears to use it as an analytics value. Verify the intended meaning before changing analytics logic.
+2. Some screens build location from `gedung`, `lantai`, and `ruang`, while history sync also references a `location` field directly. Keeping those formats consistent will make the app easier to maintain.
-To learn more about Next.js, take a look at the following resources:
+## Auth and Access Notes
-- [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.
+- Middleware currently only matches `/admin/:path*`
+- Most page-level auth protection is handled inside pages with `useSession`
+- There is commented-out role logic in middleware, so role-based access control looks planned but not fully enforced globally yet
-You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+## Known Gaps
-## Deploy on Vercel
+- `README.md` was previously still the default Next.js template
+- Some notification code exists in commented legacy form and is no longer the active path
+- `notificationReadService.subscribeToReadNotifications` is not a full real-time subscription yet
+- The repo contains a `.env.local`; secrets should stay out of version control
-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.
+## Suggested Next Improvements
-Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+- Add Firestore security rules documentation
+- Document the exact bin schema used by the device side
+- Clarify user roles and access policy
+- Add deployment steps for Vercel or your chosen host
+- Add architecture diagrams for device-to-database data flow
diff --git a/package-lock.json b/package-lock.json
index d8a3115..f69eb56 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,8 +8,11 @@
"name": "trashware",
"version": "0.1.0",
"dependencies": {
+ "bcryptjs": "^3.0.3",
+ "date-fns": "^4.1.0",
"firebase": "^12.12.0",
"next": "16.2.3",
+ "next-auth": "^4.24.14",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-icons": "^5.6.0"
@@ -230,6 +233,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -1870,6 +1882,15 @@
"node": ">=12.4.0"
}
},
+ "node_modules/@panva/hkdf": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
+ "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -1883,25 +1904,24 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
- "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
- "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
+ "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
- "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
+ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"license": "BSD-3-Clause",
"dependencies": {
- "@protobufjs/aspromise": "^1.1.1",
- "@protobufjs/inquire": "^1.1.0"
+ "@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
@@ -1911,9 +1931,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
- "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
+ "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
@@ -1929,9 +1949,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
- "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
+ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@rtsao/scc": {
@@ -2481,9 +2501,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
- "version": "5.0.5",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
- "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+ "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3159,6 +3179,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/bcryptjs": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
+ "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
+ "license": "BSD-3-Clause",
+ "bin": {
+ "bcrypt": "bin/bcrypt"
+ }
+ },
"node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
@@ -3366,6 +3395,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3449,6 +3487,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/date-fns": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -5214,6 +5262,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/jose": {
+ "version": "4.15.9",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
+ "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5735,9 +5792,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [
{
"type": "github",
@@ -5828,6 +5885,38 @@
}
}
},
+ "node_modules/next-auth": {
+ "version": "4.24.14",
+ "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.14.tgz",
+ "integrity": "sha512-YRz6xFDXKUwiXSMMChbrBEWyFktZ1qZXEgeSHQQ3nsy08B4c/xLk6REeutRsIFwkjY/1+ShHnu07DN3JeJguig==",
+ "license": "ISC",
+ "dependencies": {
+ "@babel/runtime": "^7.20.13",
+ "@panva/hkdf": "^1.0.2",
+ "cookie": "^0.7.0",
+ "jose": "^4.15.5",
+ "oauth": "^0.9.15",
+ "openid-client": "^5.4.0",
+ "preact": "^10.6.3",
+ "preact-render-to-string": "^5.1.19",
+ "uuid": "^8.3.2"
+ },
+ "peerDependencies": {
+ "@auth/core": "0.34.3",
+ "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16",
+ "nodemailer": "^7.0.7",
+ "react": "^17.0.2 || ^18 || ^19",
+ "react-dom": "^17.0.2 || ^18 || ^19"
+ },
+ "peerDependenciesMeta": {
+ "@auth/core": {
+ "optional": true
+ },
+ "nodemailer": {
+ "optional": true
+ }
+ }
+ },
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -5882,6 +5971,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/oauth": {
+ "version": "0.9.15",
+ "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
+ "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
+ "license": "MIT"
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5892,6 +5987,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/object-hash": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
+ "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -6005,6 +6109,48 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/oidc-token-hash": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
+ "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
+ "license": "MIT",
+ "engines": {
+ "node": "^10.13.0 || >=12.0.0"
+ }
+ },
+ "node_modules/openid-client": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
+ "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
+ "license": "MIT",
+ "dependencies": {
+ "jose": "^4.15.9",
+ "lru-cache": "^6.0.0",
+ "object-hash": "^2.2.0",
+ "oidc-token-hash": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/openid-client/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/openid-client/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "license": "ISC"
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -6143,9 +6289,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.9",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
- "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
@@ -6163,7 +6309,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.11",
+ "nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -6171,6 +6317,28 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/preact": {
+ "version": "10.29.1",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz",
+ "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
+ "node_modules/preact-render-to-string": {
+ "version": "5.2.6",
+ "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
+ "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
+ "license": "MIT",
+ "dependencies": {
+ "pretty-format": "^3.8.0"
+ },
+ "peerDependencies": {
+ "preact": ">=10"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -6181,6 +6349,12 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/pretty-format": {
+ "version": "3.8.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
+ "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
+ "license": "MIT"
+ },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -6194,24 +6368,24 @@
}
},
"node_modules/protobufjs": {
- "version": "7.5.4",
- "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
- "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
+ "version": "7.6.2",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.2.tgz",
+ "integrity": "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
- "@protobufjs/codegen": "^2.0.4",
- "@protobufjs/eventemitter": "^1.1.0",
- "@protobufjs/fetch": "^1.1.0",
+ "@protobufjs/codegen": "^2.0.5",
+ "@protobufjs/eventemitter": "^1.1.1",
+ "@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
- "@protobufjs/inquire": "^1.1.0",
+ "@protobufjs/inquire": "^1.1.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
- "@protobufjs/utf8": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
- "long": "^5.0.0"
+ "long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
@@ -7318,6 +7492,15 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
"node_modules/web-vitals": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
diff --git a/package.json b/package.json
index 9212218..a12336b 100644
--- a/package.json
+++ b/package.json
@@ -9,8 +9,11 @@
"lint": "eslint"
},
"dependencies": {
+ "bcryptjs": "^3.0.3",
+ "date-fns": "^4.1.0",
"firebase": "^12.12.0",
"next": "16.2.3",
+ "next-auth": "^4.24.14",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-icons": "^5.6.0"
diff --git a/public/img/logotrashware1.png b/public/img/logotrashware1.png
new file mode 100644
index 0000000..58e5c16
Binary files /dev/null and b/public/img/logotrashware1.png differ
diff --git a/src/components/dashboard/BinCard.tsx b/src/components/dashboard/BinCard.tsx
new file mode 100644
index 0000000..93d7438
--- /dev/null
+++ b/src/components/dashboard/BinCard.tsx
@@ -0,0 +1,98 @@
+import Link from 'next/link';
+import { Bin } from '@/types/database';
+import { FiMapPin, FiClock, FiWifi, FiWifiOff } from 'react-icons/fi';
+import { formatDistanceToNow } from 'date-fns'; // Opsional: Instal dengan 'npm install date-fns'
+import { id as localeId } from 'date-fns/locale';
+
+export default function BinCard({ id, gedung, lantai, ruang, level, capacity, status, lastUpdate }: Bin) {
+ const isOnline = status === 'on';
+
+ // Logika tema diletakkan SEBELUM return
+
+ const getLastUpdateLabel = (timestamp: unknown) => {
+ if (!timestamp) return 'Belum ada data';
+
+ try {
+ const maybeTimestamp = timestamp as { toDate?: () => Date };
+ const date = maybeTimestamp.toDate ? maybeTimestamp.toDate() : new Date(timestamp as string | number | Date);
+ return formatDistanceToNow(date, { addSuffix: true, locale: localeId });
+ } catch {
+ return 'Baru saja';
+ }
+ };
+ const getTheme = () => {
+ if (!isOnline) {
+ return { color: 'bg-gray-300', text: 'text-gray-500', label: 'Offline', bgLabel: 'bg-gray-100' };
+ }
+ if (level >= 90) {
+ return { color: 'bg-red-500', text: 'text-red-500', label: 'Penuh', bgLabel: 'bg-red-100' };
+ }
+ if (level >= 70) {
+ return { color: 'bg-orange-400', text: 'text-orange-500', label: 'Hampir Penuh', bgLabel: 'bg-orange-100' };
+ }
+ if (level > 0) {
+ return { color: 'bg-emerald-500', text: 'text-emerald-500', label: 'Terisi', bgLabel: 'bg-emerald-100' };
+ }
+ return { color: 'bg-gray-300', text: 'text-gray-400', label: 'Kosong', bgLabel: 'bg-gray-50' };
+ };
+
+ const theme = getTheme();
+
+ return (
+
+
+ {/* Header: ID & Status Wifi */}
+
+
+
+ {id}
+
+
+
+ {gedung} - {lantai} {ruang ? `• ${ruang}` : ''}
+
+
+
+ {isOnline ? : }
+
+ {theme.label}
+
+
+
+
+ {/* Progress Bar Level Isi */}
+
+
+ Level Isi
+
+ {isOnline ? `${level}%` : '--'}
+
+
+
+
+
100 ? 100 : level < 0 ? 0 : level) : 0}%` }}
+ />
+
+
+
+ 0L
+ {capacity}L
+
+
+
+ {/* Footer Card */}
+
+
+
+ Update: {getLastUpdateLabel(lastUpdate)}
+
+
+ DETAIL AKSI →
+
+
+
+
+ );
+}
diff --git a/src/components/dashboard/StatCard.tsx b/src/components/dashboard/StatCard.tsx
index cb485d5..be2ac78 100644
--- a/src/components/dashboard/StatCard.tsx
+++ b/src/components/dashboard/StatCard.tsx
@@ -1,27 +1,23 @@
-// src/components/dashboard/StatCard.tsx
-import React from 'react';
-
interface StatCardProps {
title: string;
- value: number | string;
- trend: string;
- iconBg: string;
- textColor: string;
+ value: number;
icon: React.ReactNode;
+ textColor: string;
}
-export default function StatCard({ title, value, trend, iconBg, textColor, icon }: StatCardProps) {
+export default function StatCard({ title, value, icon, textColor }: StatCardProps) {
return (
-
+
-
+
{title}
+
{icon}
-
{trend}
-
-
{value}
-
{title}
+
+
+ {value}
+
);
diff --git a/src/components/layout/PageContainer.tsx b/src/components/layout/PageContainer.tsx
new file mode 100644
index 0000000..a74d000
--- /dev/null
+++ b/src/components/layout/PageContainer.tsx
@@ -0,0 +1,14 @@
+import React, { ReactNode } from 'react';
+
+interface PageContainerProps {
+ children: ReactNode;
+ className?: string;
+}
+
+export default function PageContainer({ children, className = '' }: PageContainerProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/layout/PageHeader.tsx b/src/components/layout/PageHeader.tsx
new file mode 100644
index 0000000..ee745e2
--- /dev/null
+++ b/src/components/layout/PageHeader.tsx
@@ -0,0 +1,21 @@
+interface PageHeaderProps {
+ title: string;
+ subtitle?: string;
+ className?: string;
+}
+
+export default function PageHeader({ title, subtitle, className = '' }: PageHeaderProps) {
+ return (
+
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ );
+}
+
diff --git a/src/components/layout/navbar/index.tsx b/src/components/layout/navbar/index.tsx
index 00f7214..2820882 100644
--- a/src/components/layout/navbar/index.tsx
+++ b/src/components/layout/navbar/index.tsx
@@ -1,41 +1,116 @@
-import { FiSearch, FiBell, FiMoon } from 'react-icons/fi';
+import { FiSearch, FiBell } from "react-icons/fi";
+import { useRouter } from "next/router";
+import { useState, useEffect } from "react";
+import { deriveNotificationService } from "@/lib/services/deriveNotificationService";
+import { useSearch } from "@/context/SearchContext";
+import { useSession } from "next-auth/react";
+import { FiMenu } from "react-icons/fi";
+import Image from "next/image";
+
+type NavbarProps = {
+ onMenuClick?: () => void;
+};
+
+export default function Navbar({ onMenuClick }: NavbarProps) {
+ const router = useRouter();
+ const [notificationCount, setNotificationCount] = useState(0);
+ const { searchQuery, setSearchQuery } = useSearch();
+ const { data: session } = useSession();
+ const userRole = session?.user?.role || "user";
+ const userName = session?.user?.name || "Loading...";
+
+ // 1. Tentukan halaman mana yang SEARCH-nya mau DIHILANGKAN
+ const hideSearchOn = ["/notifications", "/settings", "/profile"];
+ const shouldHideSearch = hideSearchOn.includes(router.pathname);
+
+ const handleBellClick = () => {
+ router.push("/notifications");
+ };
+
+ useEffect(() => {
+ const unsubscribe = deriveNotificationService.subscribeToNotificationCounts((counts) => {
+ setNotificationCount(counts.baru);
+ });
+ return () => unsubscribe();
+ }, []);
-export default function Navbar() {
return (
-
- {/* Search Bar */}
-
-
-
-
-
+
+ {/* Menu Button - Mobile Only */}
+
+ {})}
+ className="flex h-8 w-8 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-700 shadow-sm transition hover:bg-gray-50"
+ aria-label="Buka menu"
+ >
+
+
- {/* Right Side Icons & Profile */}
-
-
+ {/* Search Bar Group - Mobile and Desktop */}
+
+ {!shouldHideSearch ? (
+ <>
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="w-full rounded-full border-none bg-white py-1.5 pl-9 pr-2 text-[11px] shadow-soft outline-none transition-all focus:ring-2 focus:ring-emerald-500 sm:py-2.5 sm:pl-12 sm:pr-4 sm:text-sm"
+ />
+
+ {/* Bell Icon Mobile - di sebelah kanan search */}
+
+
+ {notificationCount > 0 && (
+
+ {notificationCount > 9 ? "9+" : notificationCount}
+
+ )}
+
+ >
+ ) : (
+ /* Hidden Search - Show empty space or nothing */
+
+ )}
+
+
+ {/* Spacer for desktop - pushes right side to the right */}
+
+ {/* Right Side - Bell and Profile - Always on Right */}
+
+ {/* Notification Icon Desktop Only */}
+
+
+ {notificationCount > 0 && (
+
+ {notificationCount > 9 ? "9+" : notificationCount}
+
+ )}
+
+
{/* User Profile */}
-
-
-
Admin User
-
Facility Manager
-
-
- AU
-
+
+
router.push("/profile")} className="flex items-center gap-3 hover:opacity-80 transition">
+
+
{userName}
+
{userRole}
+
+ {session?.user?.image ? (
+
+ ) : (
+
+ {(session?.user?.name || "").split(" ").map((n: string) => n[0]).slice(0,2).join("")}
+
+ )}
+
);
-}
\ No newline at end of file
+}
diff --git a/src/components/layout/sidebar/index.tsx b/src/components/layout/sidebar/index.tsx
index fee06a4..689d1de 100644
--- a/src/components/layout/sidebar/index.tsx
+++ b/src/components/layout/sidebar/index.tsx
@@ -1,62 +1,82 @@
-import { FiGrid, FiBarChart2, FiMapPin, FiBell, FiSettings, FiChevronLeft, FiTrash2 } from 'react-icons/fi';
-import Link from 'next/link';
-import { useRouter } from 'next/router';
+import { FiGrid, FiMapPin, FiBell, FiTrash2 } from "react-icons/fi";
+import Link from "next/link";
+import { useRouter } from "next/router";
+import { FiLogOut, FiX } from "react-icons/fi";
+import { signOut, useSession } from "next-auth/react";
+import Image from "next/image";
const menuItems = [
- { name: 'Dashboard', icon:
, path: '/' },
- { name: 'Monitoring', icon:
, path: '/monitoring' },
- { name: 'Map View', icon:
, path: '/map-view' },
- { name: 'Analytics', icon:
, path: '/analytics' },
- { name: 'Notifications', icon:
, path: '/notifications' },
+ { name: "Dashboard", icon:
, path: "/", role: ["admin", "petugas"] },
+ { name: "Monitoring", icon:
, path: "/monitoring", role: ["admin", "petugas"] },
+ { name: "Manage Bin", icon:
, path: "/manage", role: ["admin"] }, // hanya admin
+ // { name: "Analytics", icon:
, path: "/analytics", role: ["admin", "petugas"] },
+ { name: "Notifications", icon:
, path: "/notifications", role: ["admin", "petugas"] },
];
-export default function Sidebar() {
+type Role = "admin" | "petugas";
+
+type SidebarProps = {
+ isOpen: boolean;
+ onClose: () => void;
+};
+
+export default function Sidebar({ isOpen, onClose }: SidebarProps) {
const router = useRouter();
+ const { data: session } = useSession();
+ const role = (session?.user as { role?: Role })?.role;
+
+ const handleLogout = async () => {
+ await signOut({ callbackUrl: "/auth/login" });
+ };
+
return (
-
-
- {/* Logo Section */}
-
-
-
-
-
-
SmartBin
-
IoT Monitoring
+
+
+
+
+
+
+
TrashWare
+
IoT Monitoring
+
+
+
+
+
- {/* Menu Items */}
-
- {menuItems.map((item) => (
-
-
- {item.icon}
- {item.name}
-
-
- ))}
+
+ {menuItems.map((item) => {
+ if (!role || !item.role.includes(role)) return null;
+
+ return (
+
+
+ {item.icon}
+ {item.name}
+
+
+ );
+ })}
- {/* Bottom Section */}
-
-
-
-
- Settings
-
-
-
+
);
-}
\ No newline at end of file
+}
diff --git a/src/components/manage/ManageBin.tsx b/src/components/manage/ManageBin.tsx
new file mode 100644
index 0000000..378e647
--- /dev/null
+++ b/src/components/manage/ManageBin.tsx
@@ -0,0 +1,225 @@
+import { useState, useEffect } from 'react';
+import { db } from '@/lib/firebase';
+import { updateDoc, doc, setDoc, serverTimestamp } from 'firebase/firestore';
+import { FiX } from 'react-icons/fi';
+import { Bin } from '@/types/database';
+
+export interface BinData extends Bin {
+ firestoreId?: string;
+}
+
+interface ManageBinProps {
+ isOpen: boolean;
+ onClose: () => void;
+ editData: BinData | null;
+}
+
+const initialFormState: BinData = {
+ id: "",
+ gedung: "",
+ lantai: "",
+ ruang: "",
+ capacity: 0,
+ level: 0,
+ status: 'on',
+ distance: 0
+};
+
+export default function ManageBin({ isOpen, onClose, editData }: ManageBinProps) {
+ const [formData, setFormData] = useState
(initialFormState);
+ const [error, setError] = useState(null);
+
+ // FIX: Sinkronisasi state yang aman dengan pengecekan kondisi objek
+ useEffect(() => {
+ if (editData) {
+ // Hanya update state jika data yang masuk berbeda dengan data form saat ini
+ if (formData.firestoreId !== editData.firestoreId || formData.id !== editData.id) {
+ setFormData(editData);
+ }
+ } else {
+ // Hanya reset jika form saat ini tidak dalam kondisi kosong/awal
+ if (formData.id !== "") {
+ setFormData(initialFormState);
+ }
+ }
+ setError(null);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [editData]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError(null);
+
+ try {
+ if (!editData && !formData.id.trim()) {
+ setError('ID Bin wajib diisi');
+ return;
+ }
+
+ if (editData?.firestoreId) {
+ // UPDATE existing bin
+ const docRef = doc(db, "bins", editData.firestoreId);
+ const dataToSave = { ...formData };
+ delete dataToSave.firestoreId;
+ await updateDoc(docRef, {
+ ...dataToSave,
+ lastUpdate: serverTimestamp()
+ });
+ } else {
+ // CREATE new bin dengan ID sebagai document ID
+ const docRef = doc(db, "bins", formData.id);
+ await setDoc(docRef, {
+ ...formData,
+ lastUpdate: serverTimestamp()
+ });
+ }
+ onClose();
+ } catch (error) {
+ console.error("Firebase Error:", error);
+ setError('Gagal menyimpan data. Silakan coba lagi.');
+ }
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
+
+
+
+
{editData ? 'Edit Data Bin' : 'Tambah Bin Baru'}
+
Konfigurasi lokasi dan kapasitas tempat sampah
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/notifications/NotificationItem.tsx b/src/components/notifications/NotificationItem.tsx
new file mode 100644
index 0000000..c74ce72
--- /dev/null
+++ b/src/components/notifications/NotificationItem.tsx
@@ -0,0 +1,88 @@
+import { DerivedNotification } from '@/lib/services/deriveNotificationService';
+import { FiAlertCircle, FiAlertTriangle } from 'react-icons/fi';
+
+interface NotificationItemProps {
+ notification: DerivedNotification;
+ onMarkAsRead: (id: string) => void;
+}
+
+export default function NotificationItem({
+ notification,
+ onMarkAsRead,
+}: NotificationItemProps) {
+ const formatTime = (date: Date) => {
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const minutes = Math.floor(diff / 60000);
+ const hours = Math.floor(diff / 3600000);
+ const days = Math.floor(diff / 86400000);
+
+ if (minutes < 1) return 'baru saja';
+ if (minutes < 60) return `${minutes} menit lalu`;
+ if (hours < 24) return `${hours} jam lalu`;
+ if (days < 7) return `${days} hari lalu`;
+
+ return date.toLocaleDateString('id-ID');
+ };
+
+ const getIconStyles = () => {
+ if (notification.capacity >= 90) {
+ // Penuh - Red
+ return {
+ icon: FiAlertCircle,
+ bgColor: 'bg-red-100',
+ iconColor: 'text-red-500',
+ };
+ } else if (notification.capacity >= 70) {
+ // Hampir Penuh - Yellow
+ return {
+ icon: FiAlertTriangle,
+ bgColor: 'bg-yellow-100',
+ iconColor: 'text-yellow-500',
+ };
+ }
+ // Normal - Green
+ return {
+ icon: FiAlertTriangle,
+ bgColor: 'bg-green-100',
+ iconColor: 'text-green-500',
+ };
+ };
+
+ const styles = getIconStyles();
+ const IconComponent = styles.icon;
+
+ return (
+
+ {/* Icon */}
+
+
+
+
+ {/* Content */}
+
+
+ {notification.gedung} - {notification.lantai} - {notification.ruang}
+
+
{notification.description}
+
+ ⏱ {formatTime(notification.timestamp)}
+
+
+
+ {/* Button - Show only if status is 'baru' (unread) */}
+ {notification.status === 'baru' ? (
+
onMarkAsRead(notification.id)}
+ className="w-full shrink-0 rounded-lg bg-blue-500 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-blue-600 sm:w-auto sm:text-sm"
+ >
+ Baru
+
+ ) : (
+
+ Dibaca
+
+ )}
+
+ );
+}
diff --git a/src/components/notifications/NotificationList.tsx b/src/components/notifications/NotificationList.tsx
new file mode 100644
index 0000000..45634fd
--- /dev/null
+++ b/src/components/notifications/NotificationList.tsx
@@ -0,0 +1,94 @@
+import { DerivedNotification } from '@/lib/services/deriveNotificationService';
+import NotificationItem from './NotificationItem';
+import PageHeader from '@/components/layout/PageHeader'; // Import PageHeader
+
+interface NotificationFilter {
+ semua: number;
+ baru: number;
+ dibaca: number;
+}
+
+interface NotificationListProps {
+ notifications: DerivedNotification[];
+ filter: 'semua' | 'baru' | 'dibaca';
+ counts: NotificationFilter;
+ onFilterChange: (filter: 'semua' | 'baru' | 'dibaca') => void;
+ onMarkAsRead: (id: string) => void;
+ onMarkAllAsRead: () => void;
+ loading: boolean;
+}
+
+export default function NotificationList({
+ notifications,
+ filter,
+ counts,
+ onFilterChange,
+ onMarkAsRead,
+ onMarkAllAsRead,
+ loading,
+}: NotificationListProps) {
+ const filterOptions = [
+ { label: 'Semua', value: 'semua' as const, count: counts.semua },
+ { label: 'Baru', value: 'baru' as const, count: counts.baru },
+ { label: 'Dibaca', value: 'dibaca' as const, count: counts.dibaca },
+ ];
+
+ return (
+
+ {/* Bagian Header yang diubah menggunakan PageHeader */}
+
+
+
+
+ ✓
+ Tandai Semua Dibaca
+
+
+
+ {/* Filter Tabs - Tetap sesuai desain asli Anda */}
+
+ {filterOptions.map((option) => (
+ onFilterChange(option.value)}
+ className={`whitespace-nowrap rounded-xl px-4 py-2 text-xs font-semibold transition sm:px-6 sm:py-2.5 sm:text-sm ${
+ filter === option.value
+ ? 'bg-white text-gray-900 shadow-sm'
+ : 'bg-transparent text-gray-600 hover:text-gray-900'
+ }`}
+ >
+ {option.label} ({option.count} )
+
+ ))}
+
+
+ {/* Notification List Content - Tetap sesuai desain asli Anda */}
+ {loading ? (
+
+
Loading notifikasi...
+
+ ) : notifications.length === 0 ? (
+
+ ) : (
+
+ {notifications.map((notification) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/profile/ChangePasswordModal.tsx b/src/components/profile/ChangePasswordModal.tsx
new file mode 100644
index 0000000..a711a29
--- /dev/null
+++ b/src/components/profile/ChangePasswordModal.tsx
@@ -0,0 +1,134 @@
+import { useState } from "react";
+
+type Props = {
+ open: boolean;
+ onClose: () => void;
+};
+
+export default function ChangePasswordModal({ open, onClose }: Props) {
+ const [currentPassword, setCurrentPassword] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState(false);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setSuccess(false);
+
+ if (!currentPassword || !newPassword || !confirmPassword) {
+ setError("Semua field harus diisi");
+ return;
+ }
+
+ if (newPassword !== confirmPassword) {
+ setError("Password baru dan konfirmasi harus sama");
+ return;
+ }
+
+ if (newPassword.length < 6) {
+ setError("Password baru minimal 6 karakter");
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const res = await fetch("/api/change-password", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ currentPassword, newPassword }),
+ });
+
+ if (!res.ok) {
+ const data = await res.json();
+ throw new Error(data.message || "Gagal mengubah password");
+ }
+
+ setSuccess(true);
+ setCurrentPassword("");
+ setNewPassword("");
+ setConfirmPassword("");
+ setTimeout(() => {
+ onClose();
+ }, 2000);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Terjadi kesalahan");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ if (!open) return null;
+
+ return (
+
+
+
Ganti Password
+
+ {success ? (
+
+
✓
+
Password berhasil diubah!
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/components/profile/ProfileModal.tsx b/src/components/profile/ProfileModal.tsx
new file mode 100644
index 0000000..0494b47
--- /dev/null
+++ b/src/components/profile/ProfileModal.tsx
@@ -0,0 +1,116 @@
+import { useState, useEffect } from "react";
+import { useSession } from "next-auth/react";
+
+type Props = {
+ open: boolean;
+ onClose: () => void;
+ onSave?: () => void;
+};
+
+export default function ProfileModal({ open, onClose, onSave }: Props) {
+ const { update } = useSession();
+ const [name, setName] = useState("");
+ const [email, setEmail] = useState("");
+ const [phone, setPhone] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (open) fetchProfile();
+ }, [open]);
+
+ const fetchProfile = async () => {
+ setLoading(true);
+ try {
+ const res = await fetch("/api/profile", {
+ method: "GET",
+ cache: "no-store",
+ headers: { "Cache-Control": "no-cache" }
+ });
+ if (res.ok) {
+ const data = await res.json();
+ const user = data.user;
+ setName(user.name || "");
+ setEmail(user.email || "");
+ setPhone(user.phone || "");
+ }
+ } catch (err) {
+ console.error("Failed to fetch profile:", err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ const res = await fetch("/api/profile", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name, phone }),
+ });
+
+ const data = await res.json();
+ console.log("PUT response:", { status: res.status, data });
+
+ if (!res.ok) {
+ throw new Error(data.message || "Gagal menyimpan");
+ }
+
+ // Wait for onSave callback to complete before closing
+ await onSave?.();
+ await update({ name });
+
+ onClose();
+ } catch (err) {
+ console.error("Save error:", err);
+ alert(err instanceof Error ? err.message : "Terjadi kesalahan saat menyimpan profile.");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ if (!open) return null;
+
+ return (
+
+
+
Edit Profile
+
+ {loading ? (
+
+ ) : (
+ <>
+
+
+
+
+ Cancel
+
+
+ {saving ? "Saving..." : "Save"}
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/context/SearchContext.tsx b/src/context/SearchContext.tsx
new file mode 100644
index 0000000..34888e2
--- /dev/null
+++ b/src/context/SearchContext.tsx
@@ -0,0 +1,22 @@
+import React, { createContext, useContext, useState } from 'react';
+
+interface SearchContextType {
+ searchQuery: string;
+ setSearchQuery: (val: string) => void;
+}
+
+const SearchContext = createContext({
+ searchQuery: "",
+ setSearchQuery: () => {},
+});
+
+export const SearchProvider = ({ children }: { children: React.ReactNode }) => {
+ const [searchQuery, setSearchQuery] = useState("");
+ return (
+
+ {children}
+
+ );
+};
+
+export const useSearch = () => useContext(SearchContext);
\ No newline at end of file
diff --git a/src/lib/services/binService.ts b/src/lib/services/binService.ts
new file mode 100644
index 0000000..625c408
--- /dev/null
+++ b/src/lib/services/binService.ts
@@ -0,0 +1,32 @@
+import { Bin } from '@/types/database';
+
+export const binService = {
+ /**
+ * Mengoreksi status ON/OFF berdasarkan detak jantung (heartbeat) IoT.
+ * Jika tidak ada kiriman data baru lebih dari 8 detik, paksa status jadi 'off'.
+ */
+ enforceHeartbeat(bin: Bin): Bin {
+ let isOnline = bin.status === 'on';
+
+ if (bin.lastUpdate) {
+ // Ekstrak waktu milidetik menggunakan safe structural casting tanpa 'any'
+ const lastUpdateMs = typeof bin.lastUpdate === 'object' && bin.lastUpdate && 'toMillis' in bin.lastUpdate
+ ? (bin.lastUpdate as { toMillis: () => number }).toMillis()
+ : new Date(bin.lastUpdate as string | number | Date).getTime();
+
+ const batasToleransi = 8 * 1000; // Jeda ketat 8 detik untuk interval IoT 5 detik
+ const waktuMaksimalUpdate = Date.now() - batasToleransi;
+
+ if (lastUpdateMs < waktuMaksimalUpdate) {
+ isOnline = false;
+ }
+ } else {
+ isOnline = false; // Jika tidak ada timestamp, otomatis anggap offline
+ }
+
+ return {
+ ...bin,
+ status: isOnline ? ('on' as const) : ('off' as const),
+ };
+ }
+};
\ No newline at end of file
diff --git a/src/lib/services/deriveNotificationService.ts b/src/lib/services/deriveNotificationService.ts
new file mode 100644
index 0000000..6e28885
--- /dev/null
+++ b/src/lib/services/deriveNotificationService.ts
@@ -0,0 +1,190 @@
+import { db } from '@/lib/firebase';
+import { collection, onSnapshot } from 'firebase/firestore';
+import { Bin } from '@/types/database';
+import { notificationReadService } from './notificationReadService';
+import { binService } from './binService';
+
+export interface DerivedNotification {
+ id: string;
+ binId: string;
+ gedung: string;
+ lantai: string;
+ ruang: string;
+ location: string;
+ title: string;
+ description: string;
+ level: number;
+ capacity: number;
+ status: 'baru' | 'dibaca';
+ type: 'error' | 'warning' | 'info';
+ timestamp: Date;
+}
+
+export const deriveNotificationService = {
+ // Subscribe to bins and derive notifications (ONLY FULL BINS - level >= 90 & status === 'on')
+ subscribeToDerivedNotifications(
+ callback: (notifications: DerivedNotification[]) => void
+ ) {
+ const unsubscribe = onSnapshot(collection(db, 'bins'), (snapshot) => {
+ const bins = snapshot.docs.map((doc) => {
+ const rawBin = { id: doc.id, ...doc.data() } as Bin;
+ return binService.enforceHeartbeat(rawBin);
+ });
+
+ // Derive notifications dari tempat sampah yang PENUH DAN AKTIF saja
+ const derivedNotifications = bins
+ .filter((bin) => bin.status === 'on' && bin.level >= 90)
+ .map((bin) => {
+ const location = `${bin.gedung} - ${bin.lantai} - ${bin.ruang}`;
+
+ const timestamp = bin.lastUpdate
+ ? (typeof bin.lastUpdate === 'object' && 'toDate' in bin.lastUpdate
+ ? (bin.lastUpdate as { toDate: () => Date }).toDate()
+ : new Date(bin.lastUpdate as string | number | Date))
+ : new Date();
+
+ return {
+ id: bin.id,
+ binId: bin.id,
+ gedung: bin.gedung,
+ lantai: bin.lantai,
+ ruang: bin.ruang,
+ location: location,
+ title: location,
+ description: `${location} penuh (level ${bin.level}) - segera kosongkan`,
+ level: bin.level,
+ capacity: bin.capacity,
+ status: 'baru' as const,
+ type: 'error' as const,
+ timestamp: timestamp,
+ } as DerivedNotification;
+ })
+ .sort((a, b) => b.level - a.level);
+
+ callback(derivedNotifications);
+ });
+
+ return unsubscribe;
+ },
+
+ // Get derived notification counts (ONLY FULL BINS) - Real-time dengan status baca
+ subscribeToNotificationCounts(
+ callback: (counts: { semua: number; baru: number; dibaca: number }) => void
+ ) {
+ let binsData: Bin[] = [];
+ let readIds: string[] = [];
+
+ // Subscribe to bins
+ const unsubscribeBins = onSnapshot(collection(db, 'bins'), (snapshot) => {
+ binsData = snapshot.docs.map((doc) => {
+ const rawBin = { id: doc.id, ...doc.data() } as Bin;
+ return binService.enforceHeartbeat(rawBin);
+ });
+
+ updateCounts();
+ });
+
+ // Subscribe to read notifications (real-time)
+ const unsubscribeRead = onSnapshot(collection(db, 'notificationRead'), (snapshot) => {
+ readIds = snapshot.docs
+ .map((doc) => doc.data() as { notificationId?: string })
+ .map((data) => data.notificationId)
+ .filter((id): id is string => Boolean(id));
+ updateCounts();
+ });
+
+ const updateCounts = () => {
+ const fullBins = binsData.filter((b) => b.status === 'on' && b.level >= 90);
+ const fullBinIds = new Set(fullBins.map((b) => b.id));
+
+ notificationReadService.cleanupReadNotificationsForNonFullBins(
+ fullBinIds
+ );
+
+ const validReadIds = readIds.filter((id) => fullBinIds.has(id));
+
+ callback({
+ semua: fullBins.length,
+ baru: fullBins.length - validReadIds.length,
+ dibaca: validReadIds.length,
+ });
+ };
+
+ return () => {
+ unsubscribeBins();
+ unsubscribeRead();
+ };
+ },
+
+ // Subscribe to notifications with read status - Real-time
+ subscribeToDerivedNotificationsWithStatus(
+ callback: (notifications: DerivedNotification[]) => void,
+ filter?: 'semua' | 'baru' | 'dibaca'
+ ) {
+ let binsData: Bin[] = [];
+ let readIds: string[] = [];
+
+ // Subscribe to bins
+ const unsubscribeBins = onSnapshot(collection(db, 'bins'), (snapshot) => {
+ binsData = snapshot.docs.map((doc) => {
+ const rawBin = { id: doc.id, ...doc.data() } as Bin;
+ return binService.enforceHeartbeat(rawBin);
+ });
+
+ updateNotifications();
+ });
+
+ // Subscribe to read notifications (real-time)
+ const unsubscribeRead = onSnapshot(collection(db, 'notificationRead'), (snapshot) => {
+ readIds = snapshot.docs
+ .map((doc) => doc.data() as { notificationId?: string })
+ .map((data) => data.notificationId)
+ .filter((id): id is string => Boolean(id));
+ updateNotifications();
+ });
+
+ const updateNotifications = () => {
+ const fullBins = binsData.filter((bin) => bin.status === 'on' && bin.level >= 90);
+
+ const derivedNotifications = fullBins
+ .map((bin) => {
+ const location = `${bin.gedung} - ${bin.lantai} - ${bin.ruang}`;
+ const isRead = readIds.includes(bin.id);
+
+ const timestamp = bin.lastUpdate
+ ? (typeof bin.lastUpdate === 'object' && 'toDate' in bin.lastUpdate
+ ? (bin.lastUpdate as { toDate: () => Date }).toDate()
+ : new Date(bin.lastUpdate as string | number | Date))
+ : new Date();
+
+ return {
+ id: bin.id,
+ binId: bin.id,
+ gedung: bin.gedung,
+ lantai: bin.lantai,
+ ruang: bin.ruang,
+ location: location,
+ title: location,
+ description: `${location} penuh (level ${bin.level}) - segera kosongkan`,
+ level: bin.level,
+ capacity: bin.capacity,
+ status: isRead ? 'dibaca' : 'baru',
+ type: 'error' as const,
+ timestamp: timestamp,
+ } as DerivedNotification;
+ })
+ .filter((notif) => {
+ if (!filter || filter === 'semua') return true;
+ return notif.status === filter;
+ })
+ .sort((a, b) => b.level - a.level);
+
+ callback(derivedNotifications);
+ };
+
+ return () => {
+ unsubscribeBins();
+ unsubscribeRead();
+ };
+ },
+};
\ No newline at end of file
diff --git a/src/lib/services/historyAnalyticsService.ts b/src/lib/services/historyAnalyticsService.ts
new file mode 100644
index 0000000..de95fed
--- /dev/null
+++ b/src/lib/services/historyAnalyticsService.ts
@@ -0,0 +1,113 @@
+import type { HistoryRecord } from './historyService';
+
+export const formatDurationInHours = (minutes: number) => {
+ if (minutes < 60) return `${minutes} menit`;
+
+ const hours = Math.floor(minutes / 60);
+ const remainingMinutes = minutes % 60;
+
+ if (remainingMinutes === 0) {
+ return `${hours} jam`;
+ }
+
+ return `${hours} jam ${remainingMinutes} menit`;
+};
+
+export type BinPriorityItem = {
+ id: string;
+ binId?: string;
+ location: string;
+ capacity: number;
+ status: string | undefined;
+ priorityScore: number;
+ priorityLabel: 'High' | 'Medium' | 'Low';
+ fullAgeMinutes?: number | null; // minutes since last became full
+ reason?: string;
+};
+
+// Compute pickup priority ranking using live bins and optional history records
+export const getPickupPriorityRanking = (
+ liveBins: Array<{ id: string; location?: string; capacity?: number; level?: number; status?: string; gedung?: string; lantai?: string|number; ruang?: string }> = [],
+ historyRecords: HistoryRecord[] = []
+): BinPriorityItem[] => {
+ const now = Date.now();
+
+ // helper: find last timestamp when this bin was >
+ const lastFullTimeForBin = (binId: string) => {
+ const rec = [...historyRecords]
+ .filter((r) => r.binId === binId && typeof r.capacity === 'number' && r.capacity )
+ .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())[0];
+ return rec ? rec.timestamp.getTime() : null;
+ };
+
+ // helper: count how often bin was full in recent period (e.g., 7 days)
+ const fullCountForBin = (binId: string, days = 7) => {
+ const cutoff = now - days * 24 * 60 * 60 * 1000;
+ return historyRecords.filter((r) => r.binId === binId && r.timestamp.getTime() >= cutoff && r.capacity).length;
+ };
+
+ const items = liveBins.map((bin) => {
+ const currentLevel = typeof bin.level === 'number'
+ ? bin.level
+ : typeof bin.capacity === 'number'
+ ? bin.capacity
+ : 0;
+ const status = bin.status;
+ // Compose location from gedung/lantai/ruang if location field is missing
+ const location =
+ bin.location ||
+ [bin.gedung, bin.lantai, bin.ruang]
+ .filter((p) => p !== undefined && p !== null && String(p).trim() !== '')
+ .join(' - ') ||
+ 'Lokasi Tidak Diketahui';
+ const binId = bin.id;
+
+ const lastFullTs = lastFullTimeForBin(binId);
+ let fullAgeMinutes: number | null = null;
+ if (lastFullTs) {
+ fullAgeMinutes = Math.max(0, Math.floor((now - lastFullTs) / 60000));
+ }
+
+ // Only rank bins that are currently full. This keeps the leaderboard aligned with manage/monitoring.
+ if (status !== 'on' || currentLevel ) {
+ return null;
+ }
+
+ const freq = fullCountForBin(binId, 14); // last 14 days
+
+ let score = 0;
+ // main factor: how long it has been full
+ if (fullAgeMinutes !== null) {
+ score += fullAgeMinutes;
+ }
+ // tie-breakers: current fullness and repeat full pattern
+ score += currentLevel;
+ if (freq > 0) score += Math.min(30, freq * 2);
+
+ // reason summary
+ const reasons: string[] = [];
+ reasons.push('Penuh');
+ if (fullAgeMinutes !== null) reasons.push(`Penuh selama ${formatDurationInHours(fullAgeMinutes)}`);
+
+ const priorityLabel: BinPriorityItem['priorityLabel'] = score >= 100 ? 'High' : score >= 60 ? 'Medium' : 'Low';
+
+ return {
+ id: binId,
+ binId,
+ location,
+ capacity: currentLevel,
+ status,
+ priorityScore: Math.round(score),
+ priorityLabel,
+ fullAgeMinutes: fullAgeMinutes ?? null,
+ reason: reasons.join(' • ') || undefined,
+ };
+ }).filter((item) => item !== null) as BinPriorityItem[];
+
+ return items.sort((a, b) => {
+ const aAge = a.fullAgeMinutes ?? -1;
+ const bAge = b.fullAgeMinutes ?? -1;
+ if (bAge !== aAge) return bAge - aAge;
+ return b.priorityScore - a.priorityScore;
+ });
+};
diff --git a/src/lib/services/historyService.ts b/src/lib/services/historyService.ts
new file mode 100644
index 0000000..cc6709f
--- /dev/null
+++ b/src/lib/services/historyService.ts
@@ -0,0 +1,57 @@
+import {
+ collection,
+ onSnapshot,
+ orderBy,
+ query,
+ Timestamp,
+} from 'firebase/firestore';
+import { db } from '@/lib/firebase';
+
+export type HistoryRecord = {
+ id: string;
+ binId: string;
+ location: string;
+ capacity: number;
+ timestamp: Date;
+};
+
+const parseFirestoreDate = (value: Timestamp | Date | string | number | null | undefined): Date => {
+ if (value == null) return new Date();
+ if (value instanceof Timestamp) return value.toDate();
+ if (value instanceof Date) return value;
+ const d = new Date(value);
+ return isNaN(d.getTime()) ? new Date() : d;
+};
+
+
+export const subscribeHistoryRecords = (onData: (data: HistoryRecord[]) => void, onError?: (error: unknown) => void) => {
+ const historyQuery = query(collection(db, 'bin_history'), orderBy('timestamp', 'asc'));
+
+ return onSnapshot(
+ historyQuery,
+ (snapshot) => {
+ const mapped = snapshot.docs.map((historyDoc) => {
+ const data = historyDoc.data();
+ const composedLocation =
+ data.location ||
+ [data.gedung, data.lantai, data.ruang]
+ .filter((p) => p !== undefined && p !== null && String(p).trim() !== '')
+ .join(' - ') ||
+ 'Lokasi Tidak Diketahui';
+
+ return {
+ id: historyDoc.id,
+ binId: data.binId || 'Unknown ID',
+ location: composedLocation,
+ capacity: data.capacity || 0,
+ timestamp: parseFirestoreDate(data.timestamp),
+ };
+ });
+
+ onData(mapped);
+ },
+ (error) => {
+ onError?.(error);
+ }
+ );
+};
diff --git a/src/lib/services/notificationReadService.ts b/src/lib/services/notificationReadService.ts
new file mode 100644
index 0000000..c11b622
--- /dev/null
+++ b/src/lib/services/notificationReadService.ts
@@ -0,0 +1,105 @@
+import { db } from '@/lib/firebase';
+import {
+ collection,
+ doc,
+ setDoc,
+ deleteDoc,
+ getDocs,
+} from 'firebase/firestore';
+
+export const notificationReadService = {
+ // Mark notification as read
+ async markAsRead(notificationId: string) {
+ try {
+ const notifReadRef = doc(
+ db,
+ 'notificationRead',
+ notificationId
+ );
+ await setDoc(notifReadRef, {
+ notificationId,
+ readAt: new Date(),
+ });
+ console.log(`✅ Marked as read: ${notificationId}`);
+ } catch (error) {
+ console.error('Error marking as read:', error);
+ throw error;
+ }
+ },
+
+ // Unmark notification as read (mark as new)
+ async markAsNew(notificationId: string) {
+ try {
+ await deleteDoc(doc(db, 'notificationRead', notificationId));
+ console.log(`✅ Marked as new: ${notificationId}`);
+ } catch (error) {
+ console.error('Error marking as new:', error);
+ throw error;
+ }
+ },
+
+ // Mark all notifications as read
+ async markAllAsRead() {
+ try {
+ // This will be handled by the caller by marking each notification
+ console.log('✅ Marking all notifications as read');
+ } catch (error) {
+ console.error('Error marking all as read:', error);
+ throw error;
+ }
+ },
+
+ // Get all read notification IDs
+ async getReadNotificationIds(): Promise {
+ try {
+ const snapshot = await getDocs(collection(db, 'notificationRead'));
+ return snapshot.docs.map((doc) => doc.data().notificationId);
+ } catch (error) {
+ console.error('Error getting read notifications:', error);
+ return [];
+ }
+ },
+
+ // Subscribe to read notification IDs (real-time)
+ subscribeToReadNotifications(callback: (ids: string[]) => void) {
+ try {
+ const readCollection = collection(db, 'notificationRead');
+ return getDocs(readCollection).then((snapshot) => {
+ const ids = snapshot.docs.map((doc) => doc.data().notificationId);
+ callback(ids);
+
+ // Also set up real-time listener
+ // (Note: getDocs is one-time, for real-time use onSnapshot)
+ return () => {}; // Return empty unsubscribe function for now
+ });
+ } catch (error) {
+ console.error('Error subscribing to read notifications:', error);
+ return () => {};
+ }
+ },
+
+ // Cleanup read notifications for bins that are no longer full
+ async cleanupReadNotificationsForNonFullBins(
+ fullBinIds: Set
+ ): Promise {
+ try {
+ const snapshot = await getDocs(collection(db, 'notificationRead'));
+
+ // Delete read entries for bins that are not full anymore
+ const deletePromises = snapshot.docs.map((docSnapshot) => {
+ const notificationId = docSnapshot.data().notificationId;
+
+ // If this notification ID is not in current full bins, delete it
+ if (!fullBinIds.has(notificationId)) {
+ return deleteDoc(doc(db, 'notificationRead', docSnapshot.id));
+ }
+ return Promise.resolve();
+ });
+
+ await Promise.all(deletePromises);
+ console.log('✅ Cleaned up read notifications for non-full bins');
+ } catch (error) {
+ console.error('Error cleaning up read notifications:', error);
+ }
+ },
+};
diff --git a/src/middleware.ts b/src/middleware.ts
new file mode 100644
index 0000000..0304782
--- /dev/null
+++ b/src/middleware.ts
@@ -0,0 +1,23 @@
+import { getToken } from "next-auth/jwt";
+import { NextResponse } from "next/server";
+import type { NextRequest } from "next/server";
+
+export async function middleware(req: NextRequest) {
+ const token = await getToken({ req });
+
+ if (!token) {
+ return NextResponse.redirect(new URL("/auth/login", req.url));
+ }
+
+ // if (req.nextUrl.pathname.startsWith("/admin")) {
+ // if ((token.user as JWTUser).role !== "admin") {
+ // return NextResponse.redirect(new URL("/", req.url));
+ // }
+ // }
+
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: ["/admin/:path*"],
+};
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index 480e9db..4c6704e 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -3,26 +3,43 @@ import type { AppProps } from "next/app";
import Sidebar from "@/components/layout/sidebar";
import Navbar from "@/components/layout/navbar";
import { useRouter } from "next/router";
+import { SessionProvider } from "next-auth/react";
+import { SearchProvider } from "@/context/SearchContext";
+import { useState } from "react";
export default function App({ Component, pageProps }: AppProps) {
const router = useRouter();
-
- // Jika di halaman login, jangan tampilkan Sidebar & Navbar
- const isLoginPage = router.pathname === "/login";
+ const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
- if (isLoginPage) {
- return ;
- }
+ const disableLayout = ["/auth/login", "/auth/register"];
+ const isDisable = disableLayout.includes(router.pathname);
return (
-
-
-
-
-
+
+
+ {isDisable ? (
+ // tanpa layout (login/register)
-
-
-
+ ) : (
+ // pakai layout responsif
+
+
setIsMobileNavOpen(false)} />
+ {isMobileNavOpen && (
+ setIsMobileNavOpen(false)}
+ className="fixed inset-0 z-30 bg-slate-900/40 lg:hidden"
+ />
+ )}
+
+ setIsMobileNavOpen(true)} />
+
+
+
+
+
+ )}
+
+
);
-}
\ No newline at end of file
+}
diff --git a/src/pages/api/auth/[...nextauth].ts b/src/pages/api/auth/[...nextauth].ts
new file mode 100644
index 0000000..c8b1afd
--- /dev/null
+++ b/src/pages/api/auth/[...nextauth].ts
@@ -0,0 +1,152 @@
+import NextAuth, { NextAuthOptions } from "next-auth"; // ✅ Tambah import NextAuthOptions
+import CredentialsProvider from "next-auth/providers/credentials";
+import GoogleProvider from "next-auth/providers/google";
+import type { DefaultSession } from "next-auth";
+
+import { db } from "@/lib/firebase";
+import { collection, query, where, getDocs, addDoc } from "firebase/firestore";
+
+import bcrypt from "bcryptjs";
+
+declare module "next-auth" {
+ interface User {
+ role?: string;
+ }
+ interface Session {
+ user: {
+ id?: string;
+ role?: string;
+ } & DefaultSession["user"];
+ }
+}
+
+// ✅ 1. Ekstrak konfigurasi ke variabel bernama authOptions dan berikan keyword 'export'
+export const authOptions: NextAuthOptions = {
+ providers: [
+ CredentialsProvider({
+ name: "Credentials",
+ credentials: {
+ email: {},
+ password: {},
+ },
+
+ async authorize(credentials) {
+ if (!credentials?.email || !credentials?.password) return null;
+
+ const q = query(collection(db, "users"), where("email", "==", credentials.email));
+
+ const snapshot = await getDocs(q);
+ if (snapshot.empty) return null;
+
+ const docSnap = snapshot.docs[0];
+ const userData = docSnap.data();
+
+ // ❗ kalau user Google
+ if (!userData.password) return null;
+
+ const isValid = await bcrypt.compare(credentials.password, userData.password);
+
+ if (!isValid) return null;
+
+ return {
+ id: docSnap.id,
+ name: userData.fullName,
+ email: userData.email,
+ role: userData.role, // ✅ ini penting
+ };
+ },
+ }),
+
+ GoogleProvider({
+ clientId: process.env.GOOGLE_CLIENT_ID || "",
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
+ }),
+ ],
+
+ pages: {
+ signIn: "/auth/login",
+ },
+
+ session: {
+ strategy: "jwt",
+ },
+
+ secret: process.env.NEXTAUTH_SECRET,
+
+ callbacks: {
+ // 🔥 simpan user ke token
+ async jwt({ token, user, account, trigger, session }) {
+ if (trigger === "update" && session) {
+ const updatedName = session.name ?? session.user?.name;
+ if (updatedName) {
+ token.name = updatedName;
+ }
+ }
+
+ // login credentials
+ if (user) {
+ token.id = user.id;
+ token.name = user.name;
+ token.email = user.email;
+ token.role = user.role;
+ }
+
+ // login Google
+ if (account?.provider === "google") {
+ const q = query(collection(db, "users"), where("email", "==", token.email));
+
+ const snapshot = await getDocs(q);
+
+ let dbUser;
+
+ if (snapshot.empty) {
+ const newUser = await addDoc(collection(db, "users"), {
+ email: token.email,
+ fullName: token.name,
+ password: null,
+ role: "petugas",
+ createdAt: new Date(),
+ });
+
+ dbUser = {
+ id: newUser.id,
+ name: token.name,
+ email: token.email,
+ role: "petugas",
+ };
+ } else {
+ const docSnap = snapshot.docs[0];
+ const data = docSnap.data();
+
+ dbUser = {
+ id: docSnap.id,
+ name: data.fullName,
+ email: data.email,
+ role: data.role,
+ };
+ }
+
+ token.id = dbUser.id;
+ token.name = dbUser.name;
+ token.email = dbUser.email;
+ token.role = dbUser.role;
+ }
+
+ return token;
+ },
+
+ // 🔥 ambil ke session (simple dulu)
+ async session({ session, token }) {
+ if (session.user) {
+ session.user.id = token.id as string;
+ session.user.name = token.name as string;
+ session.user.email = token.email as string;
+ session.user.role = token.role as string;
+ }
+ return session;
+ },
+ },
+};
+
+// ✅ 2. Jalankan fungsi NextAuth menggunakan objek konfigurasi di atas
+export default NextAuth(authOptions);
\ No newline at end of file
diff --git a/src/pages/api/change-password.ts b/src/pages/api/change-password.ts
new file mode 100644
index 0000000..2a34b32
--- /dev/null
+++ b/src/pages/api/change-password.ts
@@ -0,0 +1,76 @@
+import { NextApiRequest, NextApiResponse } from "next";
+import { getServerSession } from "next-auth/next";
+import { db } from "@/lib/firebase";
+import { doc, getDoc, updateDoc, collection, query, where, getDocs, type DocumentReference, type DocumentData } from "firebase/firestore";
+import bcrypt from "bcryptjs";
+import { authOptions } from "./auth/[...nextauth]";// FIX: Diubah menjadi default import (tanpa kurung kurawal)
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const session = await getServerSession(req, res, authOptions);
+
+ if (!session || !session.user?.email) {
+ return res.status(401).json({ message: "Unauthorized" });
+ }
+
+ if (req.method !== "POST") {
+ res.setHeader("Allow", ["POST"]);
+ return res.status(405).end(`Method ${req.method} Not Allowed`);
+ }
+
+ try {
+ const { currentPassword, newPassword } = req.body;
+
+ // Prefer using session user id (document id). If not available, try to find user doc by email.
+ const userId = (session.user as { id?: string }).id;
+ let userDocRef: DocumentReference;
+ if (userId) {
+ userDocRef = doc(db, "users", userId);
+ } else {
+ const q = query(collection(db, "users"), where("email", "==", session.user.email));
+ const snapshot = await getDocs(q);
+ if (!snapshot.empty) {
+ userDocRef = snapshot.docs[0].ref;
+ } else {
+ // Fallback to using email as doc id (legacy)
+ userDocRef = doc(db, "users", session.user.email as string);
+ }
+ }
+
+ if (!currentPassword || !newPassword) {
+ return res.status(400).json({ message: "Password lama dan baru harus diisi" });
+ }
+
+ // Fetch current user document
+ const userSnap = await getDoc(userDocRef);
+ if (!userSnap.exists()) {
+ return res.status(404).json({ message: "User tidak ditemukan" });
+ }
+
+ const userData = userSnap.data();
+
+ // For Google OAuth users (no password), prevent password change
+ if (!userData.password) {
+ return res.status(400).json({ message: "Akun Anda menggunakan Google Sign-In. Tidak dapat mengubah password." });
+ }
+
+ // Verify current password
+ const isValid = await bcrypt.compare(currentPassword, userData.password);
+ if (!isValid) {
+ return res.status(401).json({ message: "Password saat ini salah" });
+ }
+
+ // Hash new password
+ const hashedPassword = await bcrypt.hash(newPassword, 10);
+
+ // Update password in Firestore
+ await updateDoc(userDocRef, {
+ password: hashedPassword,
+ updatedAt: new Date(),
+ });
+
+ return res.status(200).json({ ok: true, message: "Password berhasil diubah" });
+ } catch (error) {
+ console.error("Change password error:", error);
+ return res.status(500).json({ message: "Gagal mengubah password" });
+ }
+}
\ No newline at end of file
diff --git a/src/pages/api/profile.ts b/src/pages/api/profile.ts
new file mode 100644
index 0000000..8a85243
--- /dev/null
+++ b/src/pages/api/profile.ts
@@ -0,0 +1,86 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import { getServerSession } from "next-auth/next";
+import { db } from "@/lib/firebase";
+import { doc, getDoc, setDoc, serverTimestamp, collection, query, where, getDocs, type DocumentReference, type DocumentData } from "firebase/firestore";
+import { authOptions } from "./auth/[...nextauth]"; // 1. TAMBAHKAN IMPORT AUTHOPTIONS DI SINI
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ // 2. MASUKKAN authOptions sebagai argumen ketiga
+ const session = await getServerSession(req, res, authOptions);
+
+ if (!session || !session.user?.email) {
+ return res.status(401).json({ message: "Unauthorized" });
+ }
+
+ // Determine user document reference. Prefer session user id (doc id), else try to find by email.
+ const userId = (session.user as { id?: string }).id;
+ let userDocRef: DocumentReference;
+ if (userId) {
+ userDocRef = doc(db, "users", userId);
+ } else {
+ const q = query(collection(db, "users"), where("email", "==", session.user.email));
+ const snapshot = await getDocs(q);
+ if (!snapshot.empty) {
+ userDocRef = snapshot.docs[0].ref;
+ } else {
+ // Fallback to email as doc id (legacy)
+ userDocRef = doc(db, "users", session.user.email as string);
+ }
+ }
+
+ if (req.method === "GET") {
+ try {
+ const userSnap = await getDoc(userDocRef);
+ if (userSnap.exists()) {
+ const data = userSnap.data();
+ return res.status(200).json({ user: {
+ ...data,
+ name: data.fullName || data.name || "",
+ } });
+ } else {
+ // Return session user if no doc exists yet
+ return res.status(200).json({
+ user: {
+ email: session.user.email,
+ name: session.user.name,
+ image: session.user.image,
+ phone: null,
+ address: null,
+ }
+ });
+ }
+ } catch (error) {
+ console.error("GET profile error:", error);
+ return res.status(500).json({ message: "Failed to fetch profile" });
+ }
+ }
+
+ if (req.method === "PUT") {
+ try {
+ const { name, phone } = req.body || {};
+
+ // Get existing data first
+ const userSnap = await getDoc(userDocRef);
+ const existingData = userSnap.exists() ? userSnap.data() : {};
+
+ const userData = {
+ ...existingData,
+ fullName: name || existingData.fullName || session.user.name,
+ email: session.user.email,
+ phone: phone !== undefined ? phone : (existingData.phone || null),
+ updatedAt: serverTimestamp(),
+ };
+
+ // Always use setDoc with merge to ensure data is saved
+ await setDoc(userDocRef, userData, { merge: true });
+
+ return res.status(200).json({ ok: true, user: { ...userData, name: userData.fullName } });
+ } catch (error) {
+ console.error("PUT profile error:", error);
+ return res.status(500).json({ message: "Failed to update profile" });
+ }
+ }
+
+ res.setHeader("Allow", ["GET", "PUT"]);
+ res.status(405).end(`Method ${req.method} Not Allowed`);
+}
\ No newline at end of file
diff --git a/src/pages/api/register.ts b/src/pages/api/register.ts
new file mode 100644
index 0000000..e2b14e0
--- /dev/null
+++ b/src/pages/api/register.ts
@@ -0,0 +1,45 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+import { db } from "@/lib/firebase";
+import { collection, addDoc, query, where, getDocs } from "firebase/firestore";
+import bcrypt from "bcryptjs";
+
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ if (req.method !== "POST") {
+ return res.status(405).json({ message: "Method not allowed" });
+ }
+
+ const { email, password, fullName } = req.body;
+
+ // ❗ paksa role
+ const userRole = "petugas";
+
+ // validasi
+ if (!email || !password || !fullName) {
+ return res.status(400).json({ message: "Data tidak lengkap" });
+ }
+
+ try {
+ // cek email sudah ada
+ const q = query(collection(db, "users"), where("email", "==", email));
+ const snapshot = await getDocs(q);
+
+ if (!snapshot.empty) {
+ return res.status(400).json({ message: "Email sudah digunakan" });
+ }
+
+ const hashedPassword = await bcrypt.hash(password, 10);
+
+ await addDoc(collection(db, "users"), {
+ email,
+ fullName,
+ password: hashedPassword,
+ role: userRole, // ✅ aman
+ createdAt: new Date(),
+ });
+
+ return res.status(200).json({ message: "Register berhasil" });
+ } catch (error) {
+ console.log(error);
+ return res.status(500).json({ message: "Server error" });
+ }
+}
\ No newline at end of file
diff --git a/src/pages/auth/login/index.tsx b/src/pages/auth/login/index.tsx
new file mode 100644
index 0000000..2fe3a37
--- /dev/null
+++ b/src/pages/auth/login/index.tsx
@@ -0,0 +1,5 @@
+import LoginView from "@/views/auth/login";
+
+export default function LoginPage() {
+ return ;
+}
diff --git a/src/pages/auth/register/index.tsx b/src/pages/auth/register/index.tsx
new file mode 100644
index 0000000..7e97d20
--- /dev/null
+++ b/src/pages/auth/register/index.tsx
@@ -0,0 +1,5 @@
+import RegisterView from "@/views/auth/register";
+
+export default function RegisterPage() {
+ return ;
+}
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index c26daf4..c6baf95 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -1,18 +1,19 @@
-// src/pages/index.tsx
-import DashboardView from '@/views/dashboard';
-import Sidebar from '@/components/layout/sidebar'; // Pastikan sudah buat Sidebar
-import Navbar from '@/components/layout/navbar'; // Pastikan sudah buat Navbar
+import DashboardView from "@/views/dashboard";
+import { useSession } from "next-auth/react";
+import { useRouter } from "next/router";
+import { useEffect } from "react";
export default function DashboardPage() {
- return (
-
-
- {/* Konten Utama */}
-
-
-
-
-
-
- );
-}
\ No newline at end of file
+ const { status } = useSession();
+ const router = useRouter();
+
+ useEffect(() => {
+ if (status === "unauthenticated") {
+ router.push("/auth/login");
+ }
+ }, [status, router]);
+
+ if (status === "loading") return Loading...
;
+
+ return ;
+}
diff --git a/src/pages/manage/index.tsx b/src/pages/manage/index.tsx
new file mode 100644
index 0000000..e814d79
--- /dev/null
+++ b/src/pages/manage/index.tsx
@@ -0,0 +1,13 @@
+import ManageView from '@/views/manage';
+import Head from 'next/head';
+
+export default function ManagePage() {
+ return (
+ <>
+
+ Manage Bin | Trashware Monitoring
+
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/src/pages/monitoring/[id].tsx b/src/pages/monitoring/[id].tsx
new file mode 100644
index 0000000..42e4d13
--- /dev/null
+++ b/src/pages/monitoring/[id].tsx
@@ -0,0 +1,12 @@
+import { useRouter } from 'next/router';
+import ActionView from '@/views/monitoring/ActionView';
+
+export default function ActionPage() {
+ const router = useRouter();
+ const { id } = router.query;
+
+ // Pastikan ID sudah ter-load dari router sebelum merender view
+ if (!id) return null;
+
+ return ;
+}
\ No newline at end of file
diff --git a/src/pages/monitoring/index.tsx b/src/pages/monitoring/index.tsx
new file mode 100644
index 0000000..d42683c
--- /dev/null
+++ b/src/pages/monitoring/index.tsx
@@ -0,0 +1,5 @@
+import MonitoringView from '@/views/monitoring';
+
+export default function MonitoringPage() {
+ return ;
+}
diff --git a/src/pages/notifications/index.tsx b/src/pages/notifications/index.tsx
new file mode 100644
index 0000000..a0c39f2
--- /dev/null
+++ b/src/pages/notifications/index.tsx
@@ -0,0 +1,5 @@
+import NotificationsView from '@/views/notifications';
+
+export default function NotificationsPage() {
+ return ;
+}
diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx
new file mode 100644
index 0000000..c879f04
--- /dev/null
+++ b/src/pages/profile/index.tsx
@@ -0,0 +1,104 @@
+'use client';
+
+import { useState, useEffect, useCallback } from "react";
+import { useSession } from "next-auth/react";
+import ProfileModal from "@/components/profile/ProfileModal";
+import ChangePasswordModal from "@/components/profile/ChangePasswordModal";
+import Image from "next/image";
+
+type ProfileShape = {
+ name?: string;
+ email?: string;
+ image?: string | null;
+ phone?: string | null;
+};
+
+export default function ProfilePage() {
+ const { data: session } = useSession();
+ const [open, setOpen] = useState(false);
+ const [passwordOpen, setPasswordOpen] = useState(false);
+ const [profile, setProfile] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ const fetchProfile = useCallback(async () => {
+ setLoading(true);
+ try {
+ const res = await fetch("/api/profile", {
+ method: "GET",
+ cache: "no-store",
+ headers: { "Cache-Control": "no-cache" }
+ });
+ if (res.ok) {
+ const data = await res.json();
+ setProfile(data.user);
+ }
+ } catch (err) {
+ console.error("Failed to fetch profile:", err);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (session?.user?.email) {
+ fetchProfile();
+ }
+ }, [session, fetchProfile]);
+
+ const profileData = (profile || session?.user) as ProfileShape | undefined;
+
+ return (
+
+
+ {loading ? (
+
+ ) : (
+ <>
+
+ {profileData?.image ? (
+
+ ) : (
+
+ {(profileData?.name || "")
+ .split(" ")
+ .filter(Boolean)
+ .map((n: string) => n[0])
+ .slice(0, 2)
+ .join("")
+ .toUpperCase()}
+
+ )}
+
+
{profileData?.name}
+
{profileData?.email}
+ {profileData?.phone &&
Telepon: {profileData.phone}
}
+
+
+
+
+ setOpen(true)} className="rounded-xl bg-emerald-600 px-4 py-2.5 text-xs font-semibold text-white transition-colors hover:bg-emerald-700 sm:px-6 sm:py-3 sm:text-sm">
+ Edit Profile
+
+ setPasswordOpen(true)} className="rounded-xl border border-gray-200 px-4 py-2.5 text-xs font-semibold text-gray-700 transition-colors hover:bg-gray-50 sm:px-6 sm:py-3 sm:text-sm">
+ Ganti Password
+
+
+ >
+ )}
+
+
+
setOpen(false)} onSave={fetchProfile} />
+ setPasswordOpen(false)} />
+
+ );
+}
\ No newline at end of file
diff --git a/src/types/database.ts b/src/types/database.ts
new file mode 100644
index 0000000..76c391e
--- /dev/null
+++ b/src/types/database.ts
@@ -0,0 +1,23 @@
+import type { Timestamp } from "firebase/firestore";
+
+export interface Bin {
+ id: string;
+ gedung: string;
+ lantai: string;
+ ruang: string;
+ capacity: number; // Diubah ke number sesuai di gambar (100)
+ distance: number; // BARU: Ditambahkan sesuai di gambar (13)
+ level: number; // Berwujud angka (46)
+ status: 'on' | 'off'; // Berwujud string ("on")
+ lastUpdate?: Timestamp | Date | string | number | null;
+}
+
+export interface TrashHistory {
+ binId: string;
+ officerName: string;
+ officerEmail: string;
+ location: string;
+ levelCaptured: number;
+ status: 'Approved' | 'Rejected';
+ timestamp: Timestamp | Date | string | number;
+}
\ No newline at end of file
diff --git a/src/views/auth/login/index.tsx b/src/views/auth/login/index.tsx
new file mode 100644
index 0000000..157ddf8
--- /dev/null
+++ b/src/views/auth/login/index.tsx
@@ -0,0 +1,148 @@
+import React from "react";
+// import { Leaf } from 'lucide-react';
+import { useRouter } from "next/router";
+import { signIn } from "next-auth/react";
+import { useState } from "react";
+import Link from "next/link";
+import { FcGoogle } from "react-icons/fc";
+import Image from "next/image";
+
+export default function LoginPage() {
+ const router = useRouter();
+
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+
+ const handleLogin = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ const res = await signIn("credentials", {
+ redirect: false,
+ email,
+ password,
+ });
+
+ if (res?.ok) {
+ router.push("/"); // redirect ke dashboard/home
+ } else {
+ alert("Login gagal!");
+ }
+ };
+ return (
+
+ {/* Container Utama */}
+
+ {/* Kolom Kiri */}
+
+ {/* Logo */}
+
+
+
+
+
Trashware
+
+
Monitoring System
+
+
+
+ {/* Hidden di mobile */}
+
+
Smart Waste Management
+
+
Sistem pemantauan tempat sampah berbasis IoT untuk kampus yang lebih bersih dan berkelanjutan
+
+ {/* Kartu fitur */}
+
+
+
24/7
+
+
Real-time Monitoring
+
+
+
+
IoT
+
+
Smart Integration
+
+
+
+
+
+ {/* Kolom Kanan */}
+
+
+
+
Login
+
+
Masuk ke sistem monitoring Trashware
+
+
+
+
+
+
+
+ {/* Footer */}
+
+
Do not sell or share my personal info
+
+
+ );
+}
diff --git a/src/views/auth/register/index.tsx b/src/views/auth/register/index.tsx
new file mode 100644
index 0000000..e756d7d
--- /dev/null
+++ b/src/views/auth/register/index.tsx
@@ -0,0 +1,152 @@
+import React, { useState } from "react";
+import { useRouter } from "next/router";
+import Link from "next/link";
+import { FcGoogle } from "react-icons/fc";
+import { signIn } from "next-auth/react";
+import Image from "next/image";
+
+export default function RegisterPage() {
+ const router = useRouter();
+
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [fullName, setFullName] = useState("");
+
+ const handleRegister = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ const res = await fetch("/api/register", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ email, password, fullName }),
+ });
+
+ const data = await res.json();
+
+ if (res.ok) {
+ alert("Register berhasil!");
+ router.push("/auth/login");
+ } else {
+ alert(data.message);
+ }
+ };
+
+ return (
+
+
+ {/* KIRI (SAMA PERSIS LOGIN) */}
+
+ {/* Logo tetap tampil */}
+
+
+
+
+
Trashware
+
+
Monitoring System
+
+
+
+ {/* Hidden di mobile */}
+
+
Smart Waste Management
+
+
Sistem pemantauan tempat sampah berbasis IoT untuk kampus yang lebih bersih dan berkelanjutan
+
+
+
+
24/7
+
+
Real-time Monitoring
+
+
+
+
IoT
+
+
Smart Integration
+
+
+
+
+ {/* KANAN (FORM REGISTER) */}
+
+
+
+
Register
+
Daftar akun baru
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/views/dashboard/index.tsx b/src/views/dashboard/index.tsx
index 30286a8..50babb4 100644
--- a/src/views/dashboard/index.tsx
+++ b/src/views/dashboard/index.tsx
@@ -1,140 +1,158 @@
+'use client';
+
import { useState, useEffect } from 'react';
-import { db } from '@/lib/firebase'; // Pastikan path config firebase benar
+import PageContainer from '@/components/layout/PageContainer';
+import PageHeader from '@/components/layout/PageHeader';
+import { db } from '@/lib/firebase';
import { collection, onSnapshot, query } from 'firebase/firestore';
import StatCard from '@/components/dashboard/StatCard';
import { FiTrash2, FiCheckCircle, FiAlertTriangle, FiXCircle, FiWifiOff } from 'react-icons/fi';
+import { formatDurationInHours, getPickupPriorityRanking } from '@/lib/services/historyAnalyticsService';
+import { subscribeHistoryRecords } from '@/lib/services/historyService';
+import type { HistoryRecord } from '@/lib/services/historyService';
+import type { Bin } from '@/types/database'; // ✅ Gunakan tipe data Bin murni dari database
+import { binService } from '@/lib/services/binService'; // ✅ 1. IMPORT SERVICE SINKRONISASI DI SINI
export default function DashboardView() {
- const [bins, setBins] = useState([]);
+ // FIX: Ubah tipe data state menjadi Bin[] agar aman dari Type Error TypeScript
+ const [bins, setBins] = useState([]);
const [loading, setLoading] = useState(true);
- const [prevCounts, setPrevCounts] = useState({});
+ const [historyRecords, setHistoryRecords] = useState([]);
useEffect(() => {
const q = query(collection(db, "bins"));
-
const unsubscribe = onSnapshot(q, (snapshot) => {
- const binsData = snapshot.docs.map(doc => ({
- id: doc.id,
- ...doc.data()
- }));
-
- setPrevCounts((prev: any) => ({
- total: bins.length,
- empty: bins.filter(b => b.capacity < 20).length,
- nearlyFull: bins.filter(b => b.capacity >= 70 && b.capacity < 90).length,
- full: bins.filter(b => b.capacity >= 90).length,
- }));
-
+ // ✅ 2. KOREKSI STATUS DI PINTU GERBANG FIRESTORE (Jika > 8 detik mati, paksa status 'off')
+ const binsData = snapshot.docs.map(doc => {
+ const rawBin = { id: doc.id, ...doc.data() } as Bin;
+ return binService.enforceHeartbeat(rawBin);
+ });
+
setBins(binsData);
setLoading(false);
});
-
return () => unsubscribe();
- });
+ }, []);
+
+ useEffect(() => {
+ const unsub = subscribeHistoryRecords((data) => setHistoryRecords(data), (err) => console.error(err));
+ return () => unsub();
+ }, []);
+ // LOGIKA HITUNG STATISTIK - Otomatis Akurat karena status bins sudah diproses binService
const stats = {
total: bins.length,
- empty: bins.filter(bin => bin.capacity < 20).length,
- nearlyFull: bins.filter(bin => bin.capacity >= 70 && bin.capacity < 90).length,
- full: bins.filter(bin => bin.capacity >= 90).length,
- offline: bins.filter(bin => bin.isOffline).length,
+ empty: bins.filter(b => b.status === 'on' && (b.level ?? 0) < 20).length,
+ nearlyFull: bins.filter(b => b.status === 'on' && (b.level ?? 0) >= 70 && (b.level ?? 0) < 90).length,
+ full: bins.filter(b => b.status === 'on' && (b.level ?? 0) >= 90).length,
+ offline: bins.filter(b => b.status === 'off').length, // Otomatis bertambah jika alat mati suri
};
- const getTrend = (current: number, previous: number) => {
- const diff = current - previous;
- if (diff > 0) return `+${diff}`;
- if (diff < 0) return `${diff}`;
- return "0";
- };
-
- if (loading) return Loading Dashboard...
;
+ if (loading) return (
+
+
+
+ );
return (
-
- {/* Header Section */}
-
-
-
Dashboard Overview
-
Real-time monitoring from Firestore
-
-
-
- System Online
-
-
+
+
- {/* Stats Grid - Otomatis terupdate dari DB */}
-
-
} />
- } />
- } />
- } />
- } />
+ {/* 1. StatCard */}
+
+ } />
+ } />
+ } />
+ } />
+ } />
- {/* Main Content: Chart & Alerts */}
-
-
-
Daily Fill Trend
-
Grafik akan muncul di sini setelah integrasi Recharts
-
-
-
-
-
Recent Alerts
-
- {stats.full} ACTIVE
-
+ {/* 2. Banner Perhatian */}
+ {(stats.full > 0 || stats.nearlyFull > 0) && (
+
+
+
+ Perhatian! {stats.full} tempat sampah penuh, {stats.nearlyFull} hampir penuh
- {/* Menampilkan alert jika ada bin yang Full (>= 90%) */}
- {bins.filter(b => b.capacity >= 90).map(bin => (
-
-
Bin Full - {bin.id}
-
{bin.location} reaches {bin.capacity}%
-
- ))}
+
Segera bersihkan untuk menjaga kebersihan kampus.
-
+ )}
- {/* Table Section - Otomatis menampilkan data dari DB */}
-
-
-
Real-time Bin Status
-
Live data
+ {/* 3. Prioritas Pengambilan - Leaderboard */}
+
+
+
Prioritas Pengambilan
+
Berdasarkan lamanya sampah penuh dan belum diambil
-
-
-
- Bin ID
- Location
- Capacity
- Status
-
-
-
- {bins.map((bin) => (
-
- {bin.id}
- {bin.location || 'Unknown'}
-
-
-
90 ? 'bg-red-500' : bin.capacity > 70 ? 'bg-orange-500' : 'bg-green-500'}`}
- style={{ width: `${bin.capacity}%` }}
- >
+
+
+ {(() => {
+ // Algoritma ranking otomatis menggunakan data bins yang statusnya sudah valid
+ const ranking = getPickupPriorityRanking(bins, historyRecords).slice(0, 10);
+
+ if (!ranking || ranking.length === 0) return (
+
Tidak ada prioritas saat ini
+ );
+
+ return ranking.map((r, idx) => (
+
+ {/* Rank Badge */}
+
+ #{idx + 1}
+
+
+ {/* Location & Details */}
+
+
{r.location}
+
ID: {r.binId}
+
+ {/* Capacity Bar */}
+
+
+ Level Isi
+ {r.capacity}%
+
+
+
+
+ {/* Full Age */}
+ {r.fullAgeMinutes != null && (
+
+ Penuh selama: {formatDurationInHours(r.fullAgeMinutes)}
+
+ )}
+
+
+ {/* Priority Label */}
+
+
+ {r.priorityLabel}
-
{bin.capacity}%
-
-
- = 90 ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'}`}>
- ● {bin.capacity >= 90 ? 'Full' : bin.capacity >= 70 ? 'Nearly Full' : 'Empty'}
-
-
-
- ))}
-
-
+
+
+ ));
+ })()}
+
-
+
);
}
\ No newline at end of file
diff --git a/src/views/manage/index.tsx b/src/views/manage/index.tsx
new file mode 100644
index 0000000..f519541
--- /dev/null
+++ b/src/views/manage/index.tsx
@@ -0,0 +1,215 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import PageContainer from '@/components/layout/PageContainer';
+import PageHeader from '@/components/layout/PageHeader';
+import { db } from '@/lib/firebase';
+import { collection, onSnapshot, query, deleteDoc, doc } from 'firebase/firestore';
+import { useSearch } from '@/context/SearchContext';
+import { FiPlus, FiEdit2, FiTrash2 } from 'react-icons/fi';
+import ManageBin, { BinData } from '@/components/manage/ManageBin';
+import { binService } from '@/lib/services/binService'; // ✅ 1. IMPORT SERVICE JANTUNG DI SINI
+
+export default function ManageView() {
+ const [bins, setBins] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [isModalOpen, setModalOpen] = useState(false);
+ const [selectedBin, setSelectedBin] = useState(null);
+ const { searchQuery } = useSearch();
+
+ useEffect(() => {
+ const q = query(collection(db, "bins"));
+ const unsubscribe = onSnapshot(q, (snapshot) => {
+ // ✅ 2. BUNGKUS DENGAN ENFORCEHEARTBEAT DI PINTU GERBANG FIRESTORE
+ const binsData = snapshot.docs.map(doc => {
+ // FIX: Mengganti 'as any' menjadi 'as BinData' untuk menjamin Type Safety
+ const rawBin = { firestoreId: doc.id, ...doc.data() } as BinData;
+ const processedBin = binService.enforceHeartbeat(rawBin);
+
+ return {
+ ...processedBin,
+ firestoreId: doc.id // Pastikan ID dokumen Firestore tetap menempel untuk keperluan Delete/Edit
+ } as BinData;
+ });
+
+ setBins(binsData);
+ setLoading(false);
+ });
+ return () => unsubscribe();
+ }, []);
+
+ const handleDelete = async (id: string) => {
+ if (confirm("Hapus data bin ini?")) {
+ await deleteDoc(doc(db, "bins", id));
+ }
+ };
+
+ // LOGIKA FILTER PENCARIAN - Tetap Asli
+ const filteredBins = bins.filter((bin) => {
+ const searchTarget = `${bin.ruang} ${bin.lantai} ${bin.gedung} ${bin.id}`.toLowerCase();
+ return searchTarget.includes(searchQuery.toLowerCase());
+ });
+
+ if (loading) return (
+
+
+ Menghubungkan Database...
+
+
+ );
+
+ return (
+
+ {/* Header Section */}
+
+
+
{ setSelectedBin(null); setModalOpen(true); }}
+ className="flex w-full items-center justify-center gap-2 bg-[#00D26A] text-white px-4 py-2 rounded-xl text-sm font-bold shadow-lg shadow-emerald-50 hover:bg-[#00b95d] transition-all active:scale-95 sm:w-auto sm:px-5 sm:py-2.5"
+ >
+ Tambah Bin
+
+
+
+ {/* Table Section (Desktop) */}
+
+
+
+
+
+ ID
+ Gedung
+ Lantai
+ Ruang
+ Kapasitas
+ Level Isi
+ Status Isi
+ Device
+ Aksi
+
+
+
+ {filteredBins.map((bin) => {
+ const level = bin.level || 0;
+ // Status ini otomatis akurat (menjadi false jika IoT mati lebih dari 8 detik)
+ const isOnline = bin.status === 'on';
+
+ return (
+
+ {bin.id}
+ {bin.gedung}
+ {bin.lantai}
+ {bin.ruang}
+ {bin.capacity}L
+ {level}%
+
+
+ = 90 ? 'bg-[#FF3B30]' :
+ level >= 70 ? 'bg-[#FFCC00]' :
+ level > 0 ? 'bg-[#00D26A]' : 'bg-gray-400'
+ }`}>
+ {level >= 90 ? 'Penuh' : level >= 70 ? 'Hampir Penuh' : level > 0 ? 'Terisi' : 'Kosong'}
+
+
+
+
+
+ {isOnline ? 'ON' : 'OFF'}
+
+
+
+
+
+ { setSelectedBin(bin); setModalOpen(true); }} className="p-2 text-gray-400 hover:text-blue-500 rounded-xl transition-all">
+ handleDelete(bin.firestoreId!)} className="p-2 text-gray-400 hover:text-red-500 rounded-xl transition-all">
+
+
+
+ );
+ })}
+
+
+ {filteredBins.length === 0 && (
+
+ Data bin tidak ditemukan.
+
+ )}
+
+
+
+ {/* Card Section (Mobile) */}
+
+ {filteredBins.length === 0 ? (
+
+ Data bin tidak ditemukan.
+
+ ) : (
+ filteredBins.map((bin) => {
+ const level = bin.level || 0;
+ const isOnline = bin.status === 'on';
+
+ return (
+
+
+
+
{bin.id}
+
{bin.gedung}
+
{bin.lantai} · {bin.ruang}
+
+
+ {isOnline ? 'ON' : 'OFF'}
+
+
+
+
+
+
Kapasitas
+
{bin.capacity}L
+
+
+
+
+
+
= 90 ? 'bg-[#FF3B30]' :
+ level >= 70 ? 'bg-[#FFCC00]' :
+ level > 0 ? 'bg-[#00D26A]' : 'bg-gray-400'
+ }`}>
+ {level >= 90 ? 'Penuh' : level >= 70 ? 'Hampir Penuh' : level > 0 ? 'Terisi' : 'Kosong'}
+
+
+
+ { setSelectedBin(bin); setModalOpen(true); }} className="rounded-xl p-1.5 text-gray-400 transition-all hover:text-blue-500">
+
+
+ handleDelete(bin.firestoreId!)} className="rounded-xl p-1.5 text-gray-400 transition-all hover:text-red-500">
+
+
+
+
+
+ );
+ })
+ )}
+
+
+ setModalOpen(false)}
+ editData={selectedBin}
+ />
+
+ );
+}
\ No newline at end of file
diff --git a/src/views/monitoring/ActionView.tsx b/src/views/monitoring/ActionView.tsx
new file mode 100644
index 0000000..653f883
--- /dev/null
+++ b/src/views/monitoring/ActionView.tsx
@@ -0,0 +1,246 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { useSession } from 'next-auth/react';
+import { db } from '@/lib/firebase';
+import {
+ doc,
+ getDoc,
+ onSnapshot,
+ addDoc,
+ collection,
+ serverTimestamp,
+ updateDoc
+} from 'firebase/firestore';
+import { Bin, TrashHistory } from '@/types/database';
+import PageContainer from '@/components/layout/PageContainer';
+import PageHeader from '@/components/layout/PageHeader';
+import {
+ FiUser,
+ FiTrash2,
+ FiCheckCircle,
+ FiAlertCircle,
+ FiLoader,
+ FiArrowLeft,
+ FiMapPin,
+ FiBarChart2
+} from 'react-icons/fi';
+import { useRouter } from 'next/router';
+import { binService } from '@/lib/services/binService';
+
+export default function ActionView({ id }: { id: string }) {
+ const router = useRouter();
+ const { data: session } = useSession();
+
+ // State Data
+ const [binData, setBinData] = useState(null);
+ const [initialLevel, setInitialLevel] = useState(null);
+
+ // State UI
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
+
+ // 1. Ambil Level Awal (Snapshot saat pertama kali halaman dibuka)
+ useEffect(() => {
+ if (!id) return;
+ const fetchInitial = async () => {
+ const docRef = doc(db, "bins", id);
+ const snap = await getDoc(docRef);
+ if (snap.exists()) {
+ setInitialLevel(Number(snap.data().level));
+ }
+ };
+ fetchInitial();
+ }, [id]);
+
+ // 2. Real-time Listener (Pantau perubahan sensor selama petugas di lokasi)
+ useEffect(() => {
+ if (!id) return;
+ const unsubscribe = onSnapshot(doc(db, "bins", id), (docSnap) => {
+ if (docSnap.exists()) {
+ const rawBin = { id: docSnap.id, ...docSnap.data() } as Bin;
+
+ // ✅ 2. SINKRONISASI STATUS DI SINI (Memastikan status 'on'/'off' jujur dalam hitungan detik)
+ setBinData(binService.enforceHeartbeat(rawBin));
+ }
+ });
+ return () => unsubscribe();
+ }, [id]);
+
+ const handleApproveAction = async () => {
+ // Validasi dasar
+ if (!session?.user || !binData || initialLevel === null) return;
+
+ setIsProcessing(true);
+ setMessage(null);
+
+ try {
+ // Ambil data paling fresh dari Firestore untuk validasi akhir
+ const binRef = doc(db, "bins", id);
+ const freshSnap = await getDoc(binRef);
+
+ if (!freshSnap.exists()) throw new Error("Unit tidak ditemukan");
+
+ const currentLevel = Number(freshSnap.data().level);
+
+ // LOGIKA 1: Mencegah Fraud (Level harus berubah dari awal datang)
+ if (currentLevel === initialLevel) {
+ setMessage({
+ type: 'error',
+ text: `Gagal! Level masih tetap ${currentLevel}%. Sensor tidak mendeteksi adanya aktivitas pengosongan.`
+ });
+ setIsProcessing(false);
+ return;
+ }
+
+ // LOGIKA 2: Ambang Batas (Harus di bawah 20% untuk dianggap sukses)
+ if (currentLevel >= 20) {
+ setMessage({
+ type: 'error',
+ text: `Gagal! Bak masih berisi ${currentLevel}%. Harap kosongkan hingga di bawah 20%.`
+ });
+ setIsProcessing(false);
+ return;
+ }
+
+ // LOGIKA 3: Simpan ke History menggunakan Interface TrashHistory
+ const historyData: TrashHistory = {
+ binId: id,
+ officerName: session.user.name || "Petugas",
+ officerEmail: session.user.email || "",
+ location: `${binData.gedung} - ${binData.lantai} (${binData.ruang})`,
+ levelCaptured: currentLevel,
+ status: 'Approved',
+ // FIX: Mengubah 'as any' menjadi 'as unknown as Date' agar lolos aturan linter ESLint strict
+ timestamp: serverTimestamp() as unknown as Date
+ };
+
+ await addDoc(collection(db, "history"), historyData);
+
+ // LOGIKA 4: Update 'lastUpdate' di koleksi bins (sebagai tanda pengosongan terakhir)
+ await updateDoc(binRef, {
+ lastUpdate: serverTimestamp()
+ });
+
+ setMessage({ type: 'success', text: 'Berhasil! Data penjemputan terverifikasi dan waktu update diperbarui.' });
+
+ // Delay sebelum kembali ke dashboard
+ setTimeout(() => router.push('/monitoring'), 2500);
+
+ } catch (err) {
+ console.error(err);
+ setMessage({ type: 'error', text: 'Gagal menghubungi database. Periksa koneksi internet Anda.' });
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ // UI state untuk proteksi tombol (Double Bang !! untuk menghindari null error di TypeScript)
+ const isButtonDisabled = !!isProcessing || !binData || (!!binData && binData.level < 20 && !message);
+
+ return (
+
+ router.back()}
+ className="flex items-center gap-2 text-gray-500 mb-3 font-bold text-xs hover:text-gray-900 transition-colors sm:mb-6 sm:text-sm"
+ >
+ Kembali
+
+
+
+
+
+
+
+ {/* Section: Petugas */}
+
+
+
+
+
+
Petugas Penjemputan
+
{session?.user?.name || 'Loading...'}
+
+
+
+ {/* Section: Level Comparison */}
+
+
+
+ Level Awal
+
+
+ {initialLevel !== null ? `${initialLevel}%` : '--%'}
+
+
+
+
+ Real-time
+
+ {/* Tambahan visual dinamis jika alat offline secara mendadak */}
+
+ {binData ? `${binData.level}%` : '--%'}
+ {binData?.status === 'off' && ⚠️ Perangkat Offline }
+
+
+
+
+ {/* Section: Lokasi */}
+
+
+
+ {binData ? `${binData.gedung}, ${binData.lantai}` : 'Memuat lokasi...'}
+
+
+ {binData?.ruang || 'Mencari detail ruang...'}
+
+
+
+ {/* Feedback Messages */}
+ {message && (
+
+ {message.type === 'success' ?
:
}
+
{message.text}
+
+ )}
+
+ {/* Action Button */}
+
+ {isProcessing ? (
+ <> Sinkronisasi...>
+ ) : (binData && binData.level < 20 && !message) ? (
+ <> Selesai Dikosongkan>
+ ) : (
+ <> Ambil & Approve>
+ )}
+
+
+
+ Verifikasi ini akan mencatat log aktivitas petugas dan memperbarui status pada dashboard admin.
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/views/monitoring/index.tsx b/src/views/monitoring/index.tsx
new file mode 100644
index 0000000..f982cad
--- /dev/null
+++ b/src/views/monitoring/index.tsx
@@ -0,0 +1,130 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import PageContainer from '@/components/layout/PageContainer';
+import PageHeader from '@/components/layout/PageHeader';
+import { db } from '@/lib/firebase';
+import { collection, onSnapshot, query } from 'firebase/firestore';
+import { useSearch } from '@/context/SearchContext';
+import BinCard from '@/components/dashboard/BinCard';
+import { FiFilter, FiInfo } from 'react-icons/fi';
+import type { Bin } from '@/types/database';
+import { binService } from '@/lib/services/binService'; // ✅ 1. IMPORT SERVICE SINKRONISASI DI SINI
+
+export default function MonitoringView() {
+ // Intersection Type agar menampung properti firestoreId secara legal
+ const [bins, setBins] = useState<(Bin & { firestoreId: string })[]>([]);
+ const [loading, setLoading] = useState(true);
+ const { searchQuery } = useSearch();
+
+ const [statusFilter, setStatusFilter] = useState("Semua Status");
+
+ useEffect(() => {
+ const q = query(collection(db, "bins"));
+ const unsubscribe = onSnapshot(q, (snapshot) => {
+ const binsData = snapshot.docs.map(doc => {
+ const rawBin = { firestoreId: doc.id, ...doc.data() } as Bin & { firestoreId: string };
+
+ // ✅ 2. KOREKSI STATUS DI SINI (Jika > 8 detik tak kirim data, status otomatis dibalik jadi 'off')
+ const processedBin = binService.enforceHeartbeat(rawBin);
+
+ return {
+ ...processedBin,
+ firestoreId: doc.id // Pastikan ID dokumen Firestore tetap menempel
+ };
+ });
+
+ setBins(binsData);
+ setLoading(false);
+ });
+ return () => unsubscribe();
+ }, []);
+
+ // LOGIKA FILTER GABUNGAN (Search + Dropdown) - 100% TETAP ASLI TANPA PERUBAHAN
+ const filteredBins = bins.filter((bin) => {
+ // 1. Filter Search (Menggunakan variabel baru: gedung, lantai, ruang)
+ const searchTarget = `${bin.gedung || ''} ${bin.lantai || ''} ${bin.ruang || ''} ${bin.id || ''}`.toLowerCase();
+ const matchesSearch = searchTarget.includes(searchQuery.toLowerCase());
+
+ // 2. Filter Dropdown Status (Menggunakan variabel 'level')
+ let matchesStatus = true;
+ const currentLevel = bin.level ?? 0;
+
+ if (statusFilter === "Penuh") matchesStatus = bin.status === 'on' && currentLevel >= 90;
+ else if (statusFilter === "Hampir Penuh") matchesStatus = bin.status === 'on' && currentLevel >= 70 && currentLevel < 90;
+ else if (statusFilter === "Terisi") matchesStatus = bin.status === 'on' && currentLevel > 0 && currentLevel < 70;
+ else if (statusFilter === "Kosong") matchesStatus = bin.status === 'on' && currentLevel <= 0;
+ else if (statusFilter === "Offline") matchesStatus = bin.status === 'off'; // Otomatis sinkron dengan binService
+
+ return matchesSearch && matchesStatus;
+ });
+
+ if (loading) return (
+
+
+
Memuat Data Monitoring...
+
+
+ );
+
+ return (
+
+
+
+ {/* Filter Bar */}
+
+
+
+ Filter Tampilan:
+
+
+
setStatusFilter(e.target.value)}
+ className="w-full cursor-pointer rounded-xl border-none bg-gray-50 p-2 text-[11px] font-bold text-gray-700 outline-none transition-all focus:ring-2 focus:ring-emerald-500 sm:p-2.5 sm:text-sm md:w-56"
+ >
+ Semua Status
+ Penuh
+ Hampir Penuh
+ Terisi
+ Kosong
+ Offline
+
+
+
+ {/* Stats Info */}
+
+
+
+ Menampilkan {filteredBins.length} dari {bins.length} tempat sampah
+
+
+
+ {/* Grid Monitoring */}
+ {filteredBins.length > 0 ? (
+
+ {filteredBins.map((bin) => (
+
+ ))}
+
+ ) : (
+
+
Tidak ada tempat sampah yang cocok dengan filter ini.
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/views/notifications/index.tsx b/src/views/notifications/index.tsx
new file mode 100644
index 0000000..791cd5b
--- /dev/null
+++ b/src/views/notifications/index.tsx
@@ -0,0 +1,83 @@
+import { useState, useEffect } from 'react';
+import PageContainer from '@/components/layout/PageContainer';
+import { DerivedNotification } from '@/lib/services/deriveNotificationService';
+import { deriveNotificationService } from '@/lib/services/deriveNotificationService';
+import { notificationReadService } from '@/lib/services/notificationReadService';
+import NotificationList from '@/components/notifications/NotificationList';
+
+interface NotificationFilter {
+ semua: number;
+ baru: number;
+ dibaca: number;
+}
+
+export default function NotificationsView() {
+ const [notifications, setNotifications] = useState([]);
+ const [filter, setFilter] = useState<'semua' | 'baru' | 'dibaca'>('semua');
+ const [counts, setCounts] = useState({
+ semua: 0,
+ baru: 0,
+ dibaca: 0,
+ });
+ const [loading, setLoading] = useState(true);
+
+ // Subscribe to derived notifications with read status
+ useEffect(() => {
+ // Subscribe to notifications with status
+ const unsubscribeNotifs = deriveNotificationService.subscribeToDerivedNotificationsWithStatus(
+ (data) => {
+ setNotifications(data);
+ setLoading(false);
+ },
+ filter
+ );
+
+ // Subscribe to notification counts
+ const unsubscribeCounts = deriveNotificationService.subscribeToNotificationCounts((counts) => {
+ setCounts(counts);
+ });
+
+ return () => {
+ unsubscribeNotifs();
+ unsubscribeCounts();
+ };
+ }, [filter]);
+
+ const handleMarkAsRead = async (notificationId: string) => {
+ try {
+ await notificationReadService.markAsRead(notificationId);
+ } catch (error) {
+ console.error('Failed to mark as read:', error);
+ }
+ };
+
+ const handleMarkAllAsRead = async () => {
+ try {
+ // Mark all current (unread) notifications as read
+ const unreadNotifications = notifications.filter((n) => n.status === 'baru');
+
+ for (const notif of unreadNotifications) {
+ await notificationReadService.markAsRead(notif.id);
+ }
+
+ console.log('✅ All notifications marked as read');
+ } catch (error) {
+ console.error('Failed to mark all as read:', error);
+ }
+ };
+
+ return (
+
+ {/* Header Section menggunakan PageHeader */}
+
+
+ );
+}