Initial commit aplikasi inventory

This commit is contained in:
ypratama committed 2026-08-12 13:51:19 +07:00
1 parent 5c97539b46
commit 8a04f517da
9 files changed
+2315 -269

No files matched your search

BIN
View File
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
+15 -1
View File
@@ -1,7 +1,21 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
// @ts-ignore - allow testing via LAN IP
allowedDevOrigins: ['192.168.80.50'],
output: 'standalone',
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
],
},
]
}
};
export default nextConfig;
+1853 -35
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -9,16 +9,24 @@
"lint": "eslint"
},
"dependencies": {
"@libsql/client": "^0.17.4",
"@prisma/adapter-libsql": "^7.9.1",
"@prisma/client": "^7.9.1",
"jose": "^6.2.8",
"next": "16.3.0",
"nodemailer": "^9.0.4",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
"eslint": "^9",
"eslint-config-next": "16.3.0",
"prisma": "^7.9.1",
"typescript": "^5"
}
}
+113 -33
View File
@@ -1,34 +1,17 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
html {
height: 100%;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
min-height: 100%;
display: flex;
flex-direction: column;
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--bg-color: #0f172a;
--glass-bg: rgba(30, 41, 59, 0.7);
--glass-border: rgba(255, 255, 255, 0.1);
--text-main: #f8fafc;
--text-muted: #94a3b8;
--accent: #3b82f6;
--accent-hover: #2563eb;
--danger: #ef4444;
--success: #10b981;
--warning: #f59e0b;
--sold: #8b5cf6;
}
* {
@@ -37,13 +20,110 @@ body {
margin: 0;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
min-height: 100vh;
width: 100%;
overflow-x: hidden;
display: flex;
flex-direction: column;
background-image:
radial-gradient(circle at 15% 50%, rgba(59, 130, 246, 0.15), transparent 25%),
radial-gradient(circle at 85% 30%, rgba(16, 185, 129, 0.15), transparent 25%);
background-attachment: fixed;
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
.glass-panel {
background: var(--glass-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: 16px;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.3);
}
.btn-primary {
background: var(--accent);
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
font-family: 'Inter', sans-serif;
}
.btn-primary:hover {
background: var(--accent-hover);
transform: translateY(-1px);
}
.btn-primary:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.input-field {
width: 100%;
padding: 12px 16px;
background: rgba(15, 23, 42, 0.6);
border: 1px solid var(--glass-border);
border-radius: 8px;
color: var(--text-main);
font-family: 'Inter', sans-serif;
transition: border-color 0.2s ease;
outline: none;
}
.input-field:focus {
border-color: var(--accent);
}
option {
background-color: #1e293b;
color: #f8fafc;
}
.label {
display: block;
margin-bottom: 6px;
font-size: 0.875rem;
color: var(--text-muted);
font-weight: 500;
}
.heading {
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.025em;
background: linear-gradient(135deg, var(--text-main) 0%, #a1a1aa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.page-grid {
display: grid;
gap: 2rem;
grid-template-columns: 1fr 2fr;
}
@media (max-width: 1024px) {
.page-grid {
grid-template-columns: 1fr;
}
}
.subheading {
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 2rem;
}
+45 -19
View File
@@ -1,26 +1,52 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { getSession } from '@/lib/auth'
import Navbar from '@/components/Navbar'
import AutoRefresh from '@/components/AutoRefresh'
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
title: 'Portal Inventory',
description: 'Modern Inventory Management System',
}
export default function RootLayout({ children }: LayoutProps<"/">) {
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await getSession()
let pendingCount = 0
if (session?.role === 'approver') {
const { prisma } = await import('@/lib/prisma')
const user = await prisma.user.findUnique({ where: { id: session.id }, include: { categories: true } })
const allowedCategoryIds = user?.categories.map(c => c.id) || []
const requests = await prisma.request.findMany({
where: {
status: { in: ['pending', 'approved_1'] },
item: { categoryId: { in: allowedCategoryIds } }
},
include: { approvalLogs: true }
})
pendingCount = requests.filter(r => {
if (r.userId === session.id) return false // Cannot approve own requests
if (r.status === 'pending') return true
const isOneStep = ['purchase', 'return_borrow', 'return_trial', 'return_billed'].includes(r.type)
if (isOneStep) return false
const firstApproverId = r.approvalLogs.find(l => l.approvalStep === 1)?.approverId
return firstApproverId !== session.id
}).length
}
return (
<html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}>
<body>{children}</body>
<html lang="en">
<body className={inter.className}>
{session && <Navbar role={session.role} name={session.name} pendingCount={pendingCount} />}
{session && session.role === 'approver' && <AutoRefresh interval={5000} pendingCount={pendingCount} />}
{children}
</body>
</html>
);
)
}
+100 -118
View File
@@ -1,150 +1,132 @@
.page {
--background: #fafafa;
--foreground: #fff;
--text-primary: #000;
--text-secondary: #666;
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
--button-secondary-border: #ebebeb;
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: var(--font-geist-sans);
background-color: var(--background);
}
.main {
display: flex;
flex: 1;
padding: 1rem 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
max-width: 800px;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
background-color: var(--foreground);
padding: 120px 60px;
}
.intro {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 24px;
.header {
margin-bottom: 2rem;
}
.intro h1 {
max-width: 320px;
font-size: 40px;
.metricsGrid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1rem;
margin-bottom: 3rem;
}
@media (max-width: 1024px) {
.metricsGrid {
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
}
}
.metricCard {
padding: 1.5rem;
text-align: center;
transition: transform 0.2s ease;
}
.metricCard:hover {
transform: translateY(-4px);
}
.metricCard h3 {
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 600;
line-height: 48px;
letter-spacing: -2.4px;
text-wrap: balance;
color: var(--text-primary);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.intro p {
max-width: 440px;
font-size: 18px;
line-height: 32px;
text-wrap: balance;
color: var(--text-secondary);
.metricValue {
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.025em;
}
.intro a {
font-weight: 500;
color: var(--text-primary);
.textSuccess { color: var(--success); }
.textWarning { color: var(--warning); }
.textAccent { color: var(--accent); }
.textMuted { color: var(--text-muted); }
.textDanger { color: var(--danger); }
.textSold { color: var(--sold); }
.tableContainer {
overflow-x: auto;
}
.ctas {
display: flex;
flex-direction: row;
.table {
width: 100%;
max-width: 440px;
gap: 16px;
font-size: 14px;
border-collapse: collapse;
text-align: left;
}
.ctas a {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
padding: 0 16px;
border-radius: 128px;
border: 1px solid transparent;
transition: 0.2s;
cursor: pointer;
width: fit-content;
.table th,
.table td {
padding: 1rem;
border-bottom: 1px solid var(--glass-border);
}
.table th {
font-weight: 600;
color: var(--text-muted);
font-size: 0.875rem;
}
.table tr:last-child td {
border-bottom: none;
}
.itemName {
font-weight: 500;
}
a.primary {
background: var(--text-primary);
color: var(--background);
gap: 8px;
.badgeSuccess {
background: rgba(16, 185, 129, 0.15);
color: var(--success);
padding: 4px 10px;
border-radius: 9999px;
font-weight: 600;
font-size: 0.875rem;
}
a.secondary {
border-color: var(--button-secondary-border);
.badgeWarning {
background: rgba(245, 158, 11, 0.15);
color: var(--warning);
padding: 4px 10px;
border-radius: 9999px;
font-weight: 600;
font-size: 0.875rem;
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
.badgeAccent {
background: rgba(59, 130, 246, 0.15);
color: var(--accent);
padding: 4px 10px;
border-radius: 9999px;
font-weight: 600;
font-size: 0.875rem;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
.clickableCard {
transition: transform 0.2s ease, box-shadow 0.2s ease;
cursor: pointer;
}
@media (max-width: 600px) {
.main {
padding: 48px 24px;
.clickableCard:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
}
.intro {
gap: 16px;
.linkWrapper {
text-decoration: none;
display: block;
height: 100%;
}
.intro h1 {
font-size: 32px;
line-height: 40px;
letter-spacing: -1.92px;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
.page {
--background: #000;
--foreground: #000;
--text-primary: #ededed;
--text-secondary: #999;
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
--button-secondary-border: #1a1a1a;
}
}
.code {
font-family: var(--font-geist-mono);
font-size: 0.9em;
background: color-mix(in srgb, currentColor 8%, transparent);
padding: 0.1em 0.4em;
border-radius: 6px;
.linkWrapper .metricCard {
height: 100%;
}
+181 -62
View File
@@ -1,69 +1,188 @@
import Image from "next/image";
import styles from "./page.module.css";
import { prisma } from '@/lib/prisma'
import { getSession } from '@/lib/auth'
import styles from './page.module.css'
import Link from 'next/link'
import { Category } from '@prisma/client'
export default function Home() {
export default async function Dashboard({ searchParams }: { searchParams: Promise<{ group?: string }> }) {
const { group } = await searchParams
const session = await getSession()
const user = session ? await prisma.user.findUnique({ where: { id: session.id }, include: { categories: true } }) : null
let allowedCategories: Category[] = []
if (session?.role === 'super_admin') {
allowedCategories = await prisma.category.findMany()
} else if (user) {
allowedCategories = user.categories
}
let displayCategories = allowedCategories
if (group && group !== 'all') {
const groupId = parseInt(group)
displayCategories = allowedCategories.filter(c => c.id === groupId)
}
const allowedCategoryIds = displayCategories.map(c => c.id)
const items = await prisma.item.findMany({
where: { categoryId: { in: allowedCategoryIds } },
include: { category: true }
})
const groupedData = displayCategories.map(cat => {
const catItems = items.filter(i => i.categoryId === cat.id)
const metrics = catItems.reduce(
(acc, item) => {
acc.total += item.totalQuantity
acc.borrowed += item.borrowedQty
acc.trial += item.trialQty
acc.billed += item.billedQty
acc.available += item.availableQty
acc.damaged += item.damagedQty || 0
acc.sold += item.soldQty || 0
return acc
},
{ total: 0, borrowed: 0, trial: 0, billed: 0, available: 0, damaged: 0, sold: 0 }
)
return { category: cat, items: catItems, metrics }
})
// Also catch uncategorized items if they somehow exist and the user is super_admin (and viewing all or uncategorized)
const showUncategorized = session?.role === 'super_admin' && (!group || group === 'all' || group === '0')
const uncategorizedItems = showUncategorized ? await prisma.item.findMany({ where: { categoryId: null } }) : []
if (uncategorizedItems.length > 0) {
const metrics = uncategorizedItems.reduce(
(acc, item) => {
acc.total += item.totalQuantity
acc.borrowed += item.borrowedQty
acc.trial += item.trialQty
acc.billed += item.billedQty
acc.available += item.availableQty
acc.damaged += item.damagedQty || 0
acc.sold += item.soldQty || 0
return acc
},
{ total: 0, borrowed: 0, trial: 0, billed: 0, available: 0, damaged: 0, sold: 0 }
)
groupedData.push({ category: { id: 0, name: 'Uncategorized', createdAt: new Date(), updatedAt: new Date() }, items: uncategorizedItems as any[], metrics })
}
return (
<div className={styles.page}>
<main className={styles.main}>
<Image
className={styles.logo}
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className={styles.intro}>
<h1>
To get started, edit the{" "}
<code className={styles.code}>page.tsx</code> file.
</h1>
<p>
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Learning
</a>{" "}
center.
</p>
<header className={styles.header}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '1rem' }}>
<div>
<h1 className="heading">Dashboard Overview</h1>
<p className="subheading">Welcome back, {session?.name} ({session?.role})</p>
</div>
<div className={styles.ctas}>
<a
className={styles.primary}
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className={styles.logo}
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className={styles.secondary}
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
{allowedCategories.length > 1 && (
<form method="get" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<select name="group" className="input-field" defaultValue={group || 'all'} style={{ padding: '0.5rem 1rem', minWidth: '200px' }}>
<option value="all">All Groups</option>
{allowedCategories.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
{session?.role === 'super_admin' && <option value="0">Uncategorized</option>}
</select>
<button type="submit" className="btn-primary" style={{ padding: '0.5rem 1rem' }}>Filter</button>
</form>
)}
</div>
</header>
{groupedData.length === 0 && (
<div className="glass-panel" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-muted)' }}>
You do not have access to any categories yet. Please contact your Super Admin.
</div>
)}
{groupedData.map(group => (
<div key={group.category.id} style={{ marginBottom: '3rem' }}>
<h2 className="heading" style={{ fontSize: '1.5rem', marginBottom: '1rem', color: 'var(--accent)' }}>
Group: {group.category.name}
</h2>
<div className={styles.metricsGrid}>
<div className={`glass-panel ${styles.metricCard}`}>
<h3>Total Items</h3>
<p className={styles.metricValue}>{group.metrics.total}</p>
</div>
<div className={`glass-panel ${styles.metricCard}`}>
<h3>Available</h3>
<p className={`${styles.metricValue} ${styles.textSuccess}`}>{group.metrics.available}</p>
</div>
<Link href={`/approvals?typeFilter=borrow&historyGroup=${group.category.id}`} className={styles.linkWrapper}>
<div className={`glass-panel ${styles.metricCard} ${styles.clickableCard}`}>
<h3>Borrowed</h3>
<p className={`${styles.metricValue} ${styles.textWarning}`}>{group.metrics.borrowed}</p>
</div>
</Link>
<Link href={`/approvals?typeFilter=trial&historyGroup=${group.category.id}`} className={styles.linkWrapper}>
<div className={`glass-panel ${styles.metricCard} ${styles.clickableCard}`}>
<h3>Trial</h3>
<p className={`${styles.metricValue} ${styles.textAccent}`}>{group.metrics.trial}</p>
</div>
</Link>
<Link href={`/approvals?typeFilter=billed&historyGroup=${group.category.id}`} className={styles.linkWrapper}>
<div className={`glass-panel ${styles.metricCard} ${styles.clickableCard}`}>
<h3>Manage Service</h3>
<p className={`${styles.metricValue} ${styles.textMuted}`}>{group.metrics.billed}</p>
</div>
</Link>
<Link href={`/approvals?typeFilter=damaged&historyGroup=${group.category.id}`} className={styles.linkWrapper}>
<div className={`glass-panel ${styles.metricCard} ${styles.clickableCard}`}>
<h3>Damaged</h3>
<p className={`${styles.metricValue} ${styles.textDanger}`}>{group.metrics.damaged}</p>
</div>
</Link>
<Link href={`/approvals?typeFilter=outright_purchase&historyGroup=${group.category.id}`} className={styles.linkWrapper}>
<div className={`glass-panel ${styles.metricCard} ${styles.clickableCard}`}>
<h3>Sold</h3>
<p className={`${styles.metricValue} ${styles.textSold}`}>{group.metrics.sold}</p>
</div>
</Link>
</div>
<div className={`glass-panel ${styles.tableContainer}`} style={{ marginTop: '1.5rem' }}>
<h3 className="heading" style={{ padding: '1rem', borderBottom: '1px solid var(--glass-border)', fontSize: '1.1rem' }}>Inventory Details - {group.category.name}</h3>
<table className={styles.table}>
<thead>
<tr>
<th>Item Name</th>
<th>Total Qty</th>
<th>Available</th>
<th>Borrowed</th>
<th>Trial</th>
<th>Manage Service</th>
<th>Damaged</th>
<th>Sold</th>
</tr>
</thead>
<tbody>
{group.items.map((item) => (
<tr key={item.id}>
<td className={styles.itemName}>{item.name}</td>
<td>{item.totalQuantity}</td>
<td><span className={styles.badgeSuccess}>{item.availableQty}</span></td>
<td>{item.borrowedQty > 0 ? <span className={styles.badgeWarning}>{item.borrowedQty}</span> : 0}</td>
<td>{item.trialQty > 0 ? <span className={styles.badgeAccent}>{item.trialQty}</span> : 0}</td>
<td>{item.billedQty}</td>
<td>{item.damagedQty > 0 ? <span className={styles.badgeDanger} style={{ color: 'var(--danger)', fontWeight: 'bold' }}>{item.damagedQty}</span> : 0}</td>
<td>{item.soldQty}</td>
</tr>
))}
{group.items.length === 0 && (
<tr>
<td colSpan={6} style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-muted)' }}>
No items in this category.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
))}
</main>
</div>
);
)
}