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"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { 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; 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" "lint": "eslint"
}, },
"dependencies": { "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", "next": "16.3.0",
"nodemailer": "^9.0.4",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"dotenv": "^17.4.2",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.0", "eslint-config-next": "16.3.0",
"prisma": "^7.9.1",
"typescript": "^5" "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 { :root {
--background: #ffffff; --bg-color: #0f172a;
--foreground: #171717; --glass-bg: rgba(30, 41, 59, 0.7);
} --glass-border: rgba(255, 255, 255, 0.1);
--text-main: #f8fafc;
@media (prefers-color-scheme: dark) { --text-muted: #94a3b8;
:root { --accent: #3b82f6;
--background: #0a0a0a; --accent-hover: #2563eb;
--foreground: #ededed; --danger: #ef4444;
} --success: #10b981;
} --warning: #f59e0b;
--sold: #8b5cf6;
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;
} }
* { * {
@@ -37,13 +20,110 @@ body {
margin: 0; 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 { a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
@media (prefers-color-scheme: dark) { .glass-panel {
html { background: var(--glass-bg);
color-scheme: dark; 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 type { Metadata } from 'next'
import { Geist, Geist_Mono } from "next/font/google"; import { Inter } from 'next/font/google'
import "./globals.css"; import './globals.css'
import { getSession } from '@/lib/auth'
import Navbar from '@/components/Navbar'
import AutoRefresh from '@/components/AutoRefresh'
const geistSans = Geist({ const inter = Inter({ subsets: ['latin'] })
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: 'Portal Inventory',
description: "Generated by create next app", 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 ( return (
<html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}> <html lang="en">
<body>{children}</body> <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> </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 { .main {
display: flex; padding: 1rem 2rem;
flex: 1; max-width: 1200px;
margin: 0 auto;
width: 100%; width: 100%;
max-width: 800px;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
background-color: var(--foreground);
padding: 120px 60px;
} }
.intro { .header {
display: flex; margin-bottom: 2rem;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 24px;
} }
.intro h1 { .metricsGrid {
max-width: 320px; display: grid;
font-size: 40px; 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; font-weight: 600;
line-height: 48px; margin-bottom: 0.5rem;
letter-spacing: -2.4px; text-transform: uppercase;
text-wrap: balance; letter-spacing: 0.05em;
color: var(--text-primary);
} }
.intro p { .metricValue {
max-width: 440px; font-size: 2rem;
font-size: 18px; font-weight: 700;
line-height: 32px; letter-spacing: -0.025em;
text-wrap: balance;
color: var(--text-secondary);
} }
.intro a { .textSuccess { color: var(--success); }
font-weight: 500; .textWarning { color: var(--warning); }
color: var(--text-primary); .textAccent { color: var(--accent); }
.textMuted { color: var(--text-muted); }
.textDanger { color: var(--danger); }
.textSold { color: var(--sold); }
.tableContainer {
overflow-x: auto;
} }
.ctas { .table {
display: flex;
flex-direction: row;
width: 100%; width: 100%;
max-width: 440px; border-collapse: collapse;
gap: 16px; text-align: left;
font-size: 14px;
} }
.ctas a { .table th,
display: flex; .table td {
justify-content: center; padding: 1rem;
align-items: center; border-bottom: 1px solid var(--glass-border);
height: 40px; }
padding: 0 16px;
border-radius: 128px; .table th {
border: 1px solid transparent; font-weight: 600;
transition: 0.2s; color: var(--text-muted);
cursor: pointer; font-size: 0.875rem;
width: fit-content; }
.table tr:last-child td {
border-bottom: none;
}
.itemName {
font-weight: 500; font-weight: 500;
} }
a.primary { .badgeSuccess {
background: var(--text-primary); background: rgba(16, 185, 129, 0.15);
color: var(--background); color: var(--success);
gap: 8px; padding: 4px 10px;
border-radius: 9999px;
font-weight: 600;
font-size: 0.875rem;
} }
a.secondary { .badgeWarning {
border-color: var(--button-secondary-border); 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 */ .badgeAccent {
@media (hover: hover) and (pointer: fine) { background: rgba(59, 130, 246, 0.15);
a.primary:hover { color: var(--accent);
background: var(--button-primary-hover); padding: 4px 10px;
border-color: transparent; border-radius: 9999px;
font-weight: 600;
font-size: 0.875rem;
} }
a.secondary:hover { .clickableCard {
background: var(--button-secondary-hover); transition: transform 0.2s ease, box-shadow 0.2s ease;
border-color: transparent; cursor: pointer;
}
} }
@media (max-width: 600px) { .clickableCard:hover {
.main { transform: translateY(-2px);
padding: 48px 24px; box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
} }
.intro { .linkWrapper {
gap: 16px; text-decoration: none;
display: block;
height: 100%;
} }
.intro h1 { .linkWrapper .metricCard {
font-size: 32px; height: 100%;
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;
} }
+181 -62
View File
@@ -1,69 +1,188 @@
import Image from "next/image"; import { prisma } from '@/lib/prisma'
import styles from "./page.module.css"; 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 ( return (
<div className={styles.page}>
<main className={styles.main}> <main className={styles.main}>
<Image <header className={styles.header}>
className={styles.logo} <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '1rem' }}>
src="/next.svg" <div>
alt="Next.js logo" <h1 className="heading">Dashboard Overview</h1>
width={100} <p className="subheading">Welcome back, {session?.name} ({session?.role})</p>
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>
</div> </div>
<div className={styles.ctas}> {allowedCategories.length > 1 && (
<a <form method="get" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
className={styles.primary} <select name="group" className="input-field" defaultValue={group || 'all'} style={{ padding: '0.5rem 1rem', minWidth: '200px' }}>
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app" <option value="all">All Groups</option>
target="_blank" {allowedCategories.map(c => (
rel="noopener noreferrer" <option key={c.id} value={c.id}>{c.name}</option>
> ))}
<Image {session?.role === 'super_admin' && <option value="0">Uncategorized</option>}
className={styles.logo} </select>
src="/vercel.svg" <button type="submit" className="btn-primary" style={{ padding: '0.5rem 1rem' }}>Filter</button>
alt="Vercel logomark" </form>
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>
</div> </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> </main>
</div> )
);
} }