Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 37 additions & 18 deletions src/components/manage/ManageBin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@ interface ManageBinProps {
}

export default function ManageBin({ isOpen, onClose, editData }: ManageBinProps) {
// FIX: Menambahkan distance ke dalam nilai awal state
const [formData, setFormData] = useState<BinData>({
id: "",
gedung: "",
lantai: "",
ruang: "",
capacity: 0,
level: 0,
status: 'on'
status: 'on',
distance: 0
});
const [error, setError] = useState<string | null>(null);

Expand All @@ -31,15 +33,16 @@ export default function ManageBin({ isOpen, onClose, editData }: ManageBinProps)
if (editData) {
setFormData(editData);
} else {
// Reset form untuk create baru
// FIX: Menambahkan distance saat reset form untuk create baru
setFormData({
id: "",
gedung: "",
lantai: "",
ruang: "",
capacity: 0,
level: 0,
status: 'on'
status: 'on',
distance: 0
});
}
setError(null);
Expand All @@ -50,7 +53,6 @@ export default function ManageBin({ isOpen, onClose, editData }: ManageBinProps)
setError(null);

try {
// Validasi ID wajib untuk create bin baru
if (!editData && !formData.id.trim()) {
setError('ID Bin wajib diisi');
return;
Expand Down Expand Up @@ -150,8 +152,8 @@ export default function ManageBin({ isOpen, onClose, editData }: ManageBinProps)
</div>
</div>

{/* Capacity dan Level */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Capacity, Level, dan Distance */}
<div className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-semibold text-gray-700 ml-1">Kapasitas (L)</label>
<input
Expand All @@ -163,16 +165,33 @@ export default function ManageBin({ isOpen, onClose, editData }: ManageBinProps)
required
/>
</div>
<div className="space-y-1">
<label className="text-sm font-semibold text-emerald-600 ml-1">Level Isi (%)</label>
<input
type="number" max="100" min="0"
className="w-full px-4 py-3 bg-gray-50 border border-emerald-100 rounded-xl text-sm font-bold focus:ring-2 focus:ring-emerald-500 outline-none"
placeholder="0-100"
value={formData.level}
onChange={(e) => setFormData({...formData, level: Number(e.target.value)})}
required
/>

{/* Grid Baru untuk Level dan Distance */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-sm font-semibold text-emerald-600 ml-1">Level Isi (%)</label>
<input
type="number" max="100" min="0"
className="w-full px-4 py-3 bg-gray-50 border border-emerald-100 rounded-xl text-sm font-bold focus:ring-2 focus:ring-emerald-500 outline-none"
placeholder="0-100"
value={formData.level}
onChange={(e) => setFormData({...formData, level: Number(e.target.value)})}
required
/>
</div>

{/* KOLOM BARU: Input untuk Distance */}
<div className="space-y-1">
<label className="text-sm font-semibold text-blue-600 ml-1">Jarak Sensor (cm)</label>
<input
type="number" min="0"
className="w-full px-4 py-3 bg-gray-50 border border-blue-100 rounded-xl text-sm font-bold focus:ring-2 focus:ring-emerald-500 outline-none"
placeholder="Contoh: 13"
value={formData.distance}
onChange={(e) => setFormData({...formData, distance: e.target.value ? Number(e.target.value) : 0})}
required
/>
</div>
</div>
</div>

Expand All @@ -182,7 +201,7 @@ export default function ManageBin({ isOpen, onClose, editData }: ManageBinProps)
<select
className="w-full px-4 py-3 bg-gray-50 border border-gray-100 rounded-xl text-sm font-bold focus:ring-2 focus:ring-emerald-500 outline-none transition-all"
value={formData.status}
onChange={(e) => setFormData({...formData, status: e.target.value as 'on' | 'off'})}
onChange={(e) => e.target.value && setFormData({...formData, status: e.target.value as 'on' | 'off'})}
>
<option value="on">ON (Active)</option>
<option value="off">OFF (Inactive)</option>
Expand All @@ -205,4 +224,4 @@ export default function ManageBin({ isOpen, onClose, editData }: ManageBinProps)
</div>
</div>
);
}
}
32 changes: 32 additions & 0 deletions src/lib/services/binService.ts
Original file line number Diff line number Diff line change
@@ -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 (aman untuk Timestamp Firestore maupun Date)
const lastUpdateMs = typeof bin.lastUpdate === 'object' && bin.lastUpdate && 'toMillis' in bin.lastUpdate
? (bin.lastUpdate as any).toMillis()

Check failure on line 14 in src/lib/services/binService.ts

View workflow job for this annotation

GitHub Actions / test (18.x)

Unexpected any. Specify a different type

Check failure on line 14 in src/lib/services/binService.ts

View workflow job for this annotation

GitHub Actions / test (20.x)

Unexpected any. Specify a different type
: new Date(bin.lastUpdate as any).getTime();

Check failure on line 15 in src/lib/services/binService.ts

View workflow job for this annotation

GitHub Actions / test (18.x)

Unexpected any. Specify a different type

Check failure on line 15 in src/lib/services/binService.ts

View workflow job for this annotation

GitHub Actions / test (20.x)

Unexpected any. Specify a different type

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),
};
}
};
71 changes: 36 additions & 35 deletions src/lib/services/deriveNotificationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { collection, onSnapshot } from 'firebase/firestore';
import { Bin } from '@/types/database';
import { notificationReadService } from './notificationReadService';
import { binService } from './binService'; // ✅ 1. IMPORT BINSERVICE DI SINI

