Migrate architecture to PostgreSQL for Swarm HA

This commit is contained in:
ypratama committed 2026-08-12 14:32:00 +07:00
1 parent ee28968a70
commit 2317f9f133
39 files changed
+3849 -10

No files matched your search

+13
View File
@@ -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/
+63
View File
@@ -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"]
+8
View File
@@ -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());
+24 -10
View File
@@ -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
BIN
View File
Binary file not shown.
+14
View File
@@ -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"],
},
});
+112
View File
@@ -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())
}
+60
View File
@@ -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()
})
+27
View File
@@ -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();
}
+45
View File
@@ -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())
+80
View File
@@ -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())
+16
View File
@@ -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());
+166
View File
@@ -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 (
<main style={{ padding: '1rem 2rem', maxWidth: '1200px', margin: '0 auto' }}>
<h1 className="heading" style={{ marginBottom: '2rem' }}>Category Management</h1>
{error && (
<div style={{ padding: '1rem', background: 'rgba(239, 68, 68, 0.1)', color: 'var(--danger)', borderRadius: '8px', marginBottom: '1.5rem', fontWeight: 500 }}>
❌ {error}
</div>
)}
<div className="page-grid">
<div className="glass-panel" style={{ padding: '1.5rem', height: 'fit-content' }}>
{editingCategory ? (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 className="heading" style={{ fontSize: '1.25rem' }}>Edit Category</h2>
<a href="/admin/categories" style={{ color: 'var(--text-muted)', textDecoration: 'none', fontSize: '0.9rem' }}>Cancel</a>
</div>
<form action={editCategory} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<input type="hidden" name="id" value={editingCategory.id} />
<div>
<label className="label">Category Name</label>
<input type="text" name="name" className="input-field" defaultValue={spName || editingCategory.name} required />
</div>
<button type="submit" className="btn-primary" style={{ marginTop: '0.5rem', background: 'var(--accent)' }}>Update Category</button>
</form>
</>
) : (
<>
<h2 className="heading" style={{ fontSize: '1.25rem', marginBottom: '1rem' }}>Add New Category</h2>
<form action={addCategory} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
<label className="label">Category Name</label>
<input name="name" type="text" className="input-field" defaultValue={spName || ''} required />
</div>
<button type="submit" className="btn-primary" style={{ marginTop: '0.5rem' }}>Create Category</button>
</form>
</>
)}
</div>
<div className="glass-panel" style={{ padding: '1.5rem' }}>
<h2 className="heading" style={{ fontSize: '1.25rem', marginBottom: '1rem' }}>Existing Categories</h2>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
<thead>
<tr>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>ID</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Name</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Items Count</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Users Count</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Actions</th>
</tr>
</thead>
<tbody>
{categories.map(category => (
<tr key={category.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<td style={{ padding: '1rem' }}>#{category.id}</td>
<td style={{ padding: '1rem' }}>{category.name}</td>
<td style={{ padding: '1rem' }}>{category._count.items}</td>
<td style={{ padding: '1rem' }}>{category._count.users}</td>
<td style={{ padding: '1rem' }}>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<a href={`/admin/categories?edit=${category.id}`} className="btn-secondary" style={{ padding: '0.4rem 0.8rem', fontSize: '0.85rem', textDecoration: 'none' }}>
Edit
</a>
<form action={deleteCategory}>
<input type="hidden" name="id" value={category.id} />
<button type="submit" className="btn-secondary" style={{ padding: '0.4rem 0.8rem', fontSize: '0.85rem', color: 'var(--danger)', borderColor: 'var(--danger)' }}>
Delete
</button>
</form>
</div>
</td>
</tr>
))}
{categories.length === 0 && (
<tr>
<td colSpan={5} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>
No categories found. Create one to get started.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</main>
)
}
+153
View File
@@ -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 (
<main style={{ padding: '1rem 2rem', maxWidth: '1200px', margin: '0 auto' }}>
<h1 className="heading" style={{ marginBottom: '2rem' }}>Item Management</h1>
<div className="page-grid">
<div className="glass-panel" style={{ padding: '1.5rem', height: 'fit-content' }}>
<h2 className="heading" style={{ fontSize: '1.25rem', marginBottom: '1rem' }}>Add Stock</h2>
<form action={addItem} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
<label className="label">Category</label>
<select name="categoryId" className="input-field" required>
<option value="">Select Category...</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
<div>
<label className="label">Item Name</label>
<input name="name" type="text" className="input-field" placeholder="Type new item name, or existing name..." list="item-list" required autoComplete="off" />
<datalist id="item-list">
{items.map(item => (
<option key={item.id} value={item.name} />
))}
</datalist>
</div>
<div>
<label className="label">Add Quantity (Optional for existing items)</label>
<input name="quantity" type="number" min="0" className="input-field" defaultValue="0" />
</div>
<button type="submit" className="btn-primary" style={{ marginTop: '0.5rem' }}>Update / Add Item</button>
</form>
<p style={{ marginTop: '1rem', fontSize: '0.8rem', color: 'var(--text-muted)' }}>
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.
</p>
</div>
<div className="glass-panel" style={{ padding: '1.5rem' }}>
<h2 className="heading" style={{ fontSize: '1.25rem', marginBottom: '1rem' }}>Inventory Status</h2>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
<thead>
<tr>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Item Name</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Category</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Total Stock</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Available</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Damaged</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Sold</th>
</tr>
</thead>
<tbody>
{items.map(item => (
<tr key={item.id}>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', fontWeight: 500 }}>{item.name}</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
{item.category?.name || '-'}
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>{item.totalQuantity}</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>
<span style={{
padding: '4px 10px', borderRadius: '99px', fontSize: '0.875rem', fontWeight: 600,
background: 'rgba(16, 185, 129, 0.15)', color: 'var(--success)'
}}>
{item.availableQty}
</span>
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--danger)', fontWeight: 600 }}>{item.damagedQty}</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>{item.soldQty}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</main>
)
}
+280
View File
@@ -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('🔔 <b>Test Notification</b>\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<string, { count: number, qty: number }> = {
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 = `📊 <b>[TEST] Rekap Inventory Bulanan</b>\nBulan: ${monthName}\n\n<b>Total Request:</b> ${totalRequests}\n<b>Total Approved:</b> ${approvedRequests}\n\n`
message += `<b>📥 BARANG MASUK & RETURN</b>\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 += `<b>📤 BARANG KELUAR</b>\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 (
<main style={{ padding: '1rem 2rem', maxWidth: '800px', margin: '0 auto' }}>
<h1 className="heading" style={{ marginBottom: '2rem' }}>System Settings</h1>
{success === 'true' && (
<div style={{ padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', color: 'var(--success)', borderRadius: '8px', marginBottom: '1.5rem', fontWeight: 500 }}>
✅ Pengaturan berhasil disimpan!
</div>
)}
{test === 'success' && (
<div style={{ padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', color: 'var(--success)', borderRadius: '8px', marginBottom: '1.5rem', fontWeight: 500 }}>
✅ Pesan uji coba berhasil dikirim ke Telegram!
</div>
)}
{test === 'error' && (
<div style={{ padding: '1rem', background: 'rgba(239, 68, 68, 0.1)', color: 'var(--danger)', borderRadius: '8px', marginBottom: '1.5rem', fontWeight: 500 }}>
❌ Gagal mengirim pesan. Pastikan Token Bot dan Chat ID sudah benar & disimpan.
</div>
)}
{test_recap === 'success' && (
<div style={{ padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', color: 'var(--success)', borderRadius: '8px', marginBottom: '1.5rem', fontWeight: 500 }}>
✅ Laporan rekap bulanan berhasil dikirim ke Telegram!
</div>
)}
{test_recap === 'error' && (
<div style={{ padding: '1rem', background: 'rgba(239, 68, 68, 0.1)', color: 'var(--danger)', borderRadius: '8px', marginBottom: '1.5rem', fontWeight: 500 }}>
❌ Gagal mengirim rekap. Pastikan integrasi Telegram sudah diaktifkan dan dikonfigurasi.
</div>
)}
<div className="glass-panel" style={{ padding: '2rem' }}>
<h2 className="heading" style={{ fontSize: '1.25rem', marginBottom: '1rem' }}>Telegram Notifications</h2>
<p style={{ color: 'var(--text-muted)', marginBottom: '1.5rem', fontSize: '0.9rem' }}>
Configure a Telegram Bot to receive real-time notifications for new requests and approvals.
</p>
<form action={updateSettings} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div>
<label className="label">Enable Telegram Notifications</label>
<select name="isEnabled" className="input-field" defaultValue={settings.isTelegramEnabled ? 'true' : 'false'}>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
</div>
<div>
<label className="label">Bot Token</label>
<input
name="botToken"
type="text"
className="input-field"
defaultValue={settings.telegramBotToken || ''}
placeholder="e.g. 123456789:ABCdefGHIjklmNoPQRstUVwxyZ"
/>
</div>
<div>
<label className="label">Chat ID</label>
<input
name="chatId"
type="text"
className="input-field"
defaultValue={settings.telegramChatId || ''}
placeholder="e.g. -1001234567890 or 123456789"
/>
</div>
<div style={{ padding: '1rem', background: 'var(--glass-bg)', borderRadius: '8px', border: '1px solid var(--glass-border)', marginTop: '2rem' }}>
<h2 style={{ fontSize: '1.1rem', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
📧 Email Recap Configuration
</h2>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', color: 'var(--text-muted)' }}>Enable Email Monthly Recap</label>
<select name="isEmailEnabled" defaultValue={settings.isEmailEnabled ? 'true' : 'false'} className="input-field" style={{ width: '100%', background: 'var(--bg-color)', color: 'var(--text-color)' }}>
<option value="false">Disabled</option>
<option value="true">Enabled</option>
</select>
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', color: 'var(--text-muted)' }}>SMTP Host</label>
<input type="text" name="smtpHost" defaultValue={settings.smtpHost || ''} className="input-field" placeholder="e.g. smtp.gmail.com" style={{ width: '100%', background: 'var(--bg-color)', color: 'var(--text-color)' }} />
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', color: 'var(--text-muted)' }}>SMTP Port</label>
<input type="number" name="smtpPort" defaultValue={settings.smtpPort || ''} className="input-field" placeholder="e.g. 465 or 587" style={{ width: '100%', background: 'var(--bg-color)', color: 'var(--text-color)' }} />
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', color: 'var(--text-muted)' }}>SMTP Username</label>
<input type="text" name="smtpUser" defaultValue={settings.smtpUser || ''} className="input-field" placeholder="Your Email" style={{ width: '100%', background: 'var(--bg-color)', color: 'var(--text-color)' }} />
</div>
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', color: 'var(--text-muted)' }}>SMTP Password</label>
<input type="password" name="smtpPass" defaultValue={settings.smtpPass || ''} className="input-field" placeholder="App Password" style={{ width: '100%', background: 'var(--bg-color)', color: 'var(--text-color)' }} />
</div>
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', color: 'var(--text-muted)' }}>Target Emails (Comma Separated)</label>
<input type="text" name="targetEmail" defaultValue={settings.targetEmail || ''} className="input-field" placeholder="ceo@company.com, admin@company.com" style={{ width: '100%', background: 'var(--bg-color)', color: 'var(--text-color)' }} />
</div>
</div>
<div style={{ display: 'flex', gap: '1rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
<button type="submit" className="btn-primary" style={{ flex: 1, minWidth: '150px' }}>Save Settings</button>
<button formAction={testTelegram} style={{ flex: 1, minWidth: '150px', background: 'var(--accent)', color: 'white', border: 'none', padding: '0.75rem', borderRadius: '8px', fontWeight: 600, cursor: 'pointer' }}>
Test Telegram
</button>
<button formAction={testTelegramRecap} style={{ flex: 1, minWidth: '150px', background: 'transparent', color: 'var(--accent)', border: '1px solid var(--accent)', padding: '0.75rem', borderRadius: '8px', fontWeight: 600, cursor: 'pointer' }}>
Test Recap Bulanan
</button>
</div>
</form>
</div>
</main>
)
}
+381
View File
@@ -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 (
<main style={{ padding: '1rem 2rem', maxWidth: '1200px', margin: '0 auto' }}>
<h1 className="heading" style={{ marginBottom: '2rem' }}>User Management</h1>
{error && (
<div style={{ padding: '1rem', background: 'rgba(239, 68, 68, 0.1)', color: 'var(--danger)', borderRadius: '8px', marginBottom: '1.5rem', fontWeight: 500 }}>
❌ {error}
</div>
)}
<div className="page-grid">
<div className="glass-panel" style={{ padding: '1.5rem', height: 'fit-content' }}>
{editingUser ? (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 className="heading" style={{ fontSize: '1.25rem' }}>Edit User</h2>
<a href="/admin/users" style={{ color: 'var(--text-muted)', textDecoration: 'none', fontSize: '0.9rem' }}>Cancel</a>
</div>
<form action={editUser} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<input type="hidden" name="id" value={editingUser.id} />
<div>
<label className="label">Name</label>
<input type="text" name="name" className="input-field" defaultValue={spName || editingUser.name} disabled={editingUser.role === 'super_admin'} required />
{err_name && <div style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '0.25rem' }}>{err_name}</div>}
{editingUser.role === 'super_admin' && (
<input type="hidden" name="name" value={editingUser.name} />
)}
</div>
<div>
<label className="label">Username</label>
<input name="username" type="text" className="input-field" defaultValue={spUsername || editingUser.username || ''} required />
{err_username && <div style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '0.25rem' }}>{err_username}</div>}
</div>
<div>
<label className="label">Email</label>
<input name="email" type="email" className="input-field" defaultValue={spEmail || editingUser.email} required />
{err_email && <div style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '0.25rem' }}>{err_email}</div>}
</div>
<div>
<label className="label">New Password (leave blank to keep current) <span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>(Min 8 chars, 1 uppercase, 1 special)</span></label>
<input name="password" type="text" className="input-field" placeholder="Enter new password..." />
{err_password && <div style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '0.25rem' }}>{err_password}</div>}
</div>
<div>
<label className="label">Role</label>
<select name="role" className="input-field" required defaultValue={spRole || editingUser.role} disabled={editingUser.role === 'super_admin'}>
<option value="user">User (Can Request)</option>
<option value="viewer">Viewer (Dashboard Only)</option>
<option value="approver">Approver</option>
<option value="super_admin">Super Admin</option>
</select>
{editingUser.role === 'super_admin' && (
<input type="hidden" name="role" value="super_admin" />
)}
</div>
<div>
<label className="label">Group / Categories <span style={{ color: 'var(--danger)' }}>*</span></label>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginTop: '0.5rem' }}>
{categories.map(cat => (
<label key={cat.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<input type="checkbox" name="categories" value={cat.id} defaultChecked={editingUser.categories.some(c => c.id === cat.id)} />
{cat.name}
</label>
))}
</div>
{err_categories && <div style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '0.25rem' }}>{err_categories}</div>}
</div>
<button type="submit" className="btn-primary" style={{ marginTop: '0.5rem', background: 'var(--accent)' }}>Update User</button>
</form>
</>
) : (
<>
<h2 className="heading" style={{ fontSize: '1.25rem', marginBottom: '1rem' }}>Add New User</h2>
<form action={addUser} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
<label className="label">Name (Minimum 2 words)</label>
<input name="name" type="text" className="input-field" defaultValue={spName || ''} required />
{err_name && <div style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '0.25rem' }}>{err_name}</div>}
</div>
<div>
<label className="label">Username</label>
<input name="username" type="text" className="input-field" defaultValue={spUsername || ''} required />
{err_username && <div style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '0.25rem' }}>{err_username}</div>}
</div>
<div>
<label className="label">Email</label>
<input name="email" type="email" className="input-field" defaultValue={spEmail || ''} required />
{err_email && <div style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '0.25rem' }}>{err_email}</div>}
</div>
<div>
<label className="label">Password <span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>(Min 8 chars, 1 uppercase, 1 special)</span></label>
<input name="password" type="password" className="input-field" required />
{err_password && <div style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '0.25rem' }}>{err_password}</div>}
</div>
<div>
<label className="label">Role</label>
<select name="role" className="input-field" required defaultValue={spRole || "user"}>
<option value="user">User (Can Request)</option>
<option value="viewer">Viewer (Dashboard Only)</option>
<option value="approver">Approver</option>
<option value="super_admin">Super Admin</option>
</select>
</div>
<div>
<label className="label">Group / Categories <span style={{ color: 'var(--danger)' }}>*</span></label>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginTop: '0.5rem' }}>
{categories.map(cat => (
<label key={cat.id} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<input type="checkbox" name="categories" value={cat.id} />
{cat.name}
</label>
))}
</div>
{err_categories && <div style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '0.25rem' }}>{err_categories}</div>}
</div>
<button type="submit" className="btn-primary" style={{ marginTop: '0.5rem' }}>Create User</button>
</form>
</>
)}
</div>
<div className="glass-panel" style={{ padding: '1.5rem' }}>
<h2 className="heading" style={{ fontSize: '1.25rem', marginBottom: '1rem' }}>Existing Users</h2>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
<thead>
<tr>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Name</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Username</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Email</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Role</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Groups</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Actions</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.id} style={{ background: editingUser?.id === user.id ? 'rgba(255,255,255,0.05)' : 'transparent' }}>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>{user.name}</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>{user.username || '-'}</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>{user.email}</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>
<span className={`role-badge role-${user.role}`}>
{user.role.replace('_', ' ')}
</span>
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', fontSize: '0.9rem' }}>
{user.categories.length > 0
? user.categories.map(c => c.name).join(', ')
: <span style={{ color: 'var(--text-muted)' }}>-</span>}
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>
<div style={{ display: 'flex', gap: '1rem' }}>
<a href={`/admin/users?edit=${user.id}`} style={{ color: 'var(--accent)', textDecoration: 'none', fontWeight: 500 }}>Edit</a>
{user.email !== session.email && user.role !== 'super_admin' && (
<form action={deleteUser}>
<input type="hidden" name="id" value={user.id} />
<button type="submit" style={{ background: 'transparent', border: 'none', color: 'var(--danger)', cursor: 'pointer', fontWeight: 500 }}>Delete</button>
</form>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</main>
)
}
+78
View File
@@ -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 })
}
}
+20
View File
@@ -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 })
}
@@ -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<string, { count: number, qty: number }> = {};
const itemBreakdown: Record<string, { qty: number }> = {};
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 = `
<div style="font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; color: #333;">
<div style="background-color: #0f172a; padding: 20px; text-align: center; border-radius: 8px 8px 0 0;">
<h1 style="color: #fff; margin: 0;">Laporan Bulanan Inventory</h1>
<p style="color: #94a3b8; margin: 5px 0 0 0;">Periode: ${formatMonth}</p>
</div>
<div style="padding: 20px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
<p>Halo,</p>
<p>Berikut adalah rekapitulasi pergerakan barang (yang disetujui) selama bulan <b>${formatMonth}</b>.</p>
<div style="display: flex; gap: 20px; margin-bottom: 20px;">
<div style="background: #f1f5f9; padding: 15px; border-radius: 8px; flex: 1; text-align: center;">
<h3 style="margin: 0; color: #475569; font-size: 14px; text-transform: uppercase;">Total Transaksi</h3>
<p style="margin: 5px 0 0 0; font-size: 24px; font-weight: bold; color: #0f172a;">${requests.length}</p>
</div>
<div style="background: #f1f5f9; padding: 15px; border-radius: 8px; flex: 1; text-align: center;">
<h3 style="margin: 0; color: #475569; font-size: 14px; text-transform: uppercase;">Total Barang Bergerak</h3>
<p style="margin: 5px 0 0 0; font-size: 24px; font-weight: bold; color: #0f172a;">${totalItems}</p>
</div>
</div>
<h2 style="font-size: 18px; border-bottom: 2px solid #e2e8f0; padding-bottom: 5px; color: #0f172a;">Rekap Berdasarkan Tipe</h2>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<thead>
<tr style="background-color: #f8fafc;">
<th style="padding: 10px; text-align: left; border: 1px solid #e2e8f0;">Tipe Transaksi</th>
<th style="padding: 10px; text-align: right; border: 1px solid #e2e8f0;">Jumlah Transaksi</th>
<th style="padding: 10px; text-align: right; border: 1px solid #e2e8f0;">Total Barang</th>
</tr>
</thead>
<tbody>
${Object.entries(typeBreakdown).map(([type, data]) => `
<tr>
<td style="padding: 10px; border: 1px solid #e2e8f0;">${formatType(type)}</td>
<td style="padding: 10px; text-align: right; border: 1px solid #e2e8f0;">${data.count}</td>
<td style="padding: 10px; text-align: right; border: 1px solid #e2e8f0;">${data.qty}</td>
</tr>
`).join('')}
</tbody>
</table>
<h2 style="font-size: 18px; border-bottom: 2px solid #e2e8f0; padding-bottom: 5px; color: #0f172a;">Top Barang Bergerak</h2>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<thead>
<tr style="background-color: #f8fafc;">
<th style="padding: 10px; text-align: left; border: 1px solid #e2e8f0;">Nama Barang</th>
<th style="padding: 10px; text-align: right; border: 1px solid #e2e8f0;">Total Keluar/Masuk</th>
</tr>
</thead>
<tbody>
${Object.entries(itemBreakdown)
.sort((a, b) => b[1].qty - a[1].qty)
.slice(0, 10) // Top 10 items
.map(([name, data]) => `
<tr>
<td style="padding: 10px; border: 1px solid #e2e8f0;">${name}</td>
<td style="padding: 10px; text-align: right; border: 1px solid #e2e8f0;">${data.qty}</td>
</tr>
`).join('')}
</tbody>
</table>
<p style="font-size: 12px; color: #94a3b8; text-align: center; margin-top: 30px;">
Email ini dibuat secara otomatis oleh Portal Inventory System.
</p>
</div>
</div>
`
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 })
}
}
+100
View File
@@ -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<string, { count: number, qty: number }> = {
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 = `📊 <b>Rekap Inventory Bulanan</b>\nBulan: ${monthName}\n\n<b>Total Request:</b> ${totalRequests}\n<b>Total Approved:</b> ${approvedRequests}\n\n`
message += `<b>📥 BARANG MASUK & RETURN</b>\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 += `<b>📤 BARANG KELUAR</b>\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 } })
}
+85
View File
@@ -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 })
}
}
+25
View File
@@ -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 })
}
}
+549
View File
@@ -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(`❌ <b>Request Rejected</b>\n\n<b>Item:</b> ${req.quantity}x ${req.item.name}\n<b>User:</b> ${req.user.name}\n<b>Company:</b> ${req.company}\n<b>Project:</b> ${req.purpose}\n<b>Rejected By:</b> ${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(`🎉 <b>Request Fully Approved (1-Step)</b>\n\n<b>Item:</b> ${req.quantity}x ${req.item.name}\n<b>User:</b> ${req.user.name}\n<b>Company:</b> ${req.company}\n<b>Project:</b> ${req.purpose}\n<b>Approver:</b> ${currentSession.name}\n<b>Status:</b> 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(`✅ <b>Request Partially Approved (Step 1)</b>\n\n<b>Item:</b> ${req.quantity}x ${req.item.name}\n<b>User:</b> ${req.user.name}\n<b>Company:</b> ${req.company}\n<b>Project:</b> ${req.purpose}\n<b>Approver 1:</b> ${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(`✅ <b>Request Fully Approved (Step 2)</b>\n\n<b>Item:</b> ${req.quantity}x ${req.item.name}\n<b>User:</b> ${req.user.name}\n<b>Company:</b> ${req.company}\n<b>Project:</b> ${req.purpose}\n<b>Approver 2:</b> ${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(`✅ <b>Bulk Approved</b>\n\n<b>${count} requests</b> 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(`❌ <b>Bulk Rejected</b>\n\n<b>${count} requests</b> were rejected simultaneously by ${adminName}.`).catch(console.error)
revalidatePath('/approvals')
revalidatePath('/')
}
}
return (
<main style={{ padding: '1rem 2rem', maxWidth: '1200px', margin: '0 auto' }}>
<h1 className="heading" style={{ marginBottom: '2rem' }}>Pending Approvals</h1>
<div className="glass-panel" style={{ padding: '1.5rem' }}>
<form>
<div style={{ marginBottom: '1rem', display: 'flex', gap: '1rem' }}>
<button type="submit" formAction={bulkApprove} className="btn-primary">✅ Bulk Approve Selected</button>
<button type="submit" formAction={bulkReject} className="btn-primary">❌ Bulk Reject Selected</button>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
<thead>
<tr>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', width: '40px' }}>
<SelectAllCheckbox />
</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>No.</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>Requester</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>Item</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>Category</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>Details</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>Status</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>Action</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={req.id}>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>
<input type="checkbox" name="requestIds" value={req.id} disabled={!canApprove} style={{ cursor: canApprove ? 'pointer' : 'not-allowed' }} />
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', fontSize: '0.85rem', color: 'var(--text-muted)' }}>
{index + 1}
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>
<strong>{req.user.name}</strong><br/>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{req.user.email}</span>
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', fontWeight: 500 }}>
{req.item.name} <br/>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', fontWeight: 400 }}>Type: {formatType(req.type)} | Qty: {req.quantity}</span>
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
{req.item.category?.name || '-'}
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>
<span style={{ fontSize: '0.85rem' }}>
<strong>{isOneStep ? 'From:' : 'To:'}</strong> {req.company} <br/>
<strong>Address:</strong> {req.address} <br/>
<strong>Project:</strong> {req.purpose}
{req.notes && <><br/><strong>Notes:</strong> {req.notes}</>}
</span>
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>
<span style={{
padding: '4px 8px', borderRadius: '4px', fontSize: '0.8rem',
background: 'rgba(245, 158, 11, 0.2)', color: 'var(--warning)'
}}>
{isOneStep ? 'Needs Approval (1-step)' : req.status === 'pending' ? 'Needs 1st Approval' : 'Needs 2nd Approval'}
</span>
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>
{canApprove ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<select name={`type_${req.id}`} defaultValue={req.type} className="input-field" style={{ padding: '4px 8px', fontSize: '0.8rem', width: '100%', minWidth: '120px' }}>
<option value="borrow">Borrow</option>
<option value="trial">Trial</option>
<option value="billed">Manage Service</option>
<option value="purchase">Outright Purchase</option>
<option value="return_borrow">Return (Borrow)</option>
<option value="return_trial">Return (Trial)</option>
<option value="return_billed">Return (Manage Service)</option>
</select>
<input type="text" name={`sn_${req.id}`} placeholder="SN (Opt)" className="input-field" style={{ padding: '4px 8px', fontSize: '0.8rem', width: '100%', minWidth: '120px' }} />
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button formAction={processApproval.bind(null, req.id, 'approve')} style={{
background: 'var(--success)', color: 'white', border: 'none', padding: '6px 12px', borderRadius: '4px', cursor: 'pointer', fontWeight: 500
}}>Approve</button>
<button formAction={processApproval.bind(null, req.id, 'reject')} style={{
background: 'var(--danger)', color: 'white', border: 'none', padding: '6px 12px', borderRadius: '4px', cursor: 'pointer', fontWeight: 500
}}>Reject</button>
</div>
</div>
) : (
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>
{isOwnRequest ? 'Cannot approve own request' : 'Awaiting another approver'}
</span>
)}
</td>
</tr>
)
})}
{pendingRequests.length === 0 && (
<tr>
<td colSpan={7} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>
No pending requests at the moment.
</td>
</tr>
)}
</tbody>
</table>
</div>
</form>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '3rem', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
<h2 className="heading" style={{ fontSize: '1.5rem' }}>Approval History</h2>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<form method="get" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="text"
name="searchQuery"
className="input-field"
placeholder="Search PT, Address, or SN..."
defaultValue={searchQuery || ''}
style={{ padding: '0.5rem 1rem', width: 'auto', minWidth: '200px' }}
/>
<select name="historyGroup" className="input-field" defaultValue={historyGroup || 'all'} style={{ padding: '0.5rem 1rem', width: 'auto' }}>
<option value="all">All Groups</option>
{(session.role === 'super_admin' ? await prisma.category.findMany() : approver?.categories || []).map((c: any) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<select name="limit" className="input-field" defaultValue={historyLimit.toString()} style={{ padding: '0.5rem 1rem', width: 'auto' }}>
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<button type="submit" className="btn-primary" style={{ padding: '0.5rem 1rem', whiteSpace: 'nowrap' }}>Filter</button>
</form>
<ExportButton data={mappedHistory} filename={`approval_history_${new Date().toISOString().split('T')[0]}`} headers={exportHeaders} />
</div>
</div>
<div className="glass-panel" style={{ padding: '1.5rem' }}>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
<thead>
<tr>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>No.</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>Requester</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>Item</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>Category</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>Details</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>Final Status</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={req.id}>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', fontSize: '0.85rem', color: 'var(--text-muted)' }}>
{index + 1}
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>
<strong>{req.user.name}</strong><br/>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{req.user.email}</span><br/>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{new Date(req.createdAt).toLocaleString('id-ID', { timeZone: 'Asia/Jakarta' })}</span>
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', fontWeight: 500 }}>
{req.item.name} <br/>
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)', fontWeight: 400 }}>Type: {formatType(req.type)} | Qty: {req.quantity}</span>
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
{req.item.category?.name || '-'}
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>
<span style={{ fontSize: '0.85rem' }}>
<strong>{isOneStep ? 'From:' : 'To:'}</strong> {req.company} <br/>
<strong>Address:</strong> {req.address} <br/>
<strong>Project:</strong> {req.purpose}
{req.notes && <><br/><strong>Notes:</strong> {req.notes}</>}
{req.serialNumber && <><br/><strong>SN:</strong> {req.serialNumber}</>}
</span>
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>
<span style={{
display: 'inline-block',
padding: '4px 8px', borderRadius: '4px', fontSize: '0.8rem', marginBottom: '4px',
background: req.status === 'rejected' ? 'rgba(239, 68, 68, 0.2)' : 'rgba(16, 185, 129, 0.2)',
color: req.status === 'rejected' ? 'var(--danger)' : 'var(--success)'
}}>
{req.status === 'rejected' ? 'Rejected' : 'Approved (Final)'}
</span>
{approvers && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
by: {approvers}
</div>
)}
</td>
</tr>
)
})}
{historyRequests.length === 0 && (
<tr>
<td colSpan={4} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>
No history found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</main>
)
}
+35
View File
@@ -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;
}
+83
View File
@@ -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 (
<div className={styles.container}>
<div className={`glass-panel ${styles.loginCard}`}>
<h1 className="heading">Portal Inventory</h1>
<p className="subheading">Sign in to manage requests and assets</p>
<form onSubmit={handleLogin} className={styles.form}>
<div>
<label className="label">Email or Username</label>
<input
type="text"
className="input-field"
placeholder=""
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<label className="label">Password</label>
<input
type="password"
className="input-field"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button
type="submit"
className={`btn-primary ${styles.submitBtn}`}
disabled={loading}
>
{loading ? 'Authenticating...' : 'Sign In'}
</button>
</form>
{error && <div className={styles.error}>{error}</div>}
</div>
</div>
)
}
+158
View File
@@ -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 (
<main style={{ padding: '1rem 2rem', maxWidth: '1200px', margin: '0 auto' }}>
<div style={{ marginBottom: '2rem' }}>
<h1 className="heading">Activity Logs</h1>
<p style={{ color: 'var(--text-muted)' }}>Showing the latest 50 logs matching your criteria.</p>
</div>
<div className="glass-panel" style={{ padding: '1rem 1.5rem', marginBottom: '1.5rem' }}>
<form style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div style={{ flex: '1', minWidth: '200px' }}>
<label style={{ display: 'block', fontSize: '0.875rem', marginBottom: '0.5rem', color: 'var(--text-muted)' }}>Search Name / Details</label>
<input
type="text"
name="q"
defaultValue={searchParams.q || ''}
placeholder="Search..."
className="input-field"
style={{ width: '100%', background: 'var(--bg-color)', color: 'var(--text-color)' }}
/>
</div>
<div style={{ width: '200px' }}>
<label style={{ display: 'block', fontSize: '0.875rem', marginBottom: '0.5rem', color: 'var(--text-muted)' }}>Filter Date</label>
<input
type="date"
name="date"
defaultValue={searchParams.date || ''}
className="input-field"
style={{ width: '100%', background: 'var(--bg-color)', color: 'var(--text-color)' }}
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button type="submit" className="btn-primary" style={{ padding: '0.75rem 1.5rem' }}>Filter</button>
{(searchParams.q || searchParams.date) && (
<a href="/logs" className="btn-secondary" style={{ padding: '0.75rem 1.5rem', textDecoration: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>Clear</a>
)}
</div>
</form>
</div>
<div className="glass-panel" style={{ padding: '1.5rem' }}>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
<thead>
<tr>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>Date & Time</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>User</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>Action</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>Details</th>
</tr>
</thead>
<tbody>
{logs.map((log: any) => (
<tr key={log.id}>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', fontSize: '0.875rem' }}>
{new Date(log.createdAt).toLocaleString()}
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>
<strong>{log.user.name}</strong><br/>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{log.user.role}</span>
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>
<span style={{
padding: '4px 8px', borderRadius: '4px', fontSize: '0.8rem',
background: log.type === 'admin_user' || log.type === 'admin_item' ? 'rgba(239, 68, 68, 0.2)' :
log.type === 'approval' ? 'rgba(59, 130, 246, 0.2)' :
log.type === 'auth' ? 'rgba(168, 85, 247, 0.2)' : 'rgba(16, 185, 129, 0.2)',
color: log.type === 'admin_user' || log.type === 'admin_item' ? 'var(--danger)' :
log.type === 'approval' ? 'var(--accent)' :
log.type === 'auth' ? '#a855f7' : 'var(--success)'
}}>
{log.action}
</span>
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', fontSize: '0.875rem', color: 'var(--text-muted)' }}>
{log.details}
</td>
</tr>
))}
{logs.length === 0 && (
<tr>
<td colSpan={4} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>
No activity logs found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</main>
)
}
+135
View File
@@ -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 (
<main style={{ padding: '1rem 2rem', maxWidth: '600px', margin: '0 auto', marginTop: '4rem' }}>
<div className="glass-panel" style={{ padding: '2rem' }}>
<h1 className="heading" style={{ fontSize: '1.5rem', marginBottom: '1.5rem', textAlign: 'center' }}>My Profile</h1>
{error && (
<div style={{ padding: '1rem', background: 'rgba(239, 68, 68, 0.1)', color: 'var(--danger)', borderRadius: '8px', marginBottom: '1.5rem', fontWeight: 500, textAlign: 'center' }}>
❌ {error}
</div>
)}
{success && (
<div style={{ padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', color: 'var(--success)', borderRadius: '8px', marginBottom: '1.5rem', fontWeight: 500, textAlign: 'center' }}>
✅ {success}
</div>
)}
<form action={updateProfile} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div>
<label className="label">Name (Read Only)</label>
<input type="text" className="input-field" value={user.name} disabled style={{ opacity: 0.7 }} />
</div>
<div>
<label className="label">Role (Read Only)</label>
<input type="text" className="input-field" value={user.role} disabled style={{ opacity: 0.7, textTransform: 'capitalize' }} />
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--glass-border)', margin: '0.5rem 0' }} />
<div>
<label className="label">Username</label>
<input name="username" type="text" className="input-field" defaultValue={spUsername || user.username || ''} required />
{err_username && <div style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '0.25rem' }}>{err_username}</div>}
</div>
<div>
<label className="label">Email</label>
<input name="email" type="email" className="input-field" defaultValue={spEmail || user.email} required />
{err_email && <div style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '0.25rem' }}>{err_email}</div>}
</div>
<div>
<label className="label">New Password (leave blank to keep current) <span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>(Min 8 chars, 1 uppercase, 1 special)</span></label>
<input name="password" type="text" className="input-field" placeholder="Enter new password..." />
{err_password && <div style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '0.25rem' }}>{err_password}</div>}
</div>
<button type="submit" className="btn-primary" style={{ marginTop: '1rem', padding: '1rem', fontSize: '1rem' }}>
Save Changes
</button>
</form>
</div>
</main>
)
}
+517
View File
@@ -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 = `📢 <b>New Request Submitted</b>\n\n<b>User:</b> ${user?.name || session?.name}\n<b>Item:</b> ${item.name}\n<b>Qty:</b> ${quantity}\n<b>Type:</b> ${typeStr}\n<b>Company:</b> ${company}\n<b>Address:</b> ${address}\n<b>Project:</b> ${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(`📢 <b>Bulk Request Submitted</b>\n\n<b>User:</b> ${user?.name || sessionData.name}\n<b>Total Requests:</b> ${successCount}\n\nPlease check the portal to approve or reject.`)
}
revalidatePath('/request')
}
return (
<main style={{ padding: '1rem 2rem', maxWidth: '1200px', margin: '0 auto' }}>
<h1 className="heading" style={{ marginBottom: '2rem' }}>Item Requests</h1>
{error && (
<div style={{ padding: '1rem', background: 'rgba(239, 68, 68, 0.1)', color: 'var(--danger)', borderRadius: '8px', marginBottom: '1.5rem', fontWeight: 500 }}>
❌ {error}
</div>
)}
<div className="page-grid">
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div className="glass-panel" style={{ padding: '1.5rem', height: 'fit-content' }}>
<h2 className="heading" style={{ fontSize: '1.25rem', marginBottom: '1rem' }}>New Request</h2>
<form action={submitRequest} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<style>{`
#newItemDiv { display: none; }
form:has(#itemSelect option[value="new"]:checked) #newItemDiv { display: flex; }
/* Cascading dropdown logic */
#itemSelect option[data-cat] { display: none; }
form:has(#categoryFilter option[value="all"]:checked) #itemSelect option[data-cat] { display: block; }
${allowedCategories.map(cat => `
form:has(#categoryFilter option[value="${cat.id}"]:checked) #itemSelect option[data-cat="${cat.id}"] { display: block; }
`).join('\n')}
`}</style>
<div>
<label className="label">Group / Category</label>
<select id="categoryFilter" className="input-field" defaultValue="all">
<option value="all">-- All Groups --</option>
{allowedCategories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
<CategorySelectListener />
</div>
<div>
<label className="label">Item</label>
<select name="itemId" id="itemSelect" className="input-field" required>
<option value="">Select Item...</option>
{items.map(item => (
<option key={item.id} value={item.id} data-cat={item.categoryId || 'none'}>
{item.name} {item.availableQty === 0 ? '(Out of Stock)' : `(Available: ${item.availableQty})`}
</option>
))}
<option value="new" style={{ fontWeight: 'bold', color: 'var(--accent)' }}>➕ Add New Item...</option>
</select>
</div>
<div id="newItemDiv" style={{ flexDirection: 'column' }}>
<label className="label">New Item Name</label>
<input name="newItemName" type="text" className="input-field" placeholder="Enter new item name..." style={{ marginBottom: '1rem' }} />
<label className="label">New Item Category</label>
<select name="categoryId" className="input-field">
<option value="">Select Category...</option>
{allowedCategories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
<div>
<label className="label">Quantity</label>
<input name="quantity" type="number" min="1" className="input-field" defaultValue="1" required />
</div>
<div>
<label className="label">Request Type</label>
<select name="type" className="input-field" required>
<optgroup label="Requests (Needs 2 Approvals)">
<option value="borrow">Borrow</option>
<option value="trial">Trial</option>
<option value="billed">Manage Service</option>
<option value="outright_purchase">Outright Purchase</option>
</optgroup>
<optgroup label="Restocks (Needs 1 Approval)">
<option value="purchase">Purchase (New Stock)</option>
<option value="return_borrow">Return from Borrow</option>
<option value="return_trial">Return from Trial</option>
<option value="return_billed">Return from Manage Service</option>
</optgroup>
<optgroup label="Damaged Returns (Needs 1 Approval)">
<option value="return_damaged_borrow">Return Damaged (from Borrow)</option>
<option value="return_damaged_trial">Return Damaged (from Trial)</option>
<option value="return_damaged_billed">Return Damaged (from Manage Service)</option>
<option value="return_damaged_sold">Return Damaged (from Outright Purchase)</option>
</optgroup>
</select>
</div>
<div>
<label className="label">Project</label>
<input name="purpose" type="text" className="input-field" placeholder="e.g. Server installation" required />
</div>
<div>
<label className="label">Company (PT)</label>
<input name="company" type="text" className="input-field" placeholder="e.g. PT Maju Bersama" required />
</div>
<div>
<label className="label">Address</label>
<textarea name="address" className="input-field" rows={3} placeholder="Complete address..." required></textarea>
</div>
<div>
<label className="label">Notes (Optional)</label>
<textarea name="notes" className="input-field" rows={2} placeholder="Any additional notes..."></textarea>
</div>
<button type="submit" className="btn-primary" style={{ marginTop: '0.5rem' }}>Submit Request</button>
</form>
</div>
<div className="glass-panel" style={{ padding: '1.5rem', height: 'fit-content' }}>
<h2 className="heading" style={{ fontSize: '1.25rem', marginBottom: '1rem' }}>Bulk Upload Requests</h2>
<form action={bulkSubmitRequest} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
<p style={{ fontSize: '0.9rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
Download the CSV template, fill it out in Excel, Save As CSV, and upload it here.
</p>
<a
href="data:text/csv;charset=utf-8,Item Name;Quantity;Type;Project;Company;Address;Serial Number;Notes%0ALaptop Lenovo;2;borrow;Proyek A;PT Maju;Jakarta;SN-12345;For Intern"
download="Template_Bulk_Request.csv"
className="btn-secondary"
style={{ display: 'inline-block', padding: '0.4rem 0.8rem', fontSize: '0.85rem', marginBottom: '0.5rem', textDecoration: 'none', background: 'rgba(255,255,255,0.1)', borderRadius: '4px' }}
>
⬇️ Download Template CSV
</a>
</div>
<div>
<label className="label">Upload CSV File</label>
<input name="csvFile" type="file" accept=".csv" className="input-field" required style={{ padding: '0.4rem' }} />
</div>
<button type="submit" className="btn-primary" style={{ marginTop: '0.5rem' }}>Upload & Submit Bulk</button>
</form>
</div>
</div>
<div className="glass-panel" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '3rem', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
<h2 className="heading" style={{ fontSize: '1.5rem' }}>Your Request History</h2>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<form method="get" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="text"
name="searchQuery"
className="input-field"
placeholder="Search PT, Address, or SN..."
defaultValue={searchQuery || ''}
style={{ padding: '0.5rem 1rem', width: 'auto', minWidth: '200px' }}
/>
<select name="historyGroup" className="input-field" defaultValue={historyGroup || 'all'} style={{ padding: '0.5rem 1rem', width: 'auto' }}>
<option value="all">All Groups</option>
{allowedCategories.map((c: any) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<select name="limit" className="input-field" defaultValue={historyLimit.toString()} style={{ padding: '0.5rem 1rem', width: 'auto' }}>
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<button type="submit" className="btn-primary" style={{ padding: '0.5rem 1rem', whiteSpace: 'nowrap' }}>Filter</button>
</form>
<ExportButton data={mappedRequests} filename={`request_history_${new Date().toISOString().split('T')[0]}`} headers={exportHeaders} />
</div>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
<thead>
<tr>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>No.</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Date</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Item</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Category</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Type/Qty</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Details</th>
<th style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', color: 'var(--text-muted)' }}>Status</th>
</tr>
</thead>
<tbody>
{userRequests.map((req, index) => {
const approvers = req.approvalLogs.map(log => log.approver.name).join(', ')
const isReturn = req.type.includes('return') || req.type === 'purchase'
return (
<tr key={req.id}>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', fontSize: '0.85rem', color: 'var(--text-muted)' }}>
{index + 1}
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>
<span style={{ fontSize: '0.85rem' }}>{new Date(req.createdAt).toLocaleString('id-ID', { timeZone: 'Asia/Jakarta' })}</span>
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', fontWeight: 500 }}>
{req.item.name}
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
{req.item.category?.name || '-'}
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>
{formatType(req.type)} x{req.quantity}
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>
<span style={{ fontSize: '0.85rem' }}>
<span style={{ color: 'var(--text-muted)' }}>{isReturn ? 'From:' : 'To:'}</span> {req.company} <br/>
<strong>Address:</strong> {req.address} <br/>
<strong>Project:</strong> {req.purpose}
</span>
</td>
<td style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)' }}>
<span style={{
display: 'inline-block',
padding: '4px 8px', borderRadius: '4px', fontSize: '0.8rem', marginBottom: '4px',
background: req.status === 'pending' ? 'rgba(245, 158, 11, 0.2)' :
req.status === 'rejected' ? 'rgba(239, 68, 68, 0.2)' :
req.status === 'approved_1' ? 'rgba(59, 130, 246, 0.2)' : 'rgba(16, 185, 129, 0.2)',
color: req.status === 'pending' ? 'var(--warning)' :
req.status === 'rejected' ? 'var(--danger)' :
req.status === 'approved_1' ? 'var(--accent)' : 'var(--success)'
}}>
{req.status === 'approved_1' ? '1st Approval' : req.status === 'approved_2' ? 'Approved' : req.status}
</span>
{approvers && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
by: {approvers}
</div>
)}
</td>
</tr>
)
})}
{userRequests.length === 0 && (
<tr>
<td colSpan={5} style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>
No requests found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</main>
)
}
+68
View File
@@ -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 (
<div style={{
position: 'fixed',
bottom: '20px',
right: '20px',
background: 'var(--accent)',
color: 'white',
padding: '16px 24px',
borderRadius: '8px',
boxShadow: '0 10px 25px rgba(0,0,0,0.5)',
zIndex: 9999,
display: 'flex',
alignItems: 'center',
gap: '12px',
animation: 'slideIn 0.3s ease-out'
}}>
<div style={{
width: '10px',
height: '10px',
background: '#10b981',
borderRadius: '50%',
boxShadow: '0 0 8px #10b981'
}}></div>
<strong style={{ fontFamily: 'Inter, sans-serif' }}>New Request Needs Approval!</strong>
<style>{`
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`}</style>
</div>
)
}
+18
View File
@@ -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
}
+53
View File
@@ -0,0 +1,53 @@
'use client'
import React from 'react'
interface ExportButtonProps {
data: Record<string, any>[]
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 (
<button onClick={handleExport} style={{
background: 'var(--accent)', color: 'white', border: 'none',
padding: '6px 12px', borderRadius: '6px', cursor: 'pointer',
fontWeight: 500, fontSize: '0.875rem'
}}>
{text}
</button>
)
}
+67
View File
@@ -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;
}
}
+51
View File
@@ -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 (
<nav className={`glass-panel ${styles.navbar}`}>
<div className={styles.logo}>Portal Inventory</div>
<div className={styles.links}>
<Link href="/">Dashboard</Link>
{(role === 'user' || role === 'approver') && <Link href="/request">Request</Link>}
{(role === 'approver' || role === 'super_admin') && (
<>
<Link href="/approvals" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
Approvals
{pendingCount > 0 && (
<span style={{
background: 'var(--danger)', color: 'white', fontSize: '0.7rem',
padding: '2px 6px', borderRadius: '99px', fontWeight: 'bold'
}}>
{pendingCount}
</span>
)}
</Link>
<Link href="/admin/items">Items</Link>
</>
)}
{role === 'super_admin' && (
<>
<Link href="/admin/categories">Categories</Link>
<Link href="/admin/users">Users</Link>
<Link href="/admin/settings">Settings</Link>
</>
)}
{role !== 'viewer' && <Link href="/logs">Logs</Link>}
<Link href="/profile" style={{ marginLeft: '1rem', color: 'var(--accent)', fontWeight: 500 }}>My Profile</Link>
<button onClick={handleLogout} className={styles.logoutBtn}>Logout</button>
</div>
</nav>
)
}
+16
View File
@@ -0,0 +1,16 @@
'use client';
export default function SelectAllCheckbox() {
return (
<input
type="checkbox"
onChange={(e) => {
const checkboxes = document.querySelectorAll('input[name="requestIds"]:not(:disabled)') as NodeListOf<HTMLInputElement>;
for (let cb of checkboxes) {
cb.checked = e.target.checked;
}
}}
style={{ cursor: 'pointer' }}
/>
);
}
+66
View File
@@ -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<any> {
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')
}
+38
View File
@@ -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
}
}
+11
View File
@@ -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
+33
View File
@@ -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
}
}
+48
View File
@@ -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).*)',
],
}