Migrate architecture to PostgreSQL for Swarm HA
This commit is contained in:
1 parent
ee28968a70
commit
2317f9f133
39 files changed
+3849
-10
No files matched your search
@@ -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
@@ -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"]
|
||||
@@ -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
@@ -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
|
||||
Binary file not shown.
@@ -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"],
|
||||
},
|
||||
});
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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());
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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).*)',
|
||||
],
|
||||
}
|
||||
Reference in new issue
Block a user