export interface DerivedNotification {
id: string;
Expand All @@ -20,26 +21,27 @@
}

export const deriveNotificationService = {
// Subscribe to bins and derive notifications (ONLY FULL BINS - level >= 90)
// 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) => ({
id: doc.id,
...doc.data(),
})) as Bin[];
// ✅ 2. Bungkus dengan binService.enforceHeartbeat saat map data mentah
const bins = snapshot.docs.map((doc) => {
const rawBin = { id: doc.id, ...doc.data() } as Bin;
return binService.enforceHeartbeat(rawBin);
});

// Derive notifications from FULL bins only (level >= 90)
// Derive notifications dari tempat sampah yang PENUH DAN AKTIF saja
const derivedNotifications = bins
.filter((bin) => bin.level >= 90) // Only full bins
.filter((bin) => bin.status === 'on' && bin.level >= 90) // ✅ Tambah cek status aktif
.map((bin) => {
// Generate location dari gedung, lantai, ruang
const location = `${bin.gedung} - ${bin.lantai} - ${bin.ruang}`;

// Convert lastUpdate ke Date jika ada, jika tidak gunakan waktu saat ini
const timestamp = bin.lastUpdate
? (bin.lastUpdate.toDate ? bin.lastUpdate.toDate() : new Date(bin.lastUpdate))
? (typeof bin.lastUpdate === 'object' && 'toDate' in bin.lastUpdate
? (bin.lastUpdate as any).toDate()

Check failure on line 43 in src/lib/services/deriveNotificationService.ts

View workflow job for this annotation

GitHub Actions / test (18.x)

Unexpected any. Specify a different type

Check failure on line 43 in src/lib/services/deriveNotificationService.ts

View workflow job for this annotation

GitHub Actions / test (20.x)

Unexpected any. Specify a different type
: new Date(bin.lastUpdate as any))

Check failure on line 44 in src/lib/services/deriveNotificationService.ts

View workflow job for this annotation

GitHub Actions / test (18.x)

Unexpected any. Specify a different type

Check failure on line 44 in src/lib/services/deriveNotificationService.ts

View workflow job for this annotation

GitHub Actions / test (20.x)

Unexpected any. Specify a different type
: new Date();

return {
Expand All @@ -53,20 +55,20 @@
description: `${location} penuh (level ${bin.level}) - segera kosongkan`,
level: bin.level,
capacity: bin.capacity,
status: 'baru' as const, // All are new/unread
type: 'error' as const, // All are error (full)
status: 'baru' as const,
type: 'error' as const,
timestamp: timestamp,
} as DerivedNotification;
})
.sort((a, b) => b.level - a.level); // Sort by level descending
.sort((a, b) => b.level - a.level);

callback(derivedNotifications);
});

return unsubscribe;
},

// Get derived notification counts (ONLY FULL BINS - level >= 90) - Real-time with read status
// Get derived notification counts (ONLY FULL BINS) - Real-time dengan status baca
subscribeToNotificationCounts(
callback: (counts: { semua: number; baru: number; dibaca: number }) => void
) {
Expand All @@ -75,10 +77,11 @@

// Subscribe to bins
const unsubscribeBins = onSnapshot(collection(db, 'bins'), (snapshot) => {
binsData = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Bin[];
// ✅ 3. Bungkus dengan binService.enforceHeartbeat di sini juga
binsData = snapshot.docs.map((doc) => {
const rawBin = { id: doc.id, ...doc.data() } as Bin;
return binService.enforceHeartbeat(rawBin);
});

updateCounts();
});
Expand All @@ -93,22 +96,20 @@
});

