Initial commit aplikasi inventory
This commit is contained in:
1 parent
5c97539b46
commit
8a04f517da
9 files changed
+2315
-269
No files matched your search
BIN
Binary file not shown.
+15
-1
@@ -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;
|
||||||
Generated
+1853
-35
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
)
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user