diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cc7ff4a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +Dockerfile +.dockerignore +node_modules +npm-debug.log +README.md +.next +.git +.env +.windsurf +.claude +dev.db +dev.db-journal +prisma/data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f5c5213 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +FROM node:22-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat openssl +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json package-lock.json* ./ +COPY prisma ./prisma/ +RUN npm install + +# Rebuild the source code only when needed +FROM base AS builder +RUN apk add --no-cache openssl +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Environment variables must be present at build time +ENV NEXT_TELEMETRY_DISABLED=1 +ENV DATABASE_URL="file:dev.db" + +# Generate Prisma Client +RUN npx prisma generate + +RUN npm run build + +# Production image, copy all the files and run next +FROM base AS runner +RUN apk add --no-cache openssl +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV DATABASE_URL="file:dev.db" + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Set the correct permission for prerender cache and sqlite +RUN mkdir .next +RUN chown nextjs:nodejs /app .next + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma +COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts ./prisma.config.ts + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 + +# We need to run migrations before starting the app. +# Since we are using PostgreSQL and might not have migration files, we use db push +CMD ["sh", "-c", "npx prisma db push --accept-data-loss && node server.js"] diff --git a/check.js b/check.js new file mode 100644 index 0000000..6c8ec40 --- /dev/null +++ b/check.js @@ -0,0 +1,8 @@ +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +async function check() { + const users = await prisma.user.findMany(); + console.log(users); +} +check().catch(console.error).finally(() => prisma.$disconnect()); diff --git a/docker-compose.yml b/docker-compose.yml index 7ca7f11..070a39c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,23 +1,37 @@ version: '3.8' services: - inventory-app: - image: inventory-app:latest - container_name: portal-inventory - ports: - - "3000:3000" + postgres-db: + image: postgres:15-alpine environment: - - NODE_ENV=production - - DATABASE_URL=file:/app/dev.db - - SESSION_SECRET=ganti_dengan_rahasia_acak_anda + - POSTGRES_USER=inventory_user + - POSTGRES_PASSWORD=sandi_rahasia_db_123 + - POSTGRES_DB=inventory_db volumes: - # PENTING: Ganti '/opt/inventory' dengan lokasi folder Anda di VPS Node Manager - - /opt/inventory/dev.db:/app/dev.db + # PENTING: Lokasi folder data PostgreSQL di Node Manager + - /opt/inventory/pgdata:/var/lib/postgresql/data deploy: mode: replicated replicas: 1 placement: constraints: + # Database tetap dikunci di Node Manager agar file data tidak hilang - node.role == manager restart_policy: condition: on-failure + + inventory-app: + image: inventory-app:latest + ports: + - "3000:3000" + environment: + - NODE_ENV=production + - DATABASE_URL=postgresql://inventory_user:sandi_rahasia_db_123@postgres-db:5432/inventory_db?schema=public + - SESSION_SECRET=ganti_dengan_rahasia_acak_anda + deploy: + mode: replicated + replicas: 2 # Aplikasi kini bisa disebar ke beberapa server! + restart_policy: + condition: on-failure + depends_on: + - postgres-db diff --git a/inventory.zip b/inventory.zip new file mode 100644 index 0000000..fb9a7e6 Binary files /dev/null and b/inventory.zip differ diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..831a20f --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,14 @@ +// This file was generated by Prisma, and assumes you have installed the following: +// npm install --save-dev prisma dotenv +import "dotenv/config"; +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + }, + datasource: { + url: process.env["DATABASE_URL"], + }, +}); diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..682d81f --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,112 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + username String? @unique + email String @unique + password String + name String + role String @default("user") // "user", "approver", "super_admin" + failedLoginAttempts Int @default(0) + lockoutUntil DateTime? + requests Request[] + approvals ApprovalLog[] + activityLogs ActivityLog[] + categories Category[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model SystemSettings { + id Int @id @default(1) + telegramBotToken String? + telegramChatId String? + isTelegramEnabled Boolean @default(false) + + // Email Config + isEmailEnabled Boolean @default(false) + smtpHost String? + smtpPort Int? + smtpUser String? + smtpPass String? + targetEmail String? // Comma separated for multiple + + updatedAt DateTime @updatedAt +} + +model Category { + id Int @id @default(autoincrement()) + name String @unique + users User[] + items Item[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Item { + id Int @id @default(autoincrement()) + name String @unique + totalQuantity Int @default(0) + borrowedQty Int @default(0) + trialQty Int @default(0) + billedQty Int @default(0) + availableQty Int @default(0) + damagedQty Int @default(0) + soldQty Int @default(0) + categoryId Int? + category Category? @relation(fields: [categoryId], references: [id]) + requests Request[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Request { + id Int @id @default(autoincrement()) + userId Int + itemId Int + quantity Int @default(1) + purpose String + company String + address String + requestDate DateTime @default(now()) + status String @default("pending") // "pending", "approved_1", "approved_2", "rejected" + type String @default("borrow") // "borrow", "trial", "billed", "purchase", "return_borrow", "return_trial", "return_billed" + notes String? + serialNumber String? + user User @relation(fields: [userId], references: [id]) + item Item @relation(fields: [itemId], references: [id]) + approvalLogs ApprovalLog[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model ApprovalLog { + id Int @id @default(autoincrement()) + requestId Int + approverId Int + approvalStep Int // 1 or 2 + status String // "approved", "rejected" + request Request @relation(fields: [requestId], references: [id]) + approver User @relation(fields: [approverId], references: [id]) + createdAt DateTime @default(now()) +} + +model ActivityLog { + id Int @id @default(autoincrement()) + userId Int + user User @relation(fields: [userId], references: [id]) + action String + details String + type String // "request", "approval", "admin_item", "admin_user" + createdAt DateTime @default(now()) +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..fa7016d --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,60 @@ +import { PrismaClient } from '@prisma/client' +import { PrismaLibSql } from '@prisma/adapter-libsql' +import 'dotenv/config' + +const adapter = new PrismaLibSql({ + url: process.env.DATABASE_URL || 'file:./dev.db', +}) +const prisma = new PrismaClient({ adapter }) + +async function main() { + const items = [ + 'BackOne Lite', + 'BackOne Pro', + 'BackOne Gateway', + 'BackOne Enterprise', + 'BackOne Ultimate' + ] + + console.log('Seeding initial items...') + + for (const itemName of items) { + const existing = await prisma.item.findUnique({ where: { name: itemName } }) + if (!existing) { + await prisma.item.create({ + data: { + name: itemName, + totalQuantity: 0, + }, + }) + console.log(`Created item: ${itemName}`) + } else { + console.log(`Item ${itemName} already exists.`) + } + } + + // Create an initial super admin + const existingAdmin = await prisma.user.findUnique({ where: { email: 'admin@backone.id' } }) + if (!existingAdmin) { + await prisma.user.create({ + data: { + name: 'Super Admin', + email: 'admin@backone.id', + password: 'password', // TODO: In production this should be hashed + role: 'super_admin' + } + }) + console.log('Created Super Admin (admin@backone.id / password)') + } + + console.log('Seeding finished.') +} + +main() + .catch((e) => { + console.error(e) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/scripts/clear_data.js b/scripts/clear_data.js new file mode 100644 index 0000000..8019e2f --- /dev/null +++ b/scripts/clear_data.js @@ -0,0 +1,27 @@ +const { DatabaseSync } = require('node:sqlite'); + +const db = new DatabaseSync('dev.db'); + +try { + // 1. Delete all ApprovalLogs + db.exec('DELETE FROM ApprovalLog'); + console.log('Cleared all ApprovalLogs'); + + // 2. Delete all Requests + db.exec('DELETE FROM Request'); + console.log('Cleared all Requests'); + + // 3. Delete ActivityLogs + db.exec('DELETE FROM ActivityLog'); + console.log('Cleared all ActivityLogs'); + + // 4. Reset all Item quantities to 0 + db.exec('UPDATE Item SET totalQuantity = 0, borrowedQty = 0, trialQty = 0, billedQty = 0, availableQty = 0, damagedQty = 0, soldQty = 0'); + console.log('Reset all item quantities to 0'); + + console.log('Done!'); +} catch (err) { + console.error('Error:', err); +} finally { + db.close(); +} diff --git a/scripts/clear_data.ts b/scripts/clear_data.ts new file mode 100644 index 0000000..3c313f9 --- /dev/null +++ b/scripts/clear_data.ts @@ -0,0 +1,45 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient({ + datasources: { + db: { + url: 'file:dev.db' + } + } +}) + +async function main() { + console.log('Clearing data...') + + // 1. Delete all ApprovalLogs + await prisma.approvalLog.deleteMany({}) + console.log('Cleared all ApprovalLogs') + + // 2. Delete all Requests + await prisma.request.deleteMany({}) + console.log('Cleared all Requests') + + // 3. Delete ActivityLogs to clear history page completely + await prisma.activityLog.deleteMany({}) + console.log('Cleared all ActivityLogs') + + // 4. Reset all Item quantities to 0 + await prisma.item.updateMany({ + data: { + totalQuantity: 0, + borrowedQty: 0, + trialQty: 0, + billedQty: 0, + availableQty: 0, + damagedQty: 0, + soldQty: 0, + } + }) + console.log('Reset all item quantities to 0') + + console.log('Done!') +} + +main() + .catch(console.error) + .finally(() => prisma.$disconnect()) diff --git a/scripts/resync.ts b/scripts/resync.ts new file mode 100644 index 0000000..2cacef2 --- /dev/null +++ b/scripts/resync.ts @@ -0,0 +1,80 @@ +import { PrismaClient } from '@prisma/client' +import { PrismaLibSql } from '@prisma/adapter-libsql' +const adapter = new PrismaLibSql({ + url: 'file:dev.db', +}) +const prisma = new PrismaClient({ adapter }) + +async function main() { + const items = await prisma.item.findMany({ + include: { requests: { where: { status: 'approved_2' } } } + }) + + let updatedCount = 0; + + for (const item of items) { + let borrowedQty = 0; + let trialQty = 0; + let billedQty = 0; + let soldQty = 0; + let damagedQty = 0; + + for (const req of item.requests) { + const qty = req.quantity; + let type = req.type.toLowerCase(); + if (type === 'manage service') type = 'billed'; + if (type === 'outright purchase') type = 'outright_purchase'; + + if (type !== req.type) { + await prisma.request.update({ where: { id: req.id }, data: { type } }); + } + + // Borrowed + if (type === 'borrow') borrowedQty += qty; + if (type === 'return_borrow' || type === 'return_damaged_borrow') borrowedQty -= qty; + + // Trial + if (type === 'trial') trialQty += qty; + if (type === 'return_trial' || type === 'return_damaged_trial') trialQty -= qty; + + // Billed + if (type === 'billed') billedQty += qty; + if (type === 'return_billed' || type === 'return_damaged_billed') billedQty -= qty; + + // Sold + if (type === 'outright_purchase') soldQty += qty; + if (type === 'return_damaged_sold') soldQty -= qty; + + // Damaged + if (type === 'return_damaged_borrow' || type === 'return_damaged_trial' || type === 'return_damaged_billed' || type === 'return_damaged_sold') { + damagedQty += qty; + } + } + + borrowedQty = Math.max(0, borrowedQty); + trialQty = Math.max(0, trialQty); + billedQty = Math.max(0, billedQty); + soldQty = Math.max(0, soldQty); + damagedQty = Math.max(0, damagedQty); + + const availableQty = Math.max(0, item.totalQuantity - borrowedQty - trialQty - billedQty - damagedQty - soldQty); + + await prisma.item.update({ + where: { id: item.id }, + data: { + borrowedQty, + trialQty, + billedQty, + soldQty, + damagedQty, + availableQty + } + }) + updatedCount++; + } + console.log(`Resynced ${updatedCount} items.`); +} + +main() + .catch(console.error) + .finally(() => prisma.$disconnect()) diff --git a/seed.js b/seed.js new file mode 100644 index 0000000..07a7106 --- /dev/null +++ b/seed.js @@ -0,0 +1,16 @@ +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +async function main() { + await prisma.user.create({ + data: { + name: 'Admin', + email: 'admin@backone.id', + password: 'password', + role: 'super_admin' + } + }); + console.log('Seeded admin user'); +} + +main().catch(console.error).finally(() => prisma.$disconnect()); diff --git a/src/app/admin/categories/page.tsx b/src/app/admin/categories/page.tsx new file mode 100644 index 0000000..c0e68b3 --- /dev/null +++ b/src/app/admin/categories/page.tsx @@ -0,0 +1,166 @@ +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import { redirect } from 'next/navigation' +import { revalidatePath } from 'next/cache' + +export default async function AdminCategoriesPage({ searchParams }: { searchParams: Promise<{ edit?: string, error?: string, name?: string }> }) { + const { edit, error, name: spName } = await searchParams + const session = await getSession() + if (!session || session.role !== 'super_admin') { + redirect('/') + } + + const categories = await prisma.category.findMany({ include: { _count: { select: { items: true, users: true } } } }) + const editingCategory = edit ? categories.find(c => c.id === parseInt(edit)) : null + + async function addCategory(formData: FormData) { + 'use server' + const name = formData.get('name') as string + + if (!name) { + redirect('/admin/categories?error=Category+name+is+required') + } + + const existing = await prisma.category.findUnique({ where: { name } }) + if (existing) { + redirect('/admin/categories?error=Category+already+exists') + } + + await prisma.category.create({ data: { name } }) + revalidatePath('/admin/categories') + redirect('/admin/categories') + } + + async function editCategory(formData: FormData) { + 'use server' + const id = parseInt(formData.get('id') as string) + const name = formData.get('name') as string + + if (!name) { + redirect(`/admin/categories?edit=${id}&error=Category+name+is+required`) + } + + const existingCategory = await prisma.category.findUnique({ where: { id } }) + if (!existingCategory) return + + if (name !== existingCategory.name) { + const checkName = await prisma.category.findUnique({ where: { name } }) + if (checkName) { + redirect(`/admin/categories?edit=${id}&error=Category+already+exists`) + } + } + + await prisma.category.update({ + where: { id }, + data: { name } + }) + + revalidatePath('/admin/categories') + redirect('/admin/categories') + } + + async function deleteCategory(formData: FormData) { + 'use server' + const id = parseInt(formData.get('id') as string) + const categoryToDelete = await prisma.category.findUnique({ where: { id }, include: { _count: { select: { items: true, users: true } } } }) + if (categoryToDelete) { + if (categoryToDelete._count.items > 0 || categoryToDelete._count.users > 0) { + redirect('/admin/categories?error=Cannot+delete+category+with+items+or+users') + } + await prisma.category.delete({ where: { id } }) + } + revalidatePath('/admin/categories') + redirect('/admin/categories') + } + + return ( +
+