const updateCounts = () => {
// Get only FULL bins (level >= 90)
const fullBins = binsData.filter((b) => b.level >= 90);
// ✅ 4. Filter counter hanya menghitung bin yang aktif 'on' dan penuh >= 90
const fullBins = binsData.filter((b) => b.status === 'on' && b.level >= 90);
const fullBinIds = new Set(fullBins.map((b) => b.id));

// Clean up read notifications for bins that are no longer full
notificationReadService.cleanupReadNotificationsForNonFullBins(
fullBinIds
);

// Only count read IDs that are still full bins
const validReadIds = readIds.filter((id) => fullBinIds.has(id));

callback({
semua: fullBins.length,
baru: fullBins.length - validReadIds.length, // Full bins minus read ones
dibaca: validReadIds.length, // Only read notifications that are still full
baru: fullBins.length - validReadIds.length,
dibaca: validReadIds.length,
});
};

Expand All @@ -128,10 +129,11 @@

// Subscribe to bins
const unsubscribeBins = onSnapshot(collection(db, 'bins'), (snapshot) => {
binsData = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Bin[];
// ✅ 5. Bungkus dengan binService.enforceHeartbeat di sini juga
binsData = snapshot.docs.map((doc) => {
const rawBin = { id: doc.id, ...doc.data() } as Bin;
return binService.enforceHeartbeat(rawBin);
});

updateNotifications();
});
Expand All @@ -143,24 +145,23 @@
});

const updateNotifications = () => {
// Get only FULL bins (level >= 90)
const fullBins = binsData.filter((bin) => bin.level >= 90);
// ✅ 6. Filter daftar halaman notifikasi: Hanya ambil bin yang aktif dan penuh
const fullBins = binsData.filter((bin) => bin.status === 'on' && bin.level >= 90);
const fullBinIds = new Set(fullBins.map((b) => b.id));

// Clean up read notifications for bins that are no longer full
notificationReadService.cleanupReadNotificationsForNonFullBins(
fullBinIds
);

// Derive notifications from FULL bins only (level >= 90)
const derivedNotifications = fullBins
.map((bin) => {
const location = `${bin.gedung} - ${bin.lantai} - ${bin.ruang}`;
const isRead = readIds.includes(bin.id);

// Convert lastUpdate ke Date jika ada, jika tidak gunakan waktu saat ini
const timestamp = bin.lastUpdate
? (bin.lastUpdate.toDate ? bin.lastUpdate.toDate() : new Date(bin.lastUpdate))
? (typeof bin.lastUpdate === 'object' && 'toDate' in bin.lastUpdate
? (bin.lastUpdate as any).toDate()

Check failure on line 163 in src/lib/services/deriveNotificationService.ts

View workflow job for this annotation

GitHub Actions / test (18.x)

Unexpected any. Specify a different type

Check failure on line 163 in src/lib/services/deriveNotificationService.ts

View workflow job for this annotation

GitHub Actions / test (20.x)

Unexpected any. Specify a different type
: new Date(bin.lastUpdate as any))

Check failure on line 164 in src/lib/services/deriveNotificationService.ts

View workflow job for this annotation

GitHub Actions / test (18.x)

Unexpected any. Specify a different type

Check failure on line 164 in src/lib/services/deriveNotificationService.ts

View workflow job for this annotation

GitHub Actions / test (20.x)

Unexpected any. Specify a different type
: new Date();

return {
Expand Down Expand Up @@ -193,4 +194,4 @@
unsubscribeRead();
};
},
};
};
10 changes: 7 additions & 3 deletions src/pages/api/auth/[...nextauth].ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import NextAuth from "next-auth";
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";
Expand All @@ -20,7 +20,8 @@ declare module "next-auth" {
}
}

export default NextAuth({
// ✅ 1. Ekstrak konfigurasi ke variabel bernama authOptions dan berikan keyword 'export'
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
name: "Credentials",
Expand Down Expand Up @@ -145,4 +146,7 @@ export default NextAuth({
return session;
},
},
});
};

// ✅ 2. Jalankan fungsi NextAuth menggunakan objek konfigurasi di atas
export default NextAuth(authOptions);
7 changes: 4 additions & 3 deletions src/pages/api/change-password.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { NextApiRequest, NextApiResponse } from "next";
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);
const session = await getServerSession(req, res, authOptions);

if (!session || !session.user?.email) {
return res.status(401).json({ message: "Unauthorized" });
Expand Down Expand Up @@ -72,4 +73,4 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
console.error("Change password error:", error);
return res.status(500).json({ message: "Gagal mengubah password" });
}
}
}
6 changes: 4 additions & 2 deletions src/pages/api/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ 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) {
const session = await getServerSession(req, res);
// 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" });
Expand Down Expand Up @@ -81,4 +83,4 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)

res.setHeader("Allow", ["GET", "PUT"]);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
5 changes: 0 additions & 5 deletions src/pages/history/index.tsx

This file was deleted.

Loading
Loading