Category Management

+ + {error && ( +
+ ❌ {error} +
+ )} + +
+
+ {editingCategory ? ( + <> +
+

Edit Category

+ Cancel +
+
+ +
+ + +
+ +
+ + ) : ( + <> +

Add New Category

+
+
+ + +
+ +
+ + )} +
+ +
+

Existing Categories

+
+ + + + + + + + + + + + {categories.map(category => ( + + + + + + + + ))} + {categories.length === 0 && ( + + + + )} + +
IDNameItems CountUsers CountActions
#{category.id}{category.name}{category._count.items}{category._count.users} +
+ + Edit + +
+ + +
+
+
+ No categories found. Create one to get started. +
+
+
+
+
+ ) +} diff --git a/src/app/admin/items/page.tsx b/src/app/admin/items/page.tsx new file mode 100644 index 0000000..5d7ef15 --- /dev/null +++ b/src/app/admin/items/page.tsx @@ -0,0 +1,153 @@ +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import { redirect } from 'next/navigation' +import { revalidatePath } from 'next/cache' + +export default async function AdminItemsPage() { + const session = await getSession() + if (!session || (session.role !== 'super_admin' && session.role !== 'approver')) { + redirect('/') + } + + const items = await prisma.item.findMany({ include: { category: true } }) + const categories = await prisma.category.findMany() + + async function addItem(formData: FormData) { + 'use server' + const name = formData.get('name') as string + const qtyStr = formData.get('quantity') as string + const qty = qtyStr ? parseInt(qtyStr) : 0 + const categoryIdStr = formData.get('categoryId') as string + const categoryId = categoryIdStr ? parseInt(categoryIdStr) : null + + if (!name) return + + const existing = await prisma.item.findUnique({ where: { name } }) + if (existing) { + const updateData: any = {} + if (qty > 0) { + updateData.totalQuantity = existing.totalQuantity + qty + updateData.availableQty = existing.availableQty + qty + } + if (categoryId) { + updateData.categoryId = categoryId + } + + await prisma.item.update({ + where: { id: existing.id }, + data: updateData + }) + + let details = `Updated existing item: ${name}` + if (qty > 0) details += ` (Added ${qty} stock)` + if (categoryId && categoryId !== existing.categoryId) details += ` (Updated Category)` + + await prisma.activityLog.create({ + data: { + userId: session!.id, + action: 'Update Item', + details, + type: 'admin_item' + } + }) + } else { + await prisma.item.create({ + data: { + name, + totalQuantity: qty > 0 ? qty : 0, + availableQty: qty > 0 ? qty : 0, + categoryId + } + }) + await prisma.activityLog.create({ + data: { + userId: session!.id, + action: 'Create New Item', + details: `Created new item: ${name} with ${qty} stock`, + type: 'admin_item' + } + }) + } + revalidatePath('/admin/items') + revalidatePath('/') + } + + return ( +
+

Item Management

+ +
+
+

Add Stock

+
+
+ + +
+
+ + + + {items.map(item => ( + +
+
+ + +
+ +
+

+ Select an existing item from the dropdown, or type a new name to create a new item. Set quantity to 0 if you only want to update the category of an existing item. +

+
+ +
+

Inventory Status

+
+ + + + + + + + + + + + + {items.map(item => ( + + + + + + + + + ))} + +
Item NameCategoryTotal StockAvailableDamagedSold
{item.name} + {item.category?.name || '-'} + {item.totalQuantity} + + {item.availableQty} + + {item.damagedQty}{item.soldQty}
+
+
+
+
+ ) +} diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx new file mode 100644 index 0000000..db62c2c --- /dev/null +++ b/src/app/admin/settings/page.tsx @@ -0,0 +1,280 @@ +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import { redirect } from 'next/navigation' +import { revalidatePath } from 'next/cache' +import { sendTelegramMessage } from '@/lib/telegram' + +export default async function SettingsPage({ searchParams }: { searchParams: Promise<{ success?: string, test?: string, test_recap?: string }> }) { + const { success, test, test_recap } = await searchParams + + const session = await getSession() + if (!session || session.role !== 'super_admin') { + redirect('/') + } + + // Ensure settings record exists + let settings = await prisma.systemSettings.findFirst() + if (!settings) { + settings = await prisma.systemSettings.create({ + data: { isTelegramEnabled: false } + }) + } + + async function updateSettings(formData: FormData) { + 'use server' + const botToken = formData.get('botToken') as string + const chatId = formData.get('chatId') as string + const isEnabled = formData.get('isEnabled') === 'true' + + const smtpHost = formData.get('smtpHost') as string + const smtpPort = parseInt(formData.get('smtpPort') as string) || null + const smtpUser = formData.get('smtpUser') as string + const smtpPass = formData.get('smtpPass') as string + const targetEmail = formData.get('targetEmail') as string + const isEmailEnabled = formData.get('isEmailEnabled') === 'true' + + const existing = await prisma.systemSettings.findFirst() + + if (existing) { + await prisma.systemSettings.update({ + where: { id: existing.id }, + data: { + telegramBotToken: botToken || null, + telegramChatId: chatId || null, + isTelegramEnabled: isEnabled, + smtpHost: smtpHost || null, + smtpPort, + smtpUser: smtpUser || null, + smtpPass: smtpPass || null, + targetEmail: targetEmail || null, + isEmailEnabled + } + }) + } else { + await prisma.systemSettings.create({ + data: { + telegramBotToken: botToken || null, + telegramChatId: chatId || null, + isTelegramEnabled: isEnabled, + smtpHost: smtpHost || null, + smtpPort, + smtpUser: smtpUser || null, + smtpPass: smtpPass || null, + targetEmail: targetEmail || null, + isEmailEnabled + } + }) + } + + const session = await getSession() + if (session) { + await prisma.activityLog.create({ + data: { + userId: session.id, + action: 'Update System Settings', + details: 'Updated Telegram API configuration', + type: 'admin_user' // Reuse admin_user for system changes + } + }) + } + + redirect('/admin/settings?success=true') + } + + async function testTelegram() { + 'use server' + const success = await sendTelegramMessage('🔔 Test Notification\n\nJika pesan ini masuk, artinya integrasi API Telegram Portal Inventory Anda sudah berjalan dengan baik!') + if (success) { + redirect('/admin/settings?test=success') + } else { + redirect('/admin/settings?test=error') + } + } + + async function testTelegramRecap() { + 'use server' + const settings = await prisma.systemSettings.findFirst() + if (!settings || !settings.isTelegramEnabled) { + redirect('/admin/settings?test_recap=error') + } + + const now = new Date() + const firstDayPrevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1) + const lastDayPrevMonth = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999) + + const totalRequests = await prisma.request.count({ + where: { createdAt: { gte: firstDayPrevMonth, lte: lastDayPrevMonth } } + }) + const approvedRequestsData = await prisma.request.findMany({ + where: { status: 'approved_2', createdAt: { gte: firstDayPrevMonth, lte: lastDayPrevMonth } } + }) + + const approvedRequests = approvedRequestsData.length; + let inCount = 0; let outCount = 0; + let inQty = 0; let outQty = 0; + + const breakdown: Record = { + purchase: { count: 0, qty: 0 }, + return_borrow: { count: 0, qty: 0 }, + return_trial: { count: 0, qty: 0 }, + return_billed: { count: 0, qty: 0 }, + return_damaged_borrow: { count: 0, qty: 0 }, + return_damaged_trial: { count: 0, qty: 0 }, + return_damaged_billed: { count: 0, qty: 0 }, + return_damaged_sold: { count: 0, qty: 0 }, + borrow: { count: 0, qty: 0 }, + trial: { count: 0, qty: 0 }, + billed: { count: 0, qty: 0 }, + outright_purchase: { count: 0, qty: 0 } + } + + approvedRequestsData.forEach(req => { + if (!breakdown[req.type]) { + breakdown[req.type] = { count: 0, qty: 0 }; + } + breakdown[req.type].count += 1; + breakdown[req.type].qty += req.quantity; + + if (['purchase', 'return_borrow', 'return_trial', 'return_billed', 'return_damaged_borrow', 'return_damaged_trial', 'return_damaged_billed', 'return_damaged_sold'].includes(req.type)) { + inCount += 1; inQty += req.quantity; + } else if (['borrow', 'trial', 'billed', 'outright_purchase'].includes(req.type)) { + outCount += 1; outQty += req.quantity; + } + }) + + const monthName = firstDayPrevMonth.toLocaleString('id-ID', { month: 'long', year: 'numeric' }) + let message = `📊 [TEST] Rekap Inventory Bulanan\nBulan: ${monthName}\n\nTotal Request: ${totalRequests}\nTotal Approved: ${approvedRequests}\n\n` + message += `📥 BARANG MASUK & RETURN\n` + message += `Total: ${inCount} transaksi (${inQty} barang)\n` + message += `• Purchase: ${breakdown.purchase?.count || 0}\n` + message += `• Return Borrow: ${breakdown.return_borrow?.count || 0}\n` + message += `• Return Trial: ${breakdown.return_trial?.count || 0}\n` + message += `• Return Manage Service: ${breakdown.return_billed?.count || 0}\n` + message += `• Return Damaged (Broken): ${(breakdown.return_damaged_borrow?.count || 0) + (breakdown.return_damaged_trial?.count || 0) + (breakdown.return_damaged_billed?.count || 0) + (breakdown.return_damaged_sold?.count || 0)}\n\n` + message += `📤 BARANG KELUAR\n` + message += `Total: ${outCount} transaksi (${outQty} barang)\n` + message += `• Borrow: ${breakdown.borrow?.count || 0}\n` + message += `• Trial: ${breakdown.trial?.count || 0}\n` + message += `• Manage Service: ${breakdown.billed?.count || 0}\n` + message += `• Outright Purchase: ${breakdown.outright_purchase?.count || 0}` + + const success = await sendTelegramMessage(message) + if (success) { + redirect('/admin/settings?test_recap=success') + } else { + redirect('/admin/settings?test_recap=error') + } + } + + return ( +
+

System Settings

+ + {success === 'true' && ( +
+ ✅ Pengaturan berhasil disimpan! +
+ )} + {test === 'success' && ( +
+ ✅ Pesan uji coba berhasil dikirim ke Telegram! +
+ )} + {test === 'error' && ( +
+ ❌ Gagal mengirim pesan. Pastikan Token Bot dan Chat ID sudah benar & disimpan. +
+ )} + {test_recap === 'success' && ( +
+ ✅ Laporan rekap bulanan berhasil dikirim ke Telegram! +
+ )} + {test_recap === 'error' && ( +
+ ❌ Gagal mengirim rekap. Pastikan integrasi Telegram sudah diaktifkan dan dikonfigurasi. +
+ )} + +
+

Telegram Notifications

+

+ Configure a Telegram Bot to receive real-time notifications for new requests and approvals. +

+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+

+ 📧 Email Recap Configuration +

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + +
+
+
+
+ ) +} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx new file mode 100644 index 0000000..1a9af01 --- /dev/null +++ b/src/app/admin/users/page.tsx @@ -0,0 +1,381 @@ +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import { redirect } from 'next/navigation' +import { revalidatePath } from 'next/cache' + +export default async function AdminUsersPage({ searchParams }: { searchParams: Promise<{ edit?: string, error?: string, err_name?: string, err_email?: string, err_username?: string, err_password?: string, err_categories?: string, name?: string, email?: string, username?: string, role?: string }> }) { + const { edit, error, err_name, err_email, err_username, err_password, err_categories, name: spName, email: spEmail, username: spUsername, role: spRole } = await searchParams + const session = await getSession() + if (!session || session.role !== 'super_admin') { + redirect('/') + } + + const users = await prisma.user.findMany({ include: { categories: true } }) + const categories = await prisma.category.findMany() + const editingUser = edit ? users.find(u => u.id === parseInt(edit)) : null + + async function addUser(formData: FormData) { + 'use server' + const name = formData.get('name') as string + const email = formData.get('email') as string + const username = formData.get('username') as string + const password = formData.get('password') as string + const role = formData.get('role') as string + const categoryIds = formData.getAll('categories').map(id => parseInt(id as string)).filter(id => !isNaN(id)) + + const qs = `&name=${encodeURIComponent(name||'')}&email=${encodeURIComponent(email||'')}&username=${encodeURIComponent(username||'')}&role=${encodeURIComponent(role||'')}` + + let hasError = false; + let errName = '', errEmail = '', errUsername = '', errPassword = '', errCategories = ''; + + if (categoryIds.length === 0) { + errCategories = 'At least one group/category is required'; + hasError = true; + } + + if (!name || name.trim().split(/\s+/).length < 2) { + errName = 'Name must contain at least 2 words'; + hasError = true; + } + + if (username) { + const existingUsername = await prisma.user.findUnique({ where: { username } }) + if (existingUsername) { + errUsername = 'Username already exists'; + hasError = true; + } + } else { + errUsername = 'Username is required'; + hasError = true; + } + + if (email) { + const existing = await prisma.user.findUnique({ where: { email } }) + if (existing) { + errEmail = 'Email already exists'; + hasError = true; + } + } else { + errEmail = 'Email is required'; + hasError = true; + } + + if (!password || password.length < 8 || !/[A-Z]/.test(password) || !/[^a-zA-Z0-9]/.test(password)) { + errPassword = 'Password must be at least 8 chars, contain an uppercase letter and a special character'; + hasError = true; + } + + if (hasError) { + redirect(`/admin/users?err_name=${encodeURIComponent(errName)}&err_email=${encodeURIComponent(errEmail)}&err_username=${encodeURIComponent(errUsername)}&err_password=${encodeURIComponent(errPassword)}&err_categories=${encodeURIComponent(errCategories)}${qs}`) + } + + await prisma.user.create({ + data: { + name, + email, + username: username || null, + password, + role, + categories: { connect: categoryIds.map(id => ({ id })) } + } + }) + + const session = await getSession() + await prisma.activityLog.create({ + data: { + userId: session!.id, + action: 'Create New User', + details: `Created new user: ${name} (${email}) with role: ${role}`, + type: 'admin_user' + } + }) + revalidatePath('/admin/users') + } + + async function editUser(formData: FormData) { + 'use server' + const id = parseInt(formData.get('id') as string) + const name = formData.get('name') as string + const email = formData.get('email') as string + const username = formData.get('username') as string + const password = formData.get('password') as string + const role = formData.get('role') as string + const categoryIds = formData.getAll('categories').map(id => parseInt(id as string)).filter(id => !isNaN(id)) + + const qs = `&edit=${id}&name=${encodeURIComponent(name||'')}&email=${encodeURIComponent(email||'')}&username=${encodeURIComponent(username||'')}&role=${encodeURIComponent(role||'')}` + + const existingUser = await prisma.user.findUnique({ where: { id } }) + if (!existingUser) return + + let hasError = false; + let errName = '', errEmail = '', errUsername = '', errPassword = '', errCategories = ''; + + if (categoryIds.length === 0) { + errCategories = 'At least one group/category is required'; + hasError = true; + } + + if (name && name.trim().split(/\s+/).length < 2) { + errName = 'Name must contain at least 2 words'; + hasError = true; + } + + if (username) { + if (username !== existingUser.username) { + const checkUsername = await prisma.user.findUnique({ where: { username } }) + if (checkUsername) { + errUsername = 'Username already exists'; + hasError = true; + } + } + } else { + errUsername = 'Username is required'; + hasError = true; + } + + if (email) { + if (email !== existingUser.email) { + const checkEmail = await prisma.user.findUnique({ where: { email } }) + if (checkEmail) { + errEmail = 'Email already exists'; + hasError = true; + } + } + } else { + errEmail = 'Email is required'; + hasError = true; + } + + if (password) { + if (password.length < 8 || !/[A-Z]/.test(password) || !/[^a-zA-Z0-9]/.test(password)) { + errPassword = 'Password must be at least 8 chars, contain an uppercase letter and a special character'; + hasError = true; + } + } + + if (hasError) { + redirect(`/admin/users?err_name=${encodeURIComponent(errName)}&err_email=${encodeURIComponent(errEmail)}&err_username=${encodeURIComponent(errUsername)}&err_password=${encodeURIComponent(errPassword)}&err_categories=${encodeURIComponent(errCategories)}${qs}`) + } + + // Only update fields that are provided + const updateData: any = { role } + if (name) updateData.name = name + if (email) updateData.email = email + if (username) updateData.username = username + if (password) updateData.password = password + updateData.categories = { set: categoryIds.map(id => ({ id })) } + + await prisma.user.update({ + where: { id }, + data: updateData + }) + + const session = await getSession() + await prisma.activityLog.create({ + data: { + userId: session!.id, + action: 'Update User', + details: `Updated user: ${existingUser.name} (${email})`, + type: 'admin_user' + } + }) + + redirect('/admin/users') + } + + async function deleteUser(formData: FormData) { + 'use server' + const id = parseInt(formData.get('id') as string) + const userToDelete = await prisma.user.findUnique({ where: { id } }) + if (userToDelete) { + if (userToDelete.role === 'super_admin') { + redirect('/admin/users?error=Cannot+delete+super+admin') + } + + await prisma.user.delete({ where: { id } }) + + const session = await getSession() + await prisma.activityLog.create({ + data: { + userId: session!.id, + action: 'Delete User', + details: `Deleted user: ${userToDelete.name} (${userToDelete.email})`, + type: 'admin_user' + } + }) + } + redirect('/admin/users') // clear any edit states + } + + return ( +
+

User Management

+ + {error && ( +
+ ❌ {error} +
+ )} + +
+
+ {editingUser ? ( + <> +
+

Edit User

+ Cancel +
+
+ +
+ + + {err_name &&
{err_name}
} + {editingUser.role === 'super_admin' && ( + + )} +
+
+ + + {err_username &&
{err_username}
} +
+
+ + + {err_email &&
{err_email}
} +
+
+ + + {err_password &&
{err_password}
} +
+
+ + + {editingUser.role === 'super_admin' && ( + + )} +
+
+ +
+ {categories.map(cat => ( + + ))} +
+ {err_categories &&
{err_categories}
} +
+ +
+ + ) : ( + <> +

Add New User

+
+
+ + + {err_name &&
{err_name}
} +
+
+ + + {err_username &&
{err_username}
} +
+
+ + + {err_email &&
{err_email}
} +
+
+ + + {err_password &&
{err_password}
} +
+
+ + +
+
+ +
+ {categories.map(cat => ( + + ))} +
+ {err_categories &&
{err_categories}
} +
+ +
+ + )} +
+ +
+

Existing Users

+
+ + + + + + + + + + + + + {users.map(user => ( + + + + + + + + + ))} + +
NameUsernameEmailRoleGroupsActions
{user.name}{user.username || '-'}{user.email} + + {user.role.replace('_', ' ')} + + + {user.categories.length > 0 + ? user.categories.map(c => c.name).join(', ') + : -} + +
+ Edit + {user.email !== session.email && user.role !== 'super_admin' && ( +
+ + +
+ )} +
+
+
+
+
+
+ ) +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..89c8d7d --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { setSession } from '@/lib/auth' + +export async function POST(request: Request) { + try { + const { email, password } = await request.json() + + // In production, compare hashed password + const user = await prisma.user.findFirst({ + where: { + OR: [ + { email }, + { username: email } // treat 'email' field as identifier + ] + } + }) + + if (!user) { + return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }) + } + + // Check if locked out + if (user.lockoutUntil && user.lockoutUntil > new Date()) { + const remainingMs = user.lockoutUntil.getTime() - new Date().getTime() + const remainingMinutes = Math.ceil(remainingMs / 60000) + return NextResponse.json({ error: `Account locked. Try again in ${remainingMinutes} minutes.` }, { status: 429 }) + } + + if (user.password !== password) { + // Increment failed attempts + const newAttempts = user.failedLoginAttempts + 1 + let lockoutDate = null + + if (newAttempts >= 5) { + // Lockout for 10 minutes + lockoutDate = new Date(new Date().getTime() + 10 * 60000) + } + + await prisma.user.update({ + where: { id: user.id }, + data: { + failedLoginAttempts: newAttempts, + lockoutUntil: lockoutDate + } + }) + + if (lockoutDate) { + return NextResponse.json({ error: 'Too many failed attempts. Account locked for 10 minutes.' }, { status: 429 }) + } + + return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }) + } + + // Success! Reset attempts and lockout + await prisma.user.update({ + where: { id: user.id }, + data: { failedLoginAttempts: 0, lockoutUntil: null } + }) + + // Log the successful login + await prisma.activityLog.create({ + data: { + userId: user.id, + action: 'User Login', + details: 'Logged into the system successfully', + type: 'auth' + } + }) + + await setSession({ id: user.id, email: user.email, name: user.name, role: user.role }) + + return NextResponse.json({ success: true }) + } catch (error: any) { + console.error("Login API Error:", error) + return NextResponse.json({ error: error.message || 'Internal Server Error' }, { status: 500 }) + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..e54941b --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server' +import { clearSession, getSession } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +export async function POST() { + const session = await getSession() + if (session) { + await prisma.activityLog.create({ + data: { + userId: session.id, + action: 'User Logout', + details: 'Logged out of the system', + type: 'auth' + } + }) + } + + await clearSession() + return NextResponse.json({ success: true }) +} diff --git a/src/app/api/cron/email-recap-monthly/route.ts b/src/app/api/cron/email-recap-monthly/route.ts new file mode 100644 index 0000000..e397e51 --- /dev/null +++ b/src/app/api/cron/email-recap-monthly/route.ts @@ -0,0 +1,153 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { sendEmailRecap } from '@/lib/email' + +export async function GET() { + try { + const settings = await prisma.systemSettings.findFirst() + if (!settings || !settings.isEmailEnabled) { + return NextResponse.json({ error: 'Email recap is disabled in settings' }, { status: 400 }) + } + + // Get date range for LAST MONTH + const now = new Date() + // First day of last month + const firstDay = new Date(now.getFullYear(), now.getMonth() - 1, 1) + // Last day of last month (0th day of current month) + const lastDay = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999) + + const requests = await prisma.request.findMany({ + where: { + status: 'approved_2', + updatedAt: { + gte: firstDay, + lte: lastDay + } + }, + include: { + item: { include: { category: true } }, + user: true + }, + orderBy: { updatedAt: 'desc' } + }) + + if (requests.length === 0) { + return NextResponse.json({ message: 'No approved requests found for the last month.' }) + } + + // Breakdown stats + let totalItems = 0; + const typeBreakdown: Record = {}; + const itemBreakdown: Record = {}; + + for (const req of requests) { + totalItems += req.quantity; + + // Type breakdown + if (!typeBreakdown[req.type]) { + typeBreakdown[req.type] = { count: 0, qty: 0 }; + } + typeBreakdown[req.type].count += 1; + typeBreakdown[req.type].qty += req.quantity; + + // Item breakdown + if (!itemBreakdown[req.item.name]) { + itemBreakdown[req.item.name] = { qty: 0 }; + } + itemBreakdown[req.item.name].qty += req.quantity; + } + + const formatType = (t: string) => { + if (t === 'billed') return 'Manage Service' + if (t === 'return_billed') return 'Return Manage Service' + if (t === 'return_damaged_billed') return 'Return Damaged Manage Service' + if (t === 'outright_purchase') return 'Outright Purchase' + if (t === 'return_damaged_sold') return 'Return Damaged Sold' + return t.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) + } + + const formatMonth = firstDay.toLocaleString('id-ID', { month: 'long', year: 'numeric' }) + + // Build HTML Template + let html = ` +
+
+

Laporan Bulanan Inventory

+

Periode: ${formatMonth}

+
+ +
+

Halo,

+

Berikut adalah rekapitulasi pergerakan barang (yang disetujui) selama bulan ${formatMonth}.

+ +
+
+

Total Transaksi

+

${requests.length}

+
+
+

Total Barang Bergerak

+

${totalItems}

+
+
+ +

Rekap Berdasarkan Tipe

+ + + + + + + + + + ${Object.entries(typeBreakdown).map(([type, data]) => ` + + + + + + `).join('')} + +
Tipe TransaksiJumlah TransaksiTotal Barang
${formatType(type)}${data.count}${data.qty}
+ +

Top Barang Bergerak

+ + + + + + + + + ${Object.entries(itemBreakdown) + .sort((a, b) => b[1].qty - a[1].qty) + .slice(0, 10) // Top 10 items + .map(([name, data]) => ` + + + + + `).join('')} + +
Nama BarangTotal Keluar/Masuk
${name}${data.qty}
+ +

+ Email ini dibuat secara otomatis oleh Portal Inventory System. +

+
+
+ ` + + const success = await sendEmailRecap(html) + if (success) { + return NextResponse.json({ message: 'Monthly email recap sent successfully' }) + } else { + return NextResponse.json({ error: 'Failed to send email recap. Check settings and SMTP credentials.' }, { status: 500 }) + } + + } catch (error) { + console.error('Email cron error:', error) + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }) + } +} diff --git a/src/app/api/cron/telegram-recap/route.ts b/src/app/api/cron/telegram-recap/route.ts new file mode 100644 index 0000000..032e9f1 --- /dev/null +++ b/src/app/api/cron/telegram-recap/route.ts @@ -0,0 +1,100 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { sendTelegramMessage } from '@/lib/telegram' + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const secret = searchParams.get('secret') + + if (secret !== (process.env.CRON_SECRET || 'inv-secret-123')) { + return NextResponse.json({ error: 'Unauthorized. Invalid or missing secret.' }, { status: 401 }) + } + + // Calculate previous month date range + const now = new Date() + const firstDayPrevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1) + const lastDayPrevMonth = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999) + + const totalRequests = await prisma.request.count({ + where: { + createdAt: { + gte: firstDayPrevMonth, + lte: lastDayPrevMonth + } + } + }) + + const approvedRequestsData = await prisma.request.findMany({ + where: { + status: 'approved_2', + createdAt: { + gte: firstDayPrevMonth, + lte: lastDayPrevMonth + } + } + }) + + const approvedRequests = approvedRequestsData.length; + + let inCount = 0; + let outCount = 0; + let inQty = 0; + let outQty = 0; + + const breakdown: Record = { + purchase: { count: 0, qty: 0 }, + return_borrow: { count: 0, qty: 0 }, + return_trial: { count: 0, qty: 0 }, + return_billed: { count: 0, qty: 0 }, + return_damaged_borrow: { count: 0, qty: 0 }, + return_damaged_trial: { count: 0, qty: 0 }, + return_damaged_billed: { count: 0, qty: 0 }, + return_damaged_sold: { count: 0, qty: 0 }, + borrow: { count: 0, qty: 0 }, + trial: { count: 0, qty: 0 }, + billed: { count: 0, qty: 0 }, + outright_purchase: { count: 0, qty: 0 } + } + + approvedRequestsData.forEach(req => { + if (!breakdown[req.type]) { + breakdown[req.type] = { count: 0, qty: 0 }; + } + breakdown[req.type].count += 1; + breakdown[req.type].qty += req.quantity; + + if (['purchase', 'return_borrow', 'return_trial', 'return_billed', 'return_damaged_borrow', 'return_damaged_trial', 'return_damaged_billed', 'return_damaged_sold'].includes(req.type)) { + inCount += 1; + inQty += req.quantity; + } else if (['borrow', 'trial', 'billed', 'outright_purchase'].includes(req.type)) { + outCount += 1; + outQty += req.quantity; + } + }) + + const monthName = firstDayPrevMonth.toLocaleString('id-ID', { month: 'long', year: 'numeric' }) + let message = `📊 Rekap Inventory Bulanan\nBulan: ${monthName}\n\nTotal Request: ${totalRequests}\nTotal Approved: ${approvedRequests}\n\n` + + message += `📥 BARANG MASUK & RETURN\n` + message += `Total: ${inCount} transaksi (${inQty} barang)\n` + message += `• Purchase: ${breakdown.purchase?.count || 0}\n` + message += `• Return Borrow: ${breakdown.return_borrow?.count || 0}\n` + message += `• Return Trial: ${breakdown.return_trial?.count || 0}\n` + message += `• Return Manage Service: ${breakdown.return_billed?.count || 0}\n` + message += `• Return Damaged (Broken): ${(breakdown.return_damaged_borrow?.count || 0) + (breakdown.return_damaged_trial?.count || 0) + (breakdown.return_damaged_billed?.count || 0) + (breakdown.return_damaged_sold?.count || 0)}\n\n` + + message += `📤 BARANG KELUAR\n` + message += `Total: ${outCount} transaksi (${outQty} barang)\n` + message += `• Borrow: ${breakdown.borrow?.count || 0}\n` + message += `• Trial: ${breakdown.trial?.count || 0}\n` + message += `• Manage Service: ${breakdown.billed?.count || 0}\n` + message += `• Outright Purchase: ${breakdown.outright_purchase?.count || 0}` + + const success = await sendTelegramMessage(message) + + if (!success) { + return NextResponse.json({ error: 'Failed to send Telegram message or telegram is not enabled in settings' }, { status: 500 }) + } + + return NextResponse.json({ success: true, message: 'Recap sent successfully', data: { totalRequests, approvedRequests } }) +} diff --git a/src/app/api/resync/route.ts b/src/app/api/resync/route.ts new file mode 100644 index 0000000..7aba009 --- /dev/null +++ b/src/app/api/resync/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' + +export async function GET() { + const session = await getSession() + if (!session || session.role !== 'super_admin') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const items = await prisma.item.findMany({ + include: { requests: { where: { status: 'approved_2' } } } + }) + + let updatedCount = 0; + + for (const item of items) { + let borrowedQty = 0; + let trialQty = 0; + let billedQty = 0; + let soldQty = 0; + let damagedQty = 0; + + for (const req of item.requests) { + const qty = req.quantity; + let type = req.type.toLowerCase(); + if (type === 'manage service') type = 'billed'; + if (type === 'outright purchase') type = 'outright_purchase'; + + if (type !== req.type) { + await prisma.request.update({ where: { id: req.id }, data: { type } }); + } + + // Borrowed + if (type === 'borrow') borrowedQty += qty; + if (type === 'return_borrow' || type === 'return_damaged_borrow') borrowedQty -= qty; + + // Trial + if (type === 'trial') trialQty += qty; + if (type === 'return_trial' || type === 'return_damaged_trial') trialQty -= qty; + + // Billed + if (type === 'billed') billedQty += qty; + if (type === 'return_billed' || type === 'return_damaged_billed') billedQty -= qty; + + // Sold + if (type === 'outright_purchase') soldQty += qty; + if (type === 'return_damaged_sold') soldQty -= qty; + + // Damaged + if (type === 'return_damaged_borrow' || type === 'return_damaged_trial' || type === 'return_damaged_billed' || type === 'return_damaged_sold') { + damagedQty += qty; + } + } + + // Ensure no negative values just in case of weird data + borrowedQty = Math.max(0, borrowedQty); + trialQty = Math.max(0, trialQty); + billedQty = Math.max(0, billedQty); + soldQty = Math.max(0, soldQty); + damagedQty = Math.max(0, damagedQty); + + const availableQty = Math.max(0, item.totalQuantity - borrowedQty - trialQty - billedQty - damagedQty - soldQty); + + await prisma.item.update({ + where: { id: item.id }, + data: { + borrowedQty, + trialQty, + billedQty, + soldQty, + damagedQty, + availableQty + } + }) + updatedCount++; + } + + return NextResponse.json({ success: true, message: `Resynced ${updatedCount} items based on approval history.` }) + } catch (error: any) { + console.error(error) + return NextResponse.json({ error: error.message }, { status: 500 }) + } +} diff --git a/src/app/api/seed/route.ts b/src/app/api/seed/route.ts new file mode 100644 index 0000000..68b65bf --- /dev/null +++ b/src/app/api/seed/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' + +export async function GET() { + try { + const existing = await prisma.user.findFirst() + if (existing) { + return NextResponse.json({ message: 'Database already has users.' }) + } + + await prisma.user.create({ + data: { + name: 'Super Admin', + username: 'superadmin', + email: 'admin@backone.id', + password: 'password', + role: 'super_admin' + } + }) + + return NextResponse.json({ message: 'Admin user created successfully! You can now login with admin@backone.id / password' }) + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } +} diff --git a/src/app/approvals/page.tsx b/src/app/approvals/page.tsx new file mode 100644 index 0000000..975f0e8 --- /dev/null +++ b/src/app/approvals/page.tsx @@ -0,0 +1,549 @@ +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import { redirect } from 'next/navigation' +import { revalidatePath } from 'next/cache' +import { sendTelegramMessage } from '@/lib/telegram' +import ExportButton from '@/components/ExportButton' +import SelectAllCheckbox from '@/components/SelectAllCheckbox' + +const formatType = (t: string) => { + if (t === 'billed') return 'Manage Service' + if (t === 'return_billed') return 'Return Manage Service' + if (t === 'return_damaged_billed') return 'Return Damaged Manage Service' + if (t === 'outright_purchase') return 'Outright Purchase' + if (t === 'return_damaged_sold') return 'Return Damaged Sold' + return t.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) +} + +export default async function ApprovalsPage({ searchParams }: { searchParams: Promise<{ historyGroup?: string, limit?: string, searchQuery?: string, typeFilter?: string }> }) { + const { historyGroup, limit, searchQuery, typeFilter } = await searchParams + const session = await getSession() + if (!session || (session.role !== 'approver' && session.role !== 'super_admin')) { + redirect('/') + } + + const approver = await prisma.user.findUnique({ where: { id: session.id }, include: { categories: true } }) + + let categoryFilter = {}; + if (session.role !== 'super_admin') { + const allowedCategoryIds = approver?.categories.map(c => c.id) || []; + categoryFilter = { item: { categoryId: { in: allowedCategoryIds } } }; + } + + // Get all requests + let allRequests = await prisma.request.findMany({ + where: categoryFilter, + include: { + user: true, + item: { include: { category: true } }, + approvalLogs: { include: { approver: true } } + }, + orderBy: { createdAt: 'desc' } + }) + + if (typeFilter) { + if (typeFilter === 'damaged') { + allRequests = allRequests.filter(r => r.type.includes('damaged')) + } else { + allRequests = allRequests.filter(r => r.type === typeFilter) + } + } + + const pendingRequests = allRequests.filter(r => r.status === 'pending' || r.status === 'approved_1') + let historyRequests = allRequests.filter(r => r.status === 'approved_2' || r.status === 'rejected') + + if (historyGroup && historyGroup !== 'all') { + historyRequests = historyRequests.filter(r => r.item.categoryId === parseInt(historyGroup)) + } + + if (searchQuery) { + const q = searchQuery.toLowerCase() + historyRequests = historyRequests.filter(r => + r.company.toLowerCase().includes(q) || + r.address.toLowerCase().includes(q) || + (r.serialNumber && r.serialNumber.toLowerCase().includes(q)) + ) + } + + let historyLimit = limit ? parseInt(limit) : 50; + if (isNaN(historyLimit)) historyLimit = 50; + historyRequests = historyRequests.slice(0, historyLimit) + + // Prepare export data + const exportHeaders = [ + { label: 'Date', key: 'date' }, + { label: 'Requester', key: 'requester' }, + { label: 'Item', key: 'item' }, + { label: 'Category', key: 'category' }, + { label: 'Type', key: 'type' }, + { label: 'Quantity', key: 'quantity' }, + { label: 'Company', key: 'company' }, + { label: 'Address', key: 'address' }, + { label: 'Project', key: 'purpose' }, + { label: 'Final Status', key: 'status' }, + { label: 'Serial Number', key: 'serialNumber' }, + { label: 'Approvers', key: 'approvers' }, + { label: 'Notes', key: 'notes' } + ] + + const mappedHistory = historyRequests.map(r => ({ + date: new Date(r.createdAt).toLocaleString('id-ID', { timeZone: 'Asia/Jakarta' }), + requester: r.user.name, + item: r.item.name, + category: r.item.category?.name || '-', + type: formatType(r.type), + quantity: r.quantity.toString(), + company: r.company, + address: r.address, + purpose: r.purpose, + status: r.status, + notes: r.notes || '-', + serialNumber: r.serialNumber || '-', + approvers: r.approvalLogs.map((l: any) => l.approver.name).join(', ') + })) + + async function processApproval(requestId: number, action: string, formData: FormData, skipTelegram: boolean = false) { + 'use server' + const snRaw = formData.get(`sn_${requestId}`) as string | null + const serialNumber = snRaw ? snRaw.trim() : null + const currentSession = await getSession() + if (!currentSession || currentSession.role === 'user') return + + const req = await prisma.request.findUnique({ + where: { id: requestId }, + include: { approvalLogs: true, item: true, user: true } + }) + if (!req) return + + // Cannot approve own request (unless super admin override) + if (req.userId === currentSession.id && currentSession.role !== 'super_admin') return + + if (action === 'reject') { + await prisma.request.update({ + where: { id: requestId }, + data: { status: 'rejected' } + }) + await prisma.approvalLog.create({ + data: { requestId, approverId: currentSession.id, approvalStep: req.status === 'pending' ? 1 : 2, status: 'rejected' } + }) + await prisma.activityLog.create({ + data: { + userId: currentSession.id, + action: 'Reject Request', + details: `Rejected request for ${req.quantity}x ${req.item.name} from ${req.user.name}`, + type: 'approval' + } + }) + + if (!skipTelegram) { + await sendTelegramMessage(`❌ Request Rejected\n\nItem: ${req.quantity}x ${req.item.name}\nUser: ${req.user.name}\nCompany: ${req.company}\nProject: ${req.purpose}\nRejected By: ${currentSession.name}`) + } + } else if (action === 'approve') { + let reqType = req.type; + const newTypeRaw = formData.get(`type_${requestId}`) as string | null + if (newTypeRaw && newTypeRaw !== req.type) { + reqType = newTypeRaw + await prisma.request.update({ + where: { id: requestId }, + data: { type: reqType } + }) + await prisma.activityLog.create({ + data: { + userId: currentSession.id, + action: 'Edit Request', + details: `Changed request type from ${formatType(req.type)} to ${formatType(reqType)} for ${req.item.name}`, + type: 'approval' + } + }) + } + + const isOneStep = ['purchase', 'return_borrow', 'return_trial', 'return_billed', 'return_damaged_borrow', 'return_damaged_trial', 'return_damaged_billed', 'return_damaged_sold'].includes(reqType) + + if (req.status === 'pending') { + if (isOneStep) { + // Finalize immediately (1-step approval) + await prisma.request.update({ + where: { id: requestId }, + data: { + status: 'approved_2', + ...(serialNumber && { serialNumber }) + } + }) + await prisma.approvalLog.create({ + data: { requestId, approverId: currentSession.id, approvalStep: 1, status: 'approved' } + }) + + // Update item quantities + const updateData: any = {} + if (reqType === 'purchase') { + updateData.totalQuantity = req.item.totalQuantity + req.quantity + updateData.availableQty = req.item.availableQty + req.quantity + } else if (reqType === 'return_borrow') { + updateData.availableQty = req.item.availableQty + req.quantity + updateData.borrowedQty = req.item.borrowedQty - req.quantity + } else if (reqType === 'return_trial') { + updateData.availableQty = req.item.availableQty + req.quantity + updateData.trialQty = req.item.trialQty - req.quantity + } else if (reqType === 'return_billed') { + updateData.availableQty = req.item.availableQty + req.quantity + updateData.billedQty = req.item.billedQty - req.quantity + } else if (reqType === 'return_damaged_borrow') { + updateData.damagedQty = req.item.damagedQty + req.quantity + updateData.borrowedQty = req.item.borrowedQty - req.quantity + } else if (reqType === 'return_damaged_trial') { + updateData.damagedQty = req.item.damagedQty + req.quantity + updateData.trialQty = req.item.trialQty - req.quantity + } else if (reqType === 'return_damaged_billed') { + updateData.damagedQty = req.item.damagedQty + req.quantity + updateData.billedQty = req.item.billedQty - req.quantity + } else if (reqType === 'return_damaged_sold') { + updateData.damagedQty = req.item.damagedQty + req.quantity + updateData.soldQty = req.item.soldQty - req.quantity + } + await prisma.item.update({ where: { id: req.itemId }, data: updateData }) + + await prisma.activityLog.create({ + data: { + userId: currentSession.id, + action: 'Approve Request (Final)', + details: `Approved (1-step) request for ${req.quantity}x ${req.item.name} from ${req.user.name}`, + type: 'approval' + } + }) + + await sendTelegramMessage(`🎉 Request Fully Approved (1-Step)\n\nItem: ${req.quantity}x ${req.item.name}\nUser: ${req.user.name}\nCompany: ${req.company}\nProject: ${req.purpose}\nApprover: ${currentSession.name}\nStatus: Finalized.`) + } else { + // Step 1 of 2-step + await prisma.request.update({ + where: { id: requestId }, + data: { + status: 'approved_1', + ...(serialNumber && { serialNumber }) + } + }) + await prisma.approvalLog.create({ + data: { requestId, approverId: currentSession.id, approvalStep: 1, status: 'approved' } + }) + await prisma.activityLog.create({ + data: { + userId: currentSession.id, + action: 'Approve Request (Step 1)', + details: `Provided 1st approval for ${req.quantity}x ${req.item.name} from ${req.user.name}`, + type: 'approval' + } + }) + + if (!skipTelegram) { + await sendTelegramMessage(`✅ Request Partially Approved (Step 1)\n\nItem: ${req.quantity}x ${req.item.name}\nUser: ${req.user.name}\nCompany: ${req.company}\nProject: ${req.purpose}\nApprover 1: ${currentSession.name}\n\nAwaiting final approval.`) + } + } + } else if (req.status === 'approved_1' && !isOneStep) { + // Step 2 (make sure not same user, unless super admin overriding) + const firstApproval = req.approvalLogs.find(log => log.approvalStep === 1) + if (firstApproval && firstApproval.approverId === currentSession.id && currentSession.role !== 'super_admin') { + // Cannot approve twice + return + } + + // Finalize approval + await prisma.request.update({ + where: { id: requestId }, + data: { + status: 'approved_2', + ...(serialNumber && { serialNumber }) + } + }) + await prisma.approvalLog.create({ + data: { requestId, approverId: currentSession.id, approvalStep: 2, status: 'approved' } + }) + + // Update item quantities + const updateData: any = { availableQty: req.item.availableQty - req.quantity } + if (reqType === 'borrow') updateData.borrowedQty = req.item.borrowedQty + req.quantity + else if (reqType === 'trial') updateData.trialQty = req.item.trialQty + req.quantity + else if (reqType === 'billed') updateData.billedQty = req.item.billedQty + req.quantity + else if (reqType === 'outright_purchase') updateData.soldQty = req.item.soldQty + req.quantity + + await prisma.item.update({ + where: { id: req.itemId }, + data: updateData + }) + + await prisma.activityLog.create({ + data: { + userId: currentSession.id, + action: 'Approve Request (Final)', + details: `Provided 2nd approval for ${req.quantity}x ${req.item.name} from ${req.user.name}`, + type: 'approval' + } + }) + + if (!skipTelegram) { + await sendTelegramMessage(`✅ Request Fully Approved (Step 2)\n\nItem: ${req.quantity}x ${req.item.name}\nUser: ${req.user.name}\nCompany: ${req.company}\nProject: ${req.purpose}\nApprover 2: ${currentSession.name}`) + } + } + } + if (!skipTelegram) { + revalidatePath('/approvals') + revalidatePath('/') + } + } + + async function bulkApprove(formData: FormData) { + 'use server' + const requestIds = formData.getAll('requestIds') as string[] + if (!requestIds || requestIds.length === 0) return + + let count = 0; + for (const reqId of requestIds) { + await processApproval(parseInt(reqId), 'approve', formData, true) + count++; + } + + if (count > 0) { + const currentSession = await getSession() + const adminName = currentSession?.name || 'Admin' + sendTelegramMessage(`✅ Bulk Approved\n\n${count} requests were approved simultaneously by ${adminName}.`).catch(console.error) + revalidatePath('/approvals') + revalidatePath('/') + } + } + + async function bulkReject(formData: FormData) { + 'use server' + const requestIds = formData.getAll('requestIds') as string[] + if (!requestIds || requestIds.length === 0) return + + let count = 0; + for (const reqId of requestIds) { + await processApproval(parseInt(reqId), 'reject', formData, true) + count++; + } + + if (count > 0) { + const currentSession = await getSession() + const adminName = currentSession?.name || 'Admin' + sendTelegramMessage(`❌ Bulk Rejected\n\n${count} requests were rejected simultaneously by ${adminName}.`).catch(console.error) + revalidatePath('/approvals') + revalidatePath('/') + } + } + + return ( +
+

Pending Approvals

+ +
+
+
+ + +
+
+ + + + + + + + + + + + + + + {pendingRequests.map((req, index) => { + const isOneStep = ['purchase', 'return_borrow', 'return_trial', 'return_billed'].includes(req.type) + const isStep2 = req.status === 'approved_1' + const firstApproverId = req.approvalLogs.find(l => l.approvalStep === 1)?.approverId + const isOwnRequest = req.userId === session.id + const canApprove = session.role === 'approver' && !isOwnRequest && (!isStep2 || firstApproverId !== session.id) + + return ( + + + + + + + + + + + ) + })} + {pendingRequests.length === 0 && ( + + + + )} + +
+ + No.RequesterItemCategoryDetailsStatusAction
+ + + {index + 1} + + {req.user.name}
+ {req.user.email} +
+ {req.item.name}
+ Type: {formatType(req.type)} | Qty: {req.quantity} +
+ {req.item.category?.name || '-'} + + + {isOneStep ? 'From:' : 'To:'} {req.company}
+ Address: {req.address}
+ Project: {req.purpose} + {req.notes && <>
Notes: {req.notes}} +
+
+ + {isOneStep ? 'Needs Approval (1-step)' : req.status === 'pending' ? 'Needs 1st Approval' : 'Needs 2nd Approval'} + + + {canApprove ? ( +
+ + +
+ + +
+
+ ) : ( + + {isOwnRequest ? 'Cannot approve own request' : 'Awaiting another approver'} + + )} +
+ No pending requests at the moment. +
+
+
+
+ +
+

Approval History

+
+
+ + + + +
+ +
+
+ +
+
+ + + + + + + + + + + + + {historyRequests.map((req, index) => { + const approvers = req.approvalLogs.map(log => log.approver.name).join(', ') + const isOneStep = ['purchase', 'return_borrow', 'return_trial', 'return_billed'].includes(req.type) + return ( + + + + + + + + + ) + })} + {historyRequests.length === 0 && ( + + + + )} + +
No.RequesterItemCategoryDetailsFinal Status
+ {index + 1} + + {req.user.name}
+ {req.user.email}
+ {new Date(req.createdAt).toLocaleString('id-ID', { timeZone: 'Asia/Jakarta' })} +
+ {req.item.name}
+ Type: {formatType(req.type)} | Qty: {req.quantity} +
+ {req.item.category?.name || '-'} + + + {isOneStep ? 'From:' : 'To:'} {req.company}
+ Address: {req.address}
+ Project: {req.purpose} + {req.notes && <>
Notes: {req.notes}} + {req.serialNumber && <>
SN: {req.serialNumber}} +
+
+ + {req.status === 'rejected' ? 'Rejected' : 'Approved (Final)'} + + {approvers && ( +
+ by: {approvers} +
+ )} +
+ No history found. +
+
+
+
+ ) +} diff --git a/src/app/login/page.module.css b/src/app/login/page.module.css new file mode 100644 index 0000000..f5076bb --- /dev/null +++ b/src/app/login/page.module.css @@ -0,0 +1,35 @@ +.container { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 1rem; +} + +.loginCard { + width: 100%; + max-width: 400px; + padding: 2.5rem 2rem; + text-align: center; +} + +.form { + display: flex; + flex-direction: column; + gap: 1.25rem; + margin-top: 2rem; + text-align: left; +} + +.error { + color: var(--danger); + font-size: 0.875rem; + text-align: center; + margin-top: 1rem; +} + +.submitBtn { + margin-top: 1rem; + width: 100%; + padding: 12px; +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..ab3cb84 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,83 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import styles from './page.module.css' + +export default function LoginPage() { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const router = useRouter() + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError('') + + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }) + + if (res.ok) { + router.push('/') + router.refresh() + } else { + const data = await res.json() + setError(data.error || 'Failed to login') + } + } catch (err) { + setError('An error occurred. Please try again.') + } finally { + setLoading(false) + } + } + + return ( +
+
+

Portal Inventory

+

Sign in to manage requests and assets

+ +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ + +
+ + {error &&
{error}
} +
+
+ ) +} diff --git a/src/app/logs/page.tsx b/src/app/logs/page.tsx new file mode 100644 index 0000000..53b85ea --- /dev/null +++ b/src/app/logs/page.tsx @@ -0,0 +1,158 @@ +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import { redirect } from 'next/navigation' +import ExportButton from '@/components/ExportButton' + +export default async function LogsPage(props: { searchParams: Promise<{ q?: string, date?: string }> }) { + const searchParams = await props.searchParams; + const session = await getSession() + if (!session || session.role === 'viewer') { + redirect('/') + } + + // Build filter conditions based on role + let whereClause = {} + + if (session.role === 'super_admin') { + // Super admin sees all logs + whereClause = {} + } else if (session.role === 'approver') { + // Approvers see their own actions, plus all approval and request actions from everyone? + // "tapi user sesuai kelompok, approvals atau user request" + // Let's make approvers see all 'approval', 'request', and 'admin_item' logs. + whereClause = { + type: { in: ['approval', 'request', 'admin_item'] } + } + } else { + // Regular users see only their own requests + whereClause = { + userId: session.id, + type: 'request' + } + } + + // Apply search filters + let filters: any[] = [whereClause] + + if (searchParams.q) { + const q = searchParams.q.toLowerCase() + filters.push({ + OR: [ + { details: { contains: q } }, + { action: { contains: q } }, + { user: { name: { contains: q } } } + ] + }) + } + + if (searchParams.date) { + // Force to Asia/Jakarta (+07:00) bounds + const startOfDay = new Date(`${searchParams.date}T00:00:00+07:00`) + const endOfDay = new Date(`${searchParams.date}T23:59:59.999+07:00`) + filters.push({ + createdAt: { + gte: startOfDay, + lte: endOfDay + } + }) + } + + const logs = await prisma.activityLog.findMany({ + where: { AND: filters }, + take: 50, + include: { user: true }, + orderBy: { createdAt: 'desc' } + }) + + return ( +
+
+

Activity Logs

+

Showing the latest 50 logs matching your criteria.

+
+ +
+
+
+ + +
+
+ + +
+
+ + {(searchParams.q || searchParams.date) && ( + Clear + )} +
+
+
+ +
+
+ + + + + + + + + + + {logs.map((log: any) => ( + + + + + + + ))} + {logs.length === 0 && ( + + + + )} + +
Date & TimeUserActionDetails
+ {new Date(log.createdAt).toLocaleString()} + + {log.user.name}
+ {log.user.role} +
+ + {log.action} + + + {log.details} +
+ No activity logs found. +
+
+
+
+ ) +} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 0000000..e92f7f5 --- /dev/null +++ b/src/app/profile/page.tsx @@ -0,0 +1,135 @@ +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import { redirect } from 'next/navigation' +import { revalidatePath } from 'next/cache' + +export default async function ProfilePage({ searchParams }: { searchParams: Promise<{ success?: string, error?: string, err_email?: string, err_username?: string, err_password?: string, email?: string, username?: string }> }) { + const { success, error, err_email, err_username, err_password, email: spEmail, username: spUsername } = await searchParams + const session = await getSession() + if (!session) { + redirect('/login') + } + + const user = await prisma.user.findUnique({ where: { id: session.id } }) + if (!user) redirect('/login') + + async function updateProfile(formData: FormData) { + 'use server' + const email = formData.get('email') as string + const username = formData.get('username') as string + const password = formData.get('password') as string + + const qs = `&email=${encodeURIComponent(email)}&username=${encodeURIComponent(username)}` + + let hasError = false; + let errEmail = '', errUsername = '', errPassword = ''; + + if (email) { + if (email !== user!.email) { + const checkEmail = await prisma.user.findUnique({ where: { email } }) + if (checkEmail) { + errEmail = 'Email already exists'; + hasError = true; + } + } + } else { + errEmail = 'Email is required'; + hasError = true; + } + + if (username) { + if (username !== user!.username) { + const checkUsername = await prisma.user.findUnique({ where: { username } }) + if (checkUsername) { + errUsername = 'Username already taken'; + hasError = true; + } + } + } else { + errUsername = 'Username is required'; + hasError = true; + } + + if (password) { + if (password.length < 8 || !/[A-Z]/.test(password) || !/[^a-zA-Z0-9]/.test(password)) { + errPassword = 'Password must be at least 8 chars, contain an uppercase letter and a special character'; + hasError = true; + } + } + + if (hasError) { + redirect(`/profile?err_email=${encodeURIComponent(errEmail)}&err_username=${encodeURIComponent(errUsername)}&err_password=${encodeURIComponent(errPassword)}${qs}`) + } + + const updateData: any = { email } + if (username) updateData.username = username + if (password) updateData.password = password + + await prisma.user.update({ + where: { id: session!.id }, + data: updateData + }) + + // Log activity + await prisma.activityLog.create({ + data: { + userId: session!.id, + action: 'Update Profile', + details: 'User updated their own profile credentials', + type: 'auth' + } + }) + + redirect('/profile?success=Profile+updated+successfully') + } + + return ( +
+
+

My Profile

+ + {error && ( +
+ ❌ {error} +
+ )} + + {success && ( +
+ ✅ {success} +
+ )} + +
+
+ + +
+
+ + +
+
+
+ + + {err_username &&
{err_username}
} +
+
+ + + {err_email &&
{err_email}
} +
+
+ + + {err_password &&
{err_password}
} +
+ +
+
+
+ ) +} diff --git a/src/app/request/page.tsx b/src/app/request/page.tsx new file mode 100644 index 0000000..519181f --- /dev/null +++ b/src/app/request/page.tsx @@ -0,0 +1,517 @@ +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import { redirect } from 'next/navigation' +import { revalidatePath } from 'next/cache' +import ExportButton from '@/components/ExportButton' +import { sendTelegramMessage } from '@/lib/telegram' +import CategorySelectListener from '@/components/CategorySelectListener' + +const formatType = (t: string) => { + if (t === 'billed') return 'Manage Service' + if (t === 'return_billed') return 'Return Manage Service' + if (t === 'return_damaged_billed') return 'Return Damaged Manage Service' + if (t === 'outright_purchase') return 'Outright Purchase' + if (t === 'return_damaged_sold') return 'Return Damaged Sold' + return t.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) +} + +export default async function RequestPage({ searchParams }: { searchParams: Promise<{ error?: string, historyGroup?: string, limit?: string, searchQuery?: string }> }) { + const { error, historyGroup, limit, searchQuery } = await searchParams + const session = await getSession() + if (!session || session.role === 'viewer') { + redirect('/') + } + + const user = await prisma.user.findUnique({ where: { id: session.id }, include: { categories: true } }) + const allowedCategories = session.role === 'super_admin' ? await prisma.category.findMany() : (user?.categories || []) + const allowedCategoryIds = allowedCategories.map(c => c.id) + + let items; + if (session.role === 'super_admin') { + items = await prisma.item.findMany({ include: { category: true } }) + } else { + items = await prisma.item.findMany({ + where: { categoryId: { in: allowedCategoryIds } }, + include: { category: true } + }) + } + + let userRequests = await prisma.request.findMany({ + where: { userId: session.id }, + include: { item: { include: { category: true } }, approvalLogs: { include: { approver: true } } }, + orderBy: { createdAt: 'desc' } + }) + + if (historyGroup && historyGroup !== 'all') { + userRequests = userRequests.filter(r => r.item.categoryId === parseInt(historyGroup)) + } + + if (searchQuery) { + const q = searchQuery.toLowerCase() + userRequests = userRequests.filter(r => + r.company.toLowerCase().includes(q) || + r.address.toLowerCase().includes(q) || + (r.serialNumber && r.serialNumber.toLowerCase().includes(q)) + ) + } + + let historyLimit = limit ? parseInt(limit) : 50; + if (isNaN(historyLimit)) historyLimit = 50; + userRequests = userRequests.slice(0, historyLimit) + + // Prepare export data + const exportHeaders = [ + { label: 'Date', key: 'date' }, + { label: 'Item', key: 'item' }, + { label: 'Category', key: 'category' }, + { label: 'Type', key: 'type' }, + { label: 'Quantity', key: 'quantity' }, + { label: 'Company', key: 'company' }, + { label: 'Address', key: 'address' }, + { label: 'Project', key: 'purpose' }, + { label: 'Status', key: 'status' }, + { label: 'Approvers', key: 'approvers' }, + { label: 'Serial Number', key: 'serialNumber' }, + { label: 'Notes', key: 'notes' } + ] + + const mappedRequests = userRequests.map(r => ({ + date: new Date(r.createdAt).toLocaleString('id-ID', { timeZone: 'Asia/Jakarta' }), + item: r.item.name, + category: r.item.category?.name || '-', + type: formatType(r.type), + quantity: r.quantity.toString(), + company: r.company, + address: r.address, + purpose: r.purpose, + status: r.status, + approvers: r.approvalLogs.map((l: any) => l.approver.name).join(', '), + serialNumber: r.serialNumber || '-', + notes: r.notes || '-' + })) + + async function submitRequest(formData: FormData) { + 'use server' + const itemIdStr = formData.get('itemId') as string + const newItemName = formData.get('newItemName') as string + const quantity = parseInt(formData.get('quantity') as string) + const type = formData.get('type') as string + const purpose = formData.get('purpose') as string + const company = formData.get('company') as string + const address = formData.get('address') as string + const categoryIdStr = formData.get('categoryId') as string + const notes = formData.get('notes') as string | null + + if (!itemIdStr || !quantity || !purpose || !company || !address || !type) return + + let item; + + if (itemIdStr === 'new') { + if (!newItemName) return // Error: Must provide name + const categoryId = categoryIdStr ? parseInt(categoryIdStr) : null + if (!categoryId) return // Error: Must select category + + // Case-insensitive search to prevent duplicates like "laptop" and "Laptop" + const allItems = await prisma.item.findMany() + const existingItem = allItems.find(i => i.name.toLowerCase() === newItemName.toLowerCase()) + + if (existingItem) { + item = existingItem + } else { + item = await prisma.item.create({ + data: { + name: newItemName, + categoryId, + availableQty: 0, + borrowedQty: 0, + trialQty: 0, + billedQty: 0 + } + }) + } + } else { + const itemId = parseInt(itemIdStr) + item = await prisma.item.findUnique({ where: { id: itemId } }) + } + + if (!item) return + + // Validation + if (['borrow', 'trial', 'billed', 'outright_purchase'].includes(type) && quantity > item.availableQty) { + // Cannot request more than available + redirect(`/request?error=Cannot+request+more+than+available+stock+(${item.availableQty})`) + } + if ((type === 'return_borrow' || type === 'return_damaged_borrow') && quantity > item.borrowedQty) return + if ((type === 'return_trial' || type === 'return_damaged_trial') && quantity > item.trialQty) return + if ((type === 'return_billed' || type === 'return_damaged_billed') && quantity > item.billedQty) return + if (type === 'return_damaged_sold' && quantity > item.soldQty) return + + const newReq = await prisma.request.create({ + data: { + userId: session!.id, + itemId: item.id, + quantity, + type, + purpose, + company, + address, + notes: notes || null, + status: 'pending' + } + }) + + const user = await prisma.user.findUnique({ where: { id: session!.id } }) + + // Log Activity + await prisma.activityLog.create({ + data: { + userId: session!.id, + action: 'Create Request', + details: `Requested ${quantity}x ${item.name} (${type.replace(/_/g, ' ')})`, + type: 'request' + } + }) + + // Send Telegram Notification + const typeStr = type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) + const msg = `📢 New Request Submitted\n\nUser: ${user?.name || session?.name}\nItem: ${item.name}\nQty: ${quantity}\nType: ${typeStr}\nCompany: ${company}\nAddress: ${address}\nProject: ${purpose}\n\nPlease check the portal to approve or reject.` + await sendTelegramMessage(msg) + + revalidatePath('/request') + } + + async function bulkSubmitRequest(formData: FormData) { + 'use server' + const file = formData.get('csvFile') as File + if (!file || file.size === 0) return + + const text = await file.text() + const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0) + if (lines.length < 2) return // No data rows + + const allItems = await prisma.item.findMany() + const sessionData = await getSession() + if (!sessionData) return + const user = await prisma.user.findUnique({ where: { id: sessionData.id } }) + + let successCount = 0 + // Skip header line + for (let i = 1; i < lines.length; i++) { + // Very simple CSV split handling (assumes no commas/semicolons inside quotes for now) + const cols = lines[i].includes(';') ? lines[i].split(';') : lines[i].split(',') + if (cols.length < 6) continue + + const itemName = cols[0].trim() + const quantity = parseInt(cols[1].trim()) + let type = cols[2].trim().toLowerCase() + if (type === 'manage service') type = 'billed'; + if (type === 'outright purchase') type = 'outright_purchase'; + + const purpose = cols[3].trim() + const company = cols[4].trim() + const address = cols[5].trim() + const serialNumber = cols[6] ? cols[6].trim() : null + const notes = cols[7] ? cols[7].trim() : null + + if (isNaN(quantity) || quantity <= 0) { + redirect(`/request?error=Invalid quantity at row ${i + 1}`) + } + + const item = allItems.find(it => it.name.toLowerCase() === itemName.toLowerCase()) + if (!item) { + redirect(`/request?error=Item "${itemName}" not found at row ${i + 1}`) + } + + const validTypes = ['borrow', 'trial', 'billed', 'outright_purchase', 'return_borrow', 'return_trial', 'return_billed', 'return_damaged_borrow', 'return_damaged_trial', 'return_damaged_billed', 'return_damaged_sold', 'purchase'] + if (!validTypes.includes(type)) { + redirect(`/request?error=Invalid type "${type}" at row ${i + 1}`) + } + + // Validation + if (['borrow', 'trial', 'billed', 'outright_purchase'].includes(type) && quantity > item.availableQty) { + redirect(`/request?error=Not enough available quantity for ${itemName} at row ${i + 1}`) + } + if ((type === 'return_borrow' || type === 'return_damaged_borrow') && quantity > item.borrowedQty) { + redirect(`/request?error=Not enough borrowed quantity for ${itemName} at row ${i + 1}`) + } + if ((type === 'return_trial' || type === 'return_damaged_trial') && quantity > item.trialQty) { + redirect(`/request?error=Not enough trial quantity for ${itemName} at row ${i + 1}`) + } + if ((type === 'return_billed' || type === 'return_damaged_billed') && quantity > item.billedQty) { + redirect(`/request?error=Not enough manage service quantity for ${itemName} at row ${i + 1}`) + } + if (type === 'return_damaged_sold' && quantity > item.soldQty) { + redirect(`/request?error=Not enough sold quantity for ${itemName} at row ${i + 1}`) + } + + await prisma.request.create({ + data: { + userId: sessionData.id, + itemId: item.id, + quantity, + type, + purpose, + company, + address, + notes, + serialNumber, + status: 'pending' + } + }) + successCount++ + } + + if (successCount > 0) { + await prisma.activityLog.create({ + data: { + userId: sessionData.id, + action: 'Bulk Create Request', + details: `Created ${successCount} requests via CSV upload`, + type: 'request' + } + }) + await sendTelegramMessage(`📢 Bulk Request Submitted\n\nUser: ${user?.name || sessionData.name}\nTotal Requests: ${successCount}\n\nPlease check the portal to approve or reject.`) + } + + revalidatePath('/request') + } + + return ( +
+

Item Requests

+ + {error && ( +
+ ❌ {error} +
+ )} + +
+
+
+

New Request

+
+ +
+ + + +
+
+ + +
+
+ + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Bulk Upload Requests

+
+
+

+ Download the CSV template, fill it out in Excel, Save As CSV, and upload it here. +

+ + ⬇️ Download Template CSV + +
+
+ + +
+ +
+
+
+ +
+
+

Your Request History

+
+
+ + + + +
+ +
+
+
+ + + + + + + + + + + + + + {userRequests.map((req, index) => { + const approvers = req.approvalLogs.map(log => log.approver.name).join(', ') + const isReturn = req.type.includes('return') || req.type === 'purchase' + return ( + + + + + + + + + + ) + })} + {userRequests.length === 0 && ( + + + + )} + +
No.DateItemCategoryType/QtyDetailsStatus
+ {index + 1} + + {new Date(req.createdAt).toLocaleString('id-ID', { timeZone: 'Asia/Jakarta' })} + + {req.item.name} + + {req.item.category?.name || '-'} + + {formatType(req.type)} x{req.quantity} + + + {isReturn ? 'From:' : 'To:'} {req.company}
+ Address: {req.address}
+ Project: {req.purpose} +
+
+ + {req.status === 'approved_1' ? '1st Approval' : req.status === 'approved_2' ? 'Approved' : req.status} + + {approvers && ( +
+ by: {approvers} +
+ )} +
+ No requests found. +
+
+
+
+
+ ) +} diff --git a/src/components/AutoRefresh.tsx b/src/components/AutoRefresh.tsx new file mode 100644 index 0000000..f03c873 --- /dev/null +++ b/src/components/AutoRefresh.tsx @@ -0,0 +1,68 @@ +'use client' + +import { useEffect, useState, useRef } from 'react' +import { useRouter } from 'next/navigation' + +export default function AutoRefresh({ interval = 5000, pendingCount }: { interval?: number, pendingCount?: number }) { + const router = useRouter() + const prevCount = useRef(pendingCount) + const [showToast, setShowToast] = useState(false) + + useEffect(() => { + const timer = setInterval(() => { + router.refresh() + }, interval) + + return () => clearInterval(timer) + }, [router, interval]) + + useEffect(() => { + // If pendingCount increases, show a notification + if (pendingCount !== undefined && prevCount.current !== undefined && pendingCount > prevCount.current) { + // Play a simple beep sound if possible + try { + const audio = new Audio('data:audio/wav;base64,UklGRl9vT19XQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YU') // dummy or valid small beep, let's just use visual toast + } catch (e) {} + + setShowToast(true) + setTimeout(() => setShowToast(false), 5000) + } + prevCount.current = pendingCount + }, [pendingCount]) + + if (!showToast) return null + + return ( +
+
+ New Request Needs Approval! + + +
+ ) +} diff --git a/src/components/CategorySelectListener.tsx b/src/components/CategorySelectListener.tsx new file mode 100644 index 0000000..6214a13 --- /dev/null +++ b/src/components/CategorySelectListener.tsx @@ -0,0 +1,18 @@ +'use client' +import { useEffect } from 'react' + +export default function CategorySelectListener() { + useEffect(() => { + const catFilter = document.getElementById('categoryFilter') as HTMLSelectElement | null + const itemSelect = document.getElementById('itemSelect') as HTMLSelectElement | null + + if (catFilter && itemSelect) { + const listener = () => { + itemSelect.value = '' + } + catFilter.addEventListener('change', listener) + return () => catFilter.removeEventListener('change', listener) + } + }, []) + return null +} diff --git a/src/components/ExportButton.tsx b/src/components/ExportButton.tsx new file mode 100644 index 0000000..6b6481d --- /dev/null +++ b/src/components/ExportButton.tsx @@ -0,0 +1,53 @@ +'use client' + +import React from 'react' + +interface ExportButtonProps { + data: Record[] + filename: string + headers: { label: string, key: string }[] + text?: string +} + +export default function ExportButton({ data, filename, headers, text = 'Export to CSV' }: ExportButtonProps) { + const handleExport = () => { + // Generate CSV content + const csvRows = [] + + // Header row + csvRows.push(headers.map(h => `"${h.label}"`).join(';')) + + // Data rows + for (const row of data) { + const values = headers.map(h => { + const value = row[h.key] ?? '' + // Escape quotes + const stringValue = String(value).replace(/"/g, '""') + return `"${stringValue}"` + }) + csvRows.push(values.join(';')) + } + + const csvString = csvRows.join('\n') + const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8;' }) + const link = document.createElement('a') + + const url = URL.createObjectURL(blob) + link.setAttribute('href', url) + link.setAttribute('download', `${filename}.csv`) + link.style.visibility = 'hidden' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + + return ( + + ) +} diff --git a/src/components/Navbar.module.css b/src/components/Navbar.module.css new file mode 100644 index 0000000..7b82247 --- /dev/null +++ b/src/components/Navbar.module.css @@ -0,0 +1,67 @@ +.navbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 2rem; + margin: 1rem auto; + width: calc(100% - 2rem); + max-width: 1200px; + border-radius: 12px; + align-self: center; +} + +.logo { + font-weight: 700; + font-size: 1.25rem; + color: var(--accent); + text-align: center; +} + +.links { + display: flex; + gap: 1.5rem; + align-items: center; +} + +.links a { + font-size: 0.875rem; + font-weight: 500; + transition: color 0.2s ease; +} + +.links a:hover { + color: var(--accent); +} + +.logoutBtn { + background: rgba(239, 68, 68, 0.1); + color: var(--danger); + border: 1px solid rgba(239, 68, 68, 0.2); + padding: 6px 12px; + border-radius: 6px; + cursor: pointer; + font-weight: 500; + transition: all 0.2s ease; +} + +.logoutBtn:hover { + background: var(--danger); + color: white; +} + +@media (max-width: 768px) { + .navbar { + flex-direction: column; + gap: 1rem; + padding: 1rem; + width: calc(100% - 2rem); + margin: 1rem auto; + align-self: center; + } + + .links { + flex-wrap: wrap; + justify-content: center; + gap: 1rem; + } +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx new file mode 100644 index 0000000..b3d6aa5 --- /dev/null +++ b/src/components/Navbar.tsx @@ -0,0 +1,51 @@ +'use client' + +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import styles from './Navbar.module.css' + +export default function Navbar({ role, name, pendingCount = 0 }: { role: string, name?: string, pendingCount?: number }) { + const router = useRouter() + + const handleLogout = async () => { + await fetch('/api/auth/logout', { method: 'POST' }) + router.push('/login') + router.refresh() + } + + return ( + + ) +} diff --git a/src/components/SelectAllCheckbox.tsx b/src/components/SelectAllCheckbox.tsx new file mode 100644 index 0000000..ad4788f --- /dev/null +++ b/src/components/SelectAllCheckbox.tsx @@ -0,0 +1,16 @@ +'use client'; + +export default function SelectAllCheckbox() { + return ( + { + const checkboxes = document.querySelectorAll('input[name="requestIds"]:not(:disabled)') as NodeListOf; + for (let cb of checkboxes) { + cb.checked = e.target.checked; + } + }} + style={{ cursor: 'pointer' }} + /> + ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..d6b5d9b --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,66 @@ +import { jwtVerify, SignJWT } from 'jose' +import { cookies } from 'next/headers' + +const secretKey = process.env.SESSION_SECRET || 'super-secret-key-for-development' +const key = new TextEncoder().encode(secretKey) + +export async function encrypt(payload: any) { + return await new SignJWT(payload) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('2h') + .sign(key) +} + +export async function decrypt(input: string): Promise { + const { payload } = await jwtVerify(input, key, { + algorithms: ['HS256'], + }) + return payload +} + +export async function getSession() { + const cookieStore = await cookies() + const session = cookieStore.get('session')?.value + if (!session) return null + try { + return await decrypt(session) + } catch (error) { + return null + } +} + +export async function setSession(user: { id: number, email: string, name?: string, role: string }) { + const session = await encrypt(user) + const cookieStore = await cookies() + cookieStore.set('session', session, { + httpOnly: true, + secure: false, // Set to false to allow login via IP over HTTP on local networks + sameSite: 'lax', + path: '/', + maxAge: 2 * 60 * 60 // 2 hours + }) +} + +export async function updateSession(request: any) { + const session = request.cookies.get('session')?.value + if (!session) return null + + try { + // Verify it + const parsed = await decrypt(session) + // Re-encrypt it to reset the '2h' expiration + const updatedSession = await encrypt({ id: parsed.id, email: parsed.email, name: parsed.name, role: parsed.role }) + + // Create new response if not available, but usually we just return the new cookie value + // In middleware, we set it on the response + return updatedSession + } catch { + return null + } +} + +export async function clearSession() { + const cookieStore = await cookies() + cookieStore.delete('session') +} diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..71cfb27 --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,38 @@ +import { prisma } from './prisma' +import nodemailer from 'nodemailer' + +export async function sendEmailRecap(htmlContent: string) { + try { + const settings = await prisma.systemSettings.findFirst() + if (!settings || !settings.isEmailEnabled || !settings.smtpHost || !settings.smtpPort || !settings.smtpUser || !settings.smtpPass || !settings.targetEmail) { + return false + } + + const transporter = nodemailer.createTransport({ + host: settings.smtpHost, + port: settings.smtpPort, + secure: settings.smtpPort === 465, // true for 465, false for other ports + auth: { + user: settings.smtpUser, + pass: settings.smtpPass, + }, + }) + + const targets = settings.targetEmail.split(',').map(e => e.trim()).filter(e => e.length > 0) + + if (targets.length === 0) return false + + const mailOptions = { + from: `"Inventory System" <${settings.smtpUser}>`, + to: targets.join(', '), + subject: `Laporan Rekap Bulanan Inventory - ${new Date().toLocaleString('id-ID', { month: 'long', year: 'numeric' })}`, + html: htmlContent, + } + + await transporter.sendMail(mailOptions) + return true + } catch (err) { + console.error('Error sending email:', err) + return false + } +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..d4cf44a --- /dev/null +++ b/src/lib/prisma.ts @@ -0,0 +1,11 @@ +import { PrismaClient } from '@prisma/client' +import { PrismaLibSql } from '@prisma/adapter-libsql' +const adapter = new PrismaLibSql({ + url: 'file:dev.db', +}) + +const globalForPrisma = globalThis as unknown as { prisma: PrismaClient } + +export const prisma = globalForPrisma.prisma || new PrismaClient({ adapter }) + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma diff --git a/src/lib/telegram.ts b/src/lib/telegram.ts new file mode 100644 index 0000000..af9a861 --- /dev/null +++ b/src/lib/telegram.ts @@ -0,0 +1,33 @@ +import { prisma } from './prisma' + +export async function sendTelegramMessage(text: string) { + try { + const settings = await prisma.systemSettings.findFirst() + if (!settings || !settings.isTelegramEnabled || !settings.telegramBotToken || !settings.telegramChatId) { + return false + } + + const url = `https://api.telegram.org/bot${settings.telegramBotToken}/sendMessage` + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + chat_id: settings.telegramChatId, + text, + parse_mode: 'HTML' + }) + }) + + if (!res.ok) { + console.error('Failed to send telegram message:', await res.text()) + return false + } + + return true + } catch (err) { + console.error('Error sending telegram message:', err) + return false + } +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..38ceb31 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' +import { updateSession } from '@/lib/auth' + +export async function middleware(request: NextRequest) { + const session = request.cookies.get('session')?.value + + const isAuthPage = request.nextUrl.pathname.startsWith('/login') + + if (!session && !isAuthPage) { + return NextResponse.redirect(new URL('/login', request.url)) + } + + if (session && isAuthPage) { + return NextResponse.redirect(new URL('/', request.url)) + } + + // Handle rolling session + let res = NextResponse.next() + + if (session && !isAuthPage) { + const newSessionToken = await updateSession(request) + if (newSessionToken) { + res.cookies.set('session', newSessionToken, { + httpOnly: true, + secure: false, + sameSite: 'lax', + path: '/', + maxAge: 2 * 60 * 60 // 2 hours + }) + } + } + + return res +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + */ + '/((?!api|_next/static|_next/image|favicon.ico).*)', + ], +}