first commit
This commit is contained in:
commit
b7e599d0b2
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
README.md
Normal file
36
README.md
Normal file
@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
7811
package-lock.json
generated
Normal file
7811
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "odoo-next-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stripe/react-stripe-js": "^5.4.1",
|
||||
"@stripe/stripe-js": "^8.6.3",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"clsx": "^2.1.1",
|
||||
"cookies-next": "^6.1.1",
|
||||
"framer-motion": "^12.27.5",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mongoose": "^9.1.5",
|
||||
"next": "16.1.4",
|
||||
"nodemailer": "^7.0.12",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^7.0.5",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
161
src/app/accounting/accounting.module.css
Normal file
161
src/app/accounting/accounting.module.css
Normal file
@ -0,0 +1,161 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.statsRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.statBox {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.searchBox svg {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.searchBox input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 2.5rem;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
background: var(--pk-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.exportBtn {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 12px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.ledgerTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ledgerTable th {
|
||||
padding: 1rem 1.5rem;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.ledgerTable td {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 0.9rem;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.entryNumber {
|
||||
font-weight: 700;
|
||||
color: var(--pk-primary);
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-weight: 700;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.posted {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.draft {
|
||||
background: #fef9c3;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
.moreIcon {
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
}
|
||||
110
src/app/accounting/page.tsx
Normal file
110
src/app/accounting/page.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import AppLayout from '@/components/AppLayout/AppLayout';
|
||||
import {
|
||||
BookOpen,
|
||||
ArrowLeftRight,
|
||||
FileText,
|
||||
Settings,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Download,
|
||||
MoreHorizontal,
|
||||
LayoutDashboard,
|
||||
Wallet,
|
||||
Receipt
|
||||
} from 'lucide-react';
|
||||
import styles from './accounting.module.css';
|
||||
|
||||
const sidebarItems = [
|
||||
{ icon: <LayoutDashboard size={20} />, label: 'Accounting Dashboard' },
|
||||
{ icon: <BookOpen size={20} />, label: 'Journal Entries', active: true },
|
||||
{ icon: <Receipt size={20} />, label: 'Vendor Bills' },
|
||||
{ icon: <ArrowLeftRight size={20} />, label: 'Bank Sync' },
|
||||
];
|
||||
|
||||
const journalEntries = [
|
||||
{ id: 'BNK1/2026/001', date: '2026-01-20', journal: 'Bank', partner: 'Azure Interior', reference: 'INV/2026/001', amount: '$ 1,250.00', status: 'Posted' },
|
||||
{ id: 'INV/2026/042', date: '2026-01-21', journal: 'Customer Invoices', partner: 'Deco Addict', reference: 'Sales Order S0002', amount: '$ 4,500.00', status: 'Draft' },
|
||||
{ id: 'BILL/2026/012', date: '2026-01-21', journal: 'Vendor Bills', partner: 'Fresh Produce Co.', reference: 'Monthly Supply', amount: '$ -850.00', status: 'Posted' },
|
||||
{ id: 'MISC/2026/005', date: '2026-01-22', journal: 'Miscellaneous', partner: '', reference: 'Year End Adjustment', amount: '$ 0.00', status: 'Posted' },
|
||||
];
|
||||
|
||||
export default function AccountingPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
return (
|
||||
<AppLayout title="Accounting Ledger" sidebarItems={sidebarItems}>
|
||||
<div className={styles.container}>
|
||||
{/* Ledger Header */}
|
||||
<div className={styles.header}>
|
||||
<div className={styles.statsRow}>
|
||||
<div className={styles.statBox}>
|
||||
<span className={styles.statLabel}>Total Assets</span>
|
||||
<span className={styles.statValue}>$ 245,600.00</span>
|
||||
</div>
|
||||
<div className={styles.statBox}>
|
||||
<span className={styles.statLabel}>Accounts Receivable</span>
|
||||
<span className={styles.statValue}>$ 12,450.00</span>
|
||||
</div>
|
||||
<div className={styles.statBox}>
|
||||
<span className={styles.statLabel}>Accounts Payable</span>
|
||||
<span className={styles.statValue} style={{ color: '#ef4444' }}>$ -4,200.00</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<div className={styles.searchBox}>
|
||||
<Search size={18} />
|
||||
<input type="text" placeholder="Search entries..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<button className={styles.addBtn}><Plus size={18} /> New Entry</button>
|
||||
<button className={styles.exportBtn}><Download size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ledger Table */}
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.ledgerTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" /></th>
|
||||
<th>Date</th>
|
||||
<th>Number</th>
|
||||
<th>Journal</th>
|
||||
<th>Partner</th>
|
||||
<th>Reference</th>
|
||||
<th>Amount</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{journalEntries.map((entry) => (
|
||||
<tr key={entry.id} className={styles.row}>
|
||||
<td><input type="checkbox" /></td>
|
||||
<td>{entry.date}</td>
|
||||
<td className={styles.entryNumber}>{entry.id}</td>
|
||||
<td>{entry.journal}</td>
|
||||
<td>{entry.partner || '-'}</td>
|
||||
<td>{entry.reference}</td>
|
||||
<td className={styles.amount} style={{ color: entry.amount.startsWith('$ -') ? '#ef4444' : '#1e293b' }}>
|
||||
{entry.amount}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`${styles.statusBadge} ${styles[entry.status.toLowerCase()]}`}>
|
||||
{entry.status}
|
||||
</span>
|
||||
</td>
|
||||
<td><MoreHorizontal size={18} className={styles.moreIcon} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
47
src/app/api/auth/login/route.ts
Normal file
47
src/app/api/auth/login/route.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import dbConnect from '@/lib/mongodb';
|
||||
import User from '@/models/User';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your_super_secret_jwt_key';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { email, password } = await req.json();
|
||||
|
||||
await dbConnect();
|
||||
|
||||
const user = await User.findOne({ email });
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, user.password);
|
||||
if (!isMatch) {
|
||||
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{ userId: user._id, role: user.role, name: user.name },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '1d' }
|
||||
);
|
||||
|
||||
const response = NextResponse.json({
|
||||
message: 'Login successful',
|
||||
user: { id: user._id, name: user.name, email: user.email, role: user.role }
|
||||
});
|
||||
|
||||
response.cookies.set('auth_token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 86400,
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
28
src/app/api/auth/me/route.ts
Normal file
28
src/app/api/auth/me/route.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('token')?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as any;
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: decoded.userId,
|
||||
name: decoded.name,
|
||||
role: decoded.role,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
63
src/app/api/auth/reset-password/route.ts
Normal file
63
src/app/api/auth/reset-password/route.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import dbConnect from '@/lib/mongodb';
|
||||
import User from '@/models/User';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { email } = await req.json();
|
||||
|
||||
await dbConnect();
|
||||
|
||||
const user = await User.findOne({ email });
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const resetToken = crypto.randomBytes(32).toString('hex');
|
||||
const resetTokenExpire = new Date(Date.now() + 3600000); // 1 hour
|
||||
|
||||
user.resetPasswordToken = resetToken;
|
||||
user.resetPasswordExpires = resetTokenExpire;
|
||||
await user.save();
|
||||
|
||||
// In a real app, you would send an email here using nodemailer
|
||||
// console.log(`Reset token: ${resetToken}`);
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'Password reset link sent to your email (simulated)',
|
||||
token: resetToken // Only returning for demonstration purposes
|
||||
});
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
try {
|
||||
const { token, newPassword } = await req.json();
|
||||
|
||||
await dbConnect();
|
||||
|
||||
const user = await User.findOne({
|
||||
resetPasswordToken: token,
|
||||
resetPasswordExpires: { $gt: Date.now() }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Invalid or expired token' }, { status: 400 });
|
||||
}
|
||||
|
||||
const bcrypt = require('bcryptjs');
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
user.password = hashedPassword;
|
||||
user.resetPasswordToken = undefined;
|
||||
user.resetPasswordExpires = undefined;
|
||||
await user.save();
|
||||
|
||||
return NextResponse.json({ message: 'Password has been reset successfully' });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
33
src/app/api/auth/signup/route.ts
Normal file
33
src/app/api/auth/signup/route.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import dbConnect from '@/lib/mongodb';
|
||||
import User from '@/models/User';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { name, email, password } = await req.json();
|
||||
|
||||
await dbConnect();
|
||||
|
||||
const existingUser = await User.findOne({ email });
|
||||
if (existingUser) {
|
||||
return NextResponse.json({ error: 'Email already in use' }, { status: 400 });
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
const user = await User.create({
|
||||
name,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
role: 'admin', // Default first user as admin
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
message: 'User created successfully',
|
||||
user: { id: user._id, name: user.name, email: user.email }
|
||||
}, { status: 201 });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
218
src/app/auth/auth.module.css
Normal file
218
src/app/auth/auth.module.css
Normal file
@ -0,0 +1,218 @@
|
||||
.authPage {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(circle at top left, rgba(113, 75, 103, 0.05) 0%, transparent 40%),
|
||||
radial-gradient(circle at bottom right, rgba(1, 126, 132, 0.05) 0%, transparent 40%),
|
||||
var(--bg-main);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.authCard {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
border: 1px solid var(--glass-border);
|
||||
padding: 3rem;
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.logoSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.logoIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--pk-primary);
|
||||
font-weight: 800;
|
||||
font-size: 1.25rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.logoSection h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.logoSection h1 span {
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid rgba(185, 28, 28, 0.1);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.inputGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.labelRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inputGroup label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.forgotPass {
|
||||
font-size: 0.8rem;
|
||||
color: var(--pk-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inputGroup input {
|
||||
padding: 0.8rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
transition: var(--transition);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.inputGroup input:focus {
|
||||
border-color: var(--pk-primary);
|
||||
box-shadow: 0 0 0 4px rgba(113, 75, 103, 0.1);
|
||||
}
|
||||
|
||||
.submitBtn {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--pk-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.successMessage {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid rgba(21, 128, 61, 0.1);
|
||||
}
|
||||
|
||||
.backToLogin {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.backToLogin:hover {
|
||||
color: var(--pk-primary);
|
||||
}
|
||||
|
||||
.submitBtn:hover:not(:disabled) {
|
||||
background: var(--pk-primary-hover);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 15px -3px rgba(113, 75, 103, 0.3);
|
||||
}
|
||||
|
||||
.submitBtn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: rotate 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.switchAuth {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.switchAuth a {
|
||||
color: var(--pk-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.switchAuth a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
98
src/app/auth/login/page.tsx
Normal file
98
src/app/auth/login/page.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LogIn, Mail, Lock, Loader2 } from 'lucide-react';
|
||||
import { setCookie } from 'cookies-next';
|
||||
import styles from '../auth.module.css';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [formData, setFormData] = useState({ email: '', password: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch('http://localhost:5000/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Login failed');
|
||||
|
||||
// Set token in cookies for the frontend to use
|
||||
setCookie('auth_token', data.token, { maxAge: 86400, path: '/' });
|
||||
|
||||
// Store user info in localStorage for quick access
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
|
||||
router.push('/');
|
||||
router.refresh();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.authPage}>
|
||||
<div className={styles.authCard}>
|
||||
<div className={styles.logoSection}>
|
||||
<div className={styles.logoIcon}>O</div>
|
||||
<h1>Odoo<span>Next</span></h1>
|
||||
</div>
|
||||
|
||||
<div className={styles.header}>
|
||||
<h2>Welcome Back</h2>
|
||||
<p>Login to manage your business</p>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<div className={styles.inputGroup}>
|
||||
<label><Mail size={16} /> Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="name@company.com"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputGroup}>
|
||||
<div className={styles.labelRow}>
|
||||
<label><Lock size={16} /> Password</label>
|
||||
<Link href="/auth/reset-password" className={styles.forgotPass}>Forgot?</Link>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={loading}>
|
||||
{loading ? <Loader2 className={styles.spinner} size={20} /> : <><LogIn size={20} /> Login</>}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className={styles.switchAuth}>
|
||||
Don't have an account? <Link href="/auth/signup">Sign Up</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
src/app/auth/reset-password/page.tsx
Normal file
160
src/app/auth/reset-password/page.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, Suspense } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Mail, Loader2, ArrowLeft, ShieldCheck, Lock } from 'lucide-react';
|
||||
import styles from '../auth.module.css';
|
||||
|
||||
function ResetPasswordForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// If no token, we show the "Forgot Password" (request link) UI
|
||||
// If token exists, we show the "Set New Password" UI
|
||||
const isResetting = !!token;
|
||||
|
||||
const handleRequestLink = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
const res = await fetch('http://localhost:5000/api/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to send reset link');
|
||||
|
||||
setMessage(data.message);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
const res = await fetch('http://localhost:5000/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Reset failed');
|
||||
|
||||
setMessage('Password reset successful! Redirecting to login...');
|
||||
setTimeout(() => router.push('/auth/login'), 2000);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.authPage}>
|
||||
<div className={styles.authCard}>
|
||||
<div className={styles.logoSection}>
|
||||
<div className={styles.logoIcon}>O</div>
|
||||
<h1>Odoo<span>Next</span></h1>
|
||||
</div>
|
||||
|
||||
<div className={styles.header}>
|
||||
<h2>{isResetting ? 'Set New Password' : 'Reset Password'}</h2>
|
||||
<p>{isResetting ? 'Enter your new password below' : "We'll send you a link to get back into your account"}</p>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||
{message && (
|
||||
<div className={styles.successMessage}>
|
||||
<ShieldCheck size={20} />
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isResetting ? (
|
||||
<form className={styles.form} onSubmit={handleRequestLink}>
|
||||
<div className={styles.inputGroup}>
|
||||
<label><Mail size={16} /> Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="name@company.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={loading}>
|
||||
{loading ? <Loader2 className={styles.spinner} size={20} /> : 'Send Reset Link'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form className={styles.form} onSubmit={handleResetPassword}>
|
||||
<div className={styles.inputGroup}>
|
||||
<label><Lock size={16} /> New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.inputGroup}>
|
||||
<label><Lock size={16} /> Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={loading}>
|
||||
{loading ? <Loader2 className={styles.spinner} size={20} /> : 'Update Password'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<Link href="/auth/login" className={styles.backToLogin}>
|
||||
<ArrowLeft size={16} /> Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<ResetPasswordForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
104
src/app/auth/signup/page.tsx
Normal file
104
src/app/auth/signup/page.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { UserPlus, Mail, Lock, User as UserIcon, Loader2 } from 'lucide-react';
|
||||
import { setCookie } from 'cookies-next';
|
||||
import styles from '../auth.module.css';
|
||||
|
||||
export default function SignupPage() {
|
||||
const [formData, setFormData] = useState({ name: '', email: '', password: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch('http://localhost:5000/api/auth/signup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Signup failed');
|
||||
|
||||
// Set token and user data similar to login
|
||||
setCookie('auth_token', data.token, { maxAge: 86400, path: '/' });
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
|
||||
router.push('/');
|
||||
router.refresh();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.authPage}>
|
||||
<div className={styles.authCard}>
|
||||
<div className={styles.logoSection}>
|
||||
<div className={styles.logoIcon}>O</div>
|
||||
<h1>Odoo<span>Next</span></h1>
|
||||
</div>
|
||||
|
||||
<div className={styles.header}>
|
||||
<h2>Create Account</h2>
|
||||
<p>Get started with your free ERP trial</p>
|
||||
</div>
|
||||
|
||||
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<div className={styles.inputGroup}>
|
||||
<label><UserIcon size={16} /> Full Name</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputGroup}>
|
||||
<label><Mail size={16} /> Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="name@company.com"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.inputGroup}>
|
||||
<label><Lock size={16} /> Password</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.submitBtn} disabled={loading}>
|
||||
{loading ? <Loader2 className={styles.spinner} size={20} /> : <><UserPlus size={20} /> Create Account</>}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className={styles.switchAuth}>
|
||||
Already have an account? <Link href="/auth/login">Login</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
src/app/crm/crm.module.css
Normal file
151
src/app/crm/crm.module.css
Normal file
@ -0,0 +1,151 @@
|
||||
.kanbanBoard {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
height: calc(100vh - 180px);
|
||||
overflow-x: auto;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.column {
|
||||
min-width: 300px;
|
||||
max-width: 300px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.columnHeader {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.columnTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.columnName {
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
font-size: 0.95rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.columnBadge {
|
||||
background: #cbd5e1;
|
||||
color: #475569;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.columnTotal {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.columnContent {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid transparent;
|
||||
transition: var(--transition);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--pk-primary);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.companyName {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.cardFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cardValue {
|
||||
font-weight: 700;
|
||||
color: var(--pk-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.priority {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.star {
|
||||
color: #e2e8f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.starActive {
|
||||
color: #f59e0b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.addCardBtn {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: transparent;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 8px;
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.addCardBtn:hover {
|
||||
background: white;
|
||||
color: var(--pk-primary);
|
||||
border-color: var(--pk-primary);
|
||||
}
|
||||
105
src/app/crm/page.tsx
Normal file
105
src/app/crm/page.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import AppLayout from '@/components/AppLayout/AppLayout';
|
||||
import {
|
||||
Users,
|
||||
LayoutDashboard,
|
||||
Calendar,
|
||||
MessageSquare,
|
||||
MoreVertical,
|
||||
Plus
|
||||
} from 'lucide-react';
|
||||
import styles from './crm.module.css';
|
||||
|
||||
const sidebarItems = [
|
||||
{ icon: <LayoutDashboard size={20} />, label: 'Dashboard' },
|
||||
{ icon: <Users size={20} />, label: 'Pipelines', active: true },
|
||||
{ icon: <Calendar size={20} />, label: 'Activities' },
|
||||
{ icon: <MessageSquare size={20} />, label: 'Leads' },
|
||||
];
|
||||
|
||||
const kanbanColumns = [
|
||||
{
|
||||
id: 'new',
|
||||
name: 'New',
|
||||
count: 3,
|
||||
total: '$ 12,500',
|
||||
items: [
|
||||
{ id: '1', title: 'Plan for new office', company: 'Azure Interior', value: '$ 5,000', priority: 2 },
|
||||
{ id: '2', title: 'Consultation for kitchen', company: 'Deco Addict', value: '$ 2,500', priority: 1 },
|
||||
{ id: '3', title: 'Interior design project', company: 'Ready Mat', value: '$ 5,000', priority: 3 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'qualified',
|
||||
name: 'Qualified',
|
||||
count: 2,
|
||||
total: '$ 45,000',
|
||||
items: [
|
||||
{ id: '4', title: 'Full house renovation', company: 'Gemini Corp', value: '$ 30,000', priority: 3 },
|
||||
{ id: '5', title: 'Office furniture setup', company: 'The Corner', value: '$ 15,000', priority: 2 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'proposition',
|
||||
name: 'Proposition',
|
||||
count: 1,
|
||||
total: '$ 8,000',
|
||||
items: [
|
||||
{ id: '6', title: 'Smart home automation', company: 'Lumiere', value: '$ 8,000', priority: 3 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'won',
|
||||
name: 'Won',
|
||||
count: 0,
|
||||
total: '$ 0',
|
||||
items: []
|
||||
},
|
||||
];
|
||||
|
||||
export default function CRMPage() {
|
||||
return (
|
||||
<AppLayout title="CRM" sidebarItems={sidebarItems}>
|
||||
<div className={styles.kanbanBoard}>
|
||||
{kanbanColumns.map((column) => (
|
||||
<div key={column.id} className={styles.column}>
|
||||
<div className={styles.columnHeader}>
|
||||
<div className={styles.columnTitle}>
|
||||
<span className={styles.columnName}>{column.name}</span>
|
||||
<span className={styles.columnBadge}>{column.count}</span>
|
||||
</div>
|
||||
<div className={styles.columnTotal}>{column.total}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.columnContent}>
|
||||
{column.items.map((item) => (
|
||||
<div key={item.id} className={styles.card}>
|
||||
<div className={styles.cardHeader}>
|
||||
<span className={styles.cardTitle}>{item.title}</span>
|
||||
<MoreVertical size={14} className={styles.cardActions} />
|
||||
</div>
|
||||
<div className={styles.cardBody}>
|
||||
<div className={styles.companyName}>{item.company}</div>
|
||||
<div className={styles.cardFooter}>
|
||||
<div className={styles.cardValue}>{item.value}</div>
|
||||
<div className={styles.priority}>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<span key={i} className={i < item.priority ? styles.starActive : styles.star}>★</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button className={styles.addCardBtn}>
|
||||
<Plus size={16} /> Add a card
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
180
src/app/dashboard/dashboard.module.css
Normal file
180
src/app/dashboard/dashboard.module.css
Normal file
@ -0,0 +1,180 @@
|
||||
.dashboardGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.kpiRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.kpiCard {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.kpiCard:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.kpiHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.kpiIcon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.change {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.up {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.down {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.kpiValue {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
margin-bottom: 0.25rem;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.kpiLabel {
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.contentRow {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.sectionHeader h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.moreBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chartSection,
|
||||
.activitySection {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.chartPlaceholder {
|
||||
height: 250px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.svgChart {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.chartLabels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.activityList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.activityItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.activityUser {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
color: var(--pk-primary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.activityInfo {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.activityInfo p {
|
||||
font-size: 0.9rem;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.activityInfo span {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.activityTime {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.contentRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
116
src/app/dashboard/page.tsx
Normal file
116
src/app/dashboard/page.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import AppLayout from '@/components/AppLayout/AppLayout';
|
||||
import {
|
||||
TrendingUp,
|
||||
Users,
|
||||
ShoppingCart,
|
||||
DollarSign,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Clock,
|
||||
MoreVertical
|
||||
} from 'lucide-react';
|
||||
import styles from './dashboard.module.css';
|
||||
|
||||
const sidebarItems = [
|
||||
{ icon: <TrendingUp size={20} />, label: 'Overview', active: true },
|
||||
{ icon: <Users size={20} />, label: 'Analytics' },
|
||||
{ icon: <Clock size={20} />, label: 'History' },
|
||||
];
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Total Revenue', value: '$ 124,500', change: '+12.5%', isUp: true, icon: <DollarSign size={24} />, color: '#714B67' },
|
||||
{ label: 'Sales Orders', value: '1,420', change: '+8.2%', isUp: true, icon: <ShoppingCart size={24} />, color: '#017E84' },
|
||||
{ label: 'New Customers', value: '384', change: '-2.4%', isUp: false, icon: <Users size={24} />, color: '#4B7BEC' },
|
||||
];
|
||||
|
||||
const activities = [
|
||||
{ id: 1, user: 'James Doe', action: 'Confirmed Sales Order', detail: 'S00042', time: '2 mins ago' },
|
||||
{ id: 2, user: 'Alice Smith', action: 'Created New Lead', detail: 'Azure Interior', time: '15 mins ago' },
|
||||
{ id: 3, user: 'System', action: 'Inventory Alert', detail: 'Low stock on Office Chair', time: '1 hour ago' },
|
||||
{ id: 4, user: 'Bob Wilson', action: 'Payment Received', detail: '$ 450.00 from Deco Addict', time: '3 hours ago' },
|
||||
];
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<AppLayout title="Dashboard" sidebarItems={sidebarItems}>
|
||||
<div className={styles.dashboardGrid}>
|
||||
{/* KPI Row */}
|
||||
<section className={styles.kpiRow}>
|
||||
{kpis.map((kpi, idx) => (
|
||||
<div key={idx} className={styles.kpiCard}>
|
||||
<div className={styles.kpiHeader}>
|
||||
<div className={styles.kpiIcon} style={{ backgroundColor: `${kpi.color}15`, color: kpi.color }}>
|
||||
{kpi.icon}
|
||||
</div>
|
||||
<div className={`${styles.change} ${kpi.isUp ? styles.up : styles.down}`}>
|
||||
{kpi.isUp ? <ArrowUpRight size={14} /> : <ArrowDownRight size={14} />}
|
||||
{kpi.change}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.kpiValue}>{kpi.value}</div>
|
||||
<div className={styles.kpiLabel}>{kpi.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Charts & Reports */}
|
||||
<div className={styles.contentRow}>
|
||||
<section className={styles.chartSection}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h3>Revenue Growth</h3>
|
||||
<button className={styles.moreBtn}><MoreVertical size={18} /></button>
|
||||
</div>
|
||||
<div className={styles.chartPlaceholder}>
|
||||
{/* Fake Chart SVG */}
|
||||
<svg viewBox="0 0 400 150" className={styles.svgChart}>
|
||||
<path
|
||||
d="M0 120 Q 50 110, 100 130 T 200 80 T 300 100 T 400 40"
|
||||
fill="none"
|
||||
stroke="var(--pk-primary)"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M0 150 L 0 120 Q 50 110, 100 130 T 200 80 T 300 100 T 400 40 L 400 150 Z"
|
||||
fill="url(#gradient)"
|
||||
opacity="0.1"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="var(--pk-primary)" />
|
||||
<stop offset="100%" stopColor="transparent" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div className={styles.chartLabels}>
|
||||
<span>Jan</span><span>Feb</span><span>Mar</span><span>Apr</span><span>May</span><span>Jun</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.activitySection}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h3>Recent Activity</h3>
|
||||
<button className={styles.moreBtn}><MoreVertical size={18} /></button>
|
||||
</div>
|
||||
<div className={styles.activityList}>
|
||||
{activities.map(activity => (
|
||||
<div key={activity.id} className={styles.activityItem}>
|
||||
<div className={styles.activityUser}>{activity.user[0]}</div>
|
||||
<div className={styles.activityInfo}>
|
||||
<p><strong>{activity.user}</strong> {activity.action}</p>
|
||||
<span>{activity.detail}</span>
|
||||
</div>
|
||||
<div className={styles.activityTime}>{activity.time}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
269
src/app/financials/financials.module.css
Normal file
269
src/app/financials/financials.module.css
Normal file
@ -0,0 +1,269 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.titleArea h2 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.titleArea p {
|
||||
color: #64748b;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dateSelector,
|
||||
.exportBtn {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.dateSelector {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.exportBtn {
|
||||
background: #1e293b;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.metricsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.metricCard {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.metricLabel {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.metricMain {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.metricValue {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.up {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.down {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.miniChart {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.contentRow {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.chartCard,
|
||||
.topItemsCard {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 24px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.sectionHeader h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.revenueL::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #714b67;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.costL::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.visualPlaceholder {
|
||||
height: 250px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.svgChart {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chartLabels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
padding: 1rem 0 0 0;
|
||||
}
|
||||
|
||||
.itemsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.itemRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.itemInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.itemInfo strong {
|
||||
font-size: 0.95rem;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.itemInfo span {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.itemStats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.itemRevenue {
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.itemMargin {
|
||||
font-size: 0.75rem;
|
||||
color: #10b981;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.viewDetailed {
|
||||
margin-top: 2rem;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--pk-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.viewDetailed:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.contentRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
119
src/app/financials/page.tsx
Normal file
119
src/app/financials/page.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import AppLayout from '@/components/AppLayout/AppLayout';
|
||||
import {
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
PieChart,
|
||||
Calendar,
|
||||
Filter,
|
||||
Download,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
ChevronRight,
|
||||
LayoutDashboard,
|
||||
Wallet
|
||||
} from 'lucide-react';
|
||||
import styles from './financials.module.css';
|
||||
|
||||
const sidebarItems = [
|
||||
{ icon: <LayoutDashboard size={20} />, label: 'Dashboard' },
|
||||
{ icon: <Wallet size={20} />, label: 'Financial Reports', active: true },
|
||||
];
|
||||
|
||||
const metrics = [
|
||||
{ label: 'Total Revenue', value: '$ 42,850', sub: '+18% vs last month', isUp: true, color: '#017e84' },
|
||||
{ label: 'Ingredient Costs', value: '$ 12,400', sub: '+5% vs last month', isUp: false, color: '#ef4444' },
|
||||
{ label: 'Net Profit', value: '$ 30,450', sub: '+24% vs last month', isUp: true, color: '#714b67' },
|
||||
{ label: 'Avg. Check', value: '$ 64.20', sub: '-2% vs last week', isUp: false, color: '#f59e0b' },
|
||||
];
|
||||
|
||||
const topItems = [
|
||||
{ name: 'Classic Burger', sales: 450, revenue: '$ 5,845', margin: '65%' },
|
||||
{ name: 'Margherita Pizza', sales: 320, revenue: '$ 4,640', margin: '72%' },
|
||||
{ name: 'Caesar Salad', sales: 210, revenue: '$ 1,680', margin: '80%' },
|
||||
];
|
||||
|
||||
export default function FinancialsPage() {
|
||||
return (
|
||||
<AppLayout title="Financial Reporting" sidebarItems={sidebarItems}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titleArea}>
|
||||
<h2>Profit & Loss Insights</h2>
|
||||
<p>Real-time financial performance and cost analysis</p>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<button className={styles.dateSelector}><Calendar size={18} /> January 2026</button>
|
||||
<button className={styles.exportBtn}><Download size={18} /> Export PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<div className={styles.metricsGrid}>
|
||||
{metrics.map((m, idx) => (
|
||||
<div key={idx} className={styles.metricCard}>
|
||||
<span className={styles.metricLabel}>{m.label}</span>
|
||||
<div className={styles.metricMain}>
|
||||
<span className={styles.metricValue}>{m.value}</span>
|
||||
<div className={`${styles.badge} ${m.isUp ? styles.up : styles.down}`}>
|
||||
{m.isUp ? <ArrowUpRight size={14} /> : <ArrowDownRight size={14} />}
|
||||
{m.sub.split(' ')[0]}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.miniChart}>
|
||||
<div className={styles.progress} style={{ width: '70%', background: m.color }}></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.contentRow}>
|
||||
{/* Sales vs Cost Chart Placeholder */}
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h3>Revenue vs. Ingredient Cost</h3>
|
||||
<div className={styles.legend}>
|
||||
<span className={styles.revenueL}>Revenue</span>
|
||||
<span className={styles.costL}>Cost</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.visualPlaceholder}>
|
||||
<svg viewBox="0 0 500 200" className={styles.svgChart}>
|
||||
<path d="M0 150 Q 100 130, 200 140 T 300 80 T 400 60 T 500 20" fill="none" stroke="#714b67" strokeWidth="4" />
|
||||
<path d="M0 180 Q 100 170, 200 175 T 300 140 T 400 130 T 500 110" fill="none" stroke="#ef4444" strokeWidth="2" strokeDasharray="5,5" />
|
||||
</svg>
|
||||
<div className={styles.chartLabels}>
|
||||
<span>W1</span><span>W2</span><span>W3</span><span>W4</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Products Margin */}
|
||||
<div className={styles.topItemsCard}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h3>High Margin Items</h3>
|
||||
</div>
|
||||
<div className={styles.itemsList}>
|
||||
{topItems.map((item, idx) => (
|
||||
<div key={idx} className={styles.itemRow}>
|
||||
<div className={styles.itemInfo}>
|
||||
<strong>{item.name}</strong>
|
||||
<span>{item.sales} sold</span>
|
||||
</div>
|
||||
<div className={styles.itemStats}>
|
||||
<span className={styles.itemRevenue}>{item.revenue}</span>
|
||||
<span className={styles.itemMargin}>{item.margin} Margin</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className={styles.viewDetailed}>Full Menu Analysis <ChevronRight size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
127
src/app/globals.css
Normal file
127
src/app/globals.css
Normal file
@ -0,0 +1,127 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
:root {
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-heading: 'Outfit', sans-serif;
|
||||
|
||||
/* Premium Palette */
|
||||
--pk-primary: #714b67;
|
||||
--pk-primary-hover: #5d3d53;
|
||||
--pk-secondary: #017e84;
|
||||
--pk-accent: #e91e63;
|
||||
|
||||
--bg-main: #f0f2f5;
|
||||
--bg-card: rgba(255, 255, 255, 0.8);
|
||||
--bg-sidebar: #2c3e50;
|
||||
|
||||
--text-main: #1a1a1a;
|
||||
--text-muted: #64748b;
|
||||
--text-on-dark: #f8fafc;
|
||||
|
||||
--border-radius: 12px;
|
||||
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
|
||||
/* Glassmorphism */
|
||||
--glass-bg: rgba(255, 255, 255, 0.7);
|
||||
--glass-border: rgba(255, 255, 255, 0.3);
|
||||
--glass-blur: blur(12px);
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
--bg-main: #0f172a;
|
||||
--bg-card: rgba(30, 41, 59, 0.7);
|
||||
--text-main: #f8fafc;
|
||||
--text-muted: #94a3b8;
|
||||
--glass-bg: rgba(15, 23, 42, 0.7);
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-main);
|
||||
color: var(--text-main);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.glass {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, var(--pk-primary), var(--pk-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
230
src/app/inventory/inventory.module.css
Normal file
230
src/app/inventory/inventory.module.css
Normal file
@ -0,0 +1,230 @@
|
||||
.inventoryContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.statsRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.statCard {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.statIcon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.statInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.searchWrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.searchWrapper input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 2.5rem;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.searchWrapper input:focus {
|
||||
border-color: var(--pk-primary);
|
||||
box-shadow: 0 0 0 4px rgba(113, 75, 103, 0.1);
|
||||
}
|
||||
|
||||
.toolActions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.filterBtn,
|
||||
.addBtn {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.filterBtn {
|
||||
background: white;
|
||||
color: #475569;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
background: var(--pk-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.stockList {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr 1fr 0.5fr;
|
||||
padding: 1rem 1.5rem;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.listBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr 1fr 0.5fr;
|
||||
padding: 1.25rem 1.5rem;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
transition: var(--transition);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.listItem:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.itemName {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.itemIcon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--pk-primary);
|
||||
font-weight: 800;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.itemCategory {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.itemQty,
|
||||
.itemMin {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.instock {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.lowstock {
|
||||
background: #fef9c3;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
.outofstock {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
opacity: 0;
|
||||
transition: var(--transition);
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
|
||||
.listItem:hover .arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
116
src/app/inventory/page.tsx
Normal file
116
src/app/inventory/page.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import AppLayout from '@/components/AppLayout/AppLayout';
|
||||
import {
|
||||
Package,
|
||||
AlertTriangle,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
BarChart2,
|
||||
RefreshCw,
|
||||
MoreVertical,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import styles from './inventory.module.css';
|
||||
|
||||
const sidebarItems = [
|
||||
{ icon: <Package size={20} />, label: 'Stock Levels', active: true },
|
||||
{ icon: <TrendingUp size={20} />, label: 'Incoming Orders' },
|
||||
{ icon: <BarChart2 size={20} />, label: 'Forecast' },
|
||||
{ icon: <RefreshCw size={20} />, label: 'Valuation' },
|
||||
];
|
||||
|
||||
const mockItems = [
|
||||
{ id: 1, name: 'Burger Buns', category: 'Bread', quantity: 150, unit: 'pcs', minLevel: 50, status: 'In Stock' },
|
||||
{ id: 2, name: 'Beef Patties', category: 'Meat', quantity: 24, unit: 'kg', minLevel: 30, status: 'Low Stock' },
|
||||
{ id: 3, name: 'Cooking Oil', category: 'Pantry', quantity: 0, unit: 'L', minLevel: 10, status: 'Out of Stock' },
|
||||
{ id: 4, name: 'Cheddar Cheese', category: 'Dairy', quantity: 45, unit: 'kg', minLevel: 20, status: 'In Stock' },
|
||||
{ id: 5, name: 'Potatoes', category: 'Vegetables', quantity: 200, unit: 'kg', minLevel: 100, status: 'In Stock' },
|
||||
{ id: 6, name: 'Ketchup', category: 'Pantry', quantity: 12, unit: 'L', minLevel: 15, status: 'Low Stock' },
|
||||
];
|
||||
|
||||
export default function InventoryPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const stats = [
|
||||
{ label: 'Total Valuation', value: '$ 12,450', icon: <TrendingUp size={18} />, color: '#714B67' },
|
||||
{ label: 'Low Stock Items', value: '4 Assets', icon: <AlertTriangle size={18} />, color: '#f59e0b' },
|
||||
{ label: 'Out of Stock', value: '2 Items', icon: <TrendingDown size={18} />, color: '#ef4444' },
|
||||
];
|
||||
|
||||
return (
|
||||
<AppLayout title="Inventory" sidebarItems={sidebarItems}>
|
||||
<div className={styles.inventoryContainer}>
|
||||
{/* Stats Row */}
|
||||
<div className={styles.statsRow}>
|
||||
{stats.map((stat, idx) => (
|
||||
<div key={idx} className={styles.statCard}>
|
||||
<div className={styles.statIcon} style={{ background: `${stat.color}15`, color: stat.color }}>
|
||||
{stat.icon}
|
||||
</div>
|
||||
<div className={styles.statInfo}>
|
||||
<span className={styles.statLabel}>{stat.label}</span>
|
||||
<span className={styles.statValue}>{stat.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.searchWrapper}>
|
||||
<Search size={18} className={styles.searchIcon} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search products or ingredients..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.toolActions}>
|
||||
<button className={styles.filterBtn}><Filter size={18} /> Filters</button>
|
||||
<button className={styles.addBtn}><Plus size={18} /> Add Stock</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inventory List */}
|
||||
<div className={styles.stockList}>
|
||||
<div className={styles.listHeader}>
|
||||
<span>Item Name</span>
|
||||
<span>Category</span>
|
||||
<span>On Hand</span>
|
||||
<span>Min. Level</span>
|
||||
<span>Status</span>
|
||||
<span>Action</span>
|
||||
</div>
|
||||
<div className={styles.listBody}>
|
||||
{mockItems.map(item => (
|
||||
<div key={item.id} className={styles.listItem}>
|
||||
<div className={styles.itemName}>
|
||||
<div className={styles.itemIcon}>{item.name[0]}</div>
|
||||
{item.name}
|
||||
</div>
|
||||
<span className={styles.itemCategory}>{item.category}</span>
|
||||
<span className={styles.itemQty}>{item.quantity} {item.unit}</span>
|
||||
<span className={styles.itemMin}>{item.minLevel} {item.unit}</span>
|
||||
<div className={styles.statusCell}>
|
||||
<span className={`${styles.statusBadge} ${styles[item.status.toLowerCase().replace(/ /g, '')]}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<button className={styles.actionBtn}><MoreVertical size={16} /></button>
|
||||
<ChevronRight size={16} className={styles.arrow} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
152
src/app/kitchen/page.tsx
Normal file
152
src/app/kitchen/page.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ChefHat, Clock, CheckCircle, Check, RotateCcw, Truck } from 'lucide-react';
|
||||
|
||||
interface OrderItem {
|
||||
productId: number;
|
||||
name: string;
|
||||
qty: number;
|
||||
modifiers: string[];
|
||||
}
|
||||
|
||||
interface Order {
|
||||
_id: string;
|
||||
tableName: string;
|
||||
items: OrderItem[];
|
||||
status: 'KITCHEN' | 'READY' | 'SERVED';
|
||||
createdAt: string;
|
||||
elapsed?: string;
|
||||
}
|
||||
|
||||
export default function KitchenPage() {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
const res = await fetch('http://localhost:5000/api/pos/orders/kitchen');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setOrders(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Fetch kitchen error", e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
const interval = setInterval(fetchOrders, 10000); // Poll every 10s
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const updateStatus = async (id: string, status: string) => {
|
||||
try {
|
||||
await fetch(`http://localhost:5000/api/pos/orders/${id}/status`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
fetchOrders();
|
||||
} catch (error) {
|
||||
console.error("Update status error", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'KITCHEN': return '#f59e0b';
|
||||
case 'READY': return '#10b981';
|
||||
case 'SERVED': return '#3b82f6';
|
||||
default: return '#64748b';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ background: '#0f172a', minHeight: '100vh', color: 'white', fontFamily: 'Inter, sans-serif' }}>
|
||||
<header style={{ padding: '1.5rem 2rem', borderBottom: '1px solid #1e293b', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<ChefHat size={32} color="#f59e0b" />
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 800 }}>KITCHEN DISPLAY SYSTEM</h1>
|
||||
<span style={{ color: '#94a3b8', fontSize: '0.9rem' }}>Live Orders</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '2rem' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '1.5rem', fontWeight: 800, color: '#f59e0b' }}>{orders.filter(o => o.status === 'KITCHEN').length}</span>
|
||||
<span style={{ fontSize: '0.7rem', color: '#94a3b8', textTransform: 'uppercase' }}>Pending</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '1.5rem', fontWeight: 800, color: '#10b981' }}>{orders.filter(o => o.status === 'READY').length}</span>
|
||||
<span style={{ fontSize: '0.7rem', color: '#94a3b8', textTransform: 'uppercase' }}>Ready</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main style={{ padding: '2rem', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '1.5rem' }}>
|
||||
{orders.length === 0 && (
|
||||
<div style={{ gridColumn: '1/-1', textAlign: 'center', padding: '4rem', color: '#475569' }}>
|
||||
<ChefHat size={64} style={{ marginBottom: '1rem', opacity: 0.5 }} />
|
||||
<h2>No Active Orders</h2>
|
||||
<p>Waiting for new orders from POS...</p>
|
||||
</div>
|
||||
)}
|
||||
{orders.map(order => (
|
||||
<div key={order._id} style={{ background: '#1e293b', borderRadius: '16px', overflow: 'hidden', border: `2px solid ${getStatusColor(order.status)}` }}>
|
||||
<div style={{ padding: '1rem', background: getStatusColor(order.status), color: 'white', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 800, fontSize: '1.1rem' }}>{order.tableName}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.8rem', fontWeight: 600, background: 'rgba(0,0,0,0.2)', padding: '0.2rem 0.6rem', borderRadius: '8px' }}>
|
||||
<Clock size={14} />
|
||||
{new Date(order.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '1rem' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.8rem', maxHeight: '300px', overflowY: 'auto' }}>
|
||||
{order.items.map((item, idx) => (
|
||||
<div key={idx} style={{ display: 'flex', justifyContent: 'space-between', borderBottom: '1px dashed #334155', paddingBottom: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.8rem' }}>
|
||||
<span style={{ fontWeight: 800, color: '#f59e0b', fontSize: '1.1rem' }}>{item.qty}x</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '1rem' }}>{item.name}</span>
|
||||
{item.modifiers && item.modifiers.length > 0 && (
|
||||
<span style={{ fontSize: '0.8rem', color: '#94a3b8' }}>{item.modifiers.join(', ')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '1rem', borderTop: '1px solid #334155', display: 'flex', gap: '1rem' }}>
|
||||
{order.status === 'KITCHEN' && (
|
||||
<button
|
||||
onClick={() => updateStatus(order._id, 'READY')}
|
||||
style={{ flex: 1, padding: '0.8rem', background: '#10b981', border: 'none', borderRadius: '12px', color: 'white', fontWeight: 700, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<CheckCircle size={18} /> MARK READY
|
||||
</button>
|
||||
)}
|
||||
{order.status === 'READY' && (
|
||||
<button
|
||||
onClick={() => updateStatus(order._id, 'SERVED')}
|
||||
style={{ flex: 1, padding: '0.8rem', background: '#3b82f6', border: 'none', borderRadius: '12px', color: 'white', fontWeight: 700, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<Truck size={18} /> SERVE
|
||||
</button>
|
||||
)}
|
||||
{order.status === 'SERVED' && (
|
||||
<button
|
||||
onClick={() => updateStatus(order._id, 'COMPLETED')}
|
||||
style={{ flex: 1, padding: '0.8rem', background: '#64748b', border: 'none', borderRadius: '12px', color: 'white', fontWeight: 700, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
COMPLETE
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/app/layout.tsx
Normal file
23
src/app/layout.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dine360 - Premium ERP",
|
||||
description: "Dine360 - Premium ERP",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body suppressHydrationWarning>
|
||||
<div className="layout-wrapper">
|
||||
{children}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
288
src/app/page.module.css
Normal file
288
src/app/page.module.css
Normal file
@ -0,0 +1,288 @@
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-main);
|
||||
}
|
||||
|
||||
.bgGlow1 {
|
||||
position: absolute;
|
||||
top: -10%;
|
||||
left: -10%;
|
||||
width: 40%;
|
||||
height: 40%;
|
||||
background: radial-gradient(circle, rgba(113, 75, 103, 0.1) 0%, transparent 70%);
|
||||
filter: blur(80px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bgGlow2 {
|
||||
position: absolute;
|
||||
bottom: -10%;
|
||||
right: -10%;
|
||||
width: 40%;
|
||||
height: 40%;
|
||||
background: radial-gradient(circle, rgba(1, 126, 132, 0.1) 0%, transparent 70%);
|
||||
filter: blur(80px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 3rem auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
animation: fadeIn 0.8s ease-out;
|
||||
}
|
||||
|
||||
.logoArea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.logoIcon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--pk-primary);
|
||||
font-weight: 800;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.logoTitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.logoSubtitle {
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.searchWrapper {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
margin: 0 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 2.5rem;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 14px;
|
||||
outline: none;
|
||||
transition: var(--transition);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
border-color: var(--pk-primary);
|
||||
box-shadow: 0 0 0 4px rgba(113, 75, 103, 0.1);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.iconBtn:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--pk-accent);
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg-main);
|
||||
}
|
||||
|
||||
.userSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.userAvatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--pk-primary), var(--pk-secondary));
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
border: 2px solid white;
|
||||
box-shadow: var(--shadow);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logoutBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logoutBtn:hover {
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.loginBtn {
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: var(--pk-primary);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
transition: var(--transition);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.loginBtn:hover {
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.mainGrid {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 2.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.appItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.appIconWrapper {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
border-radius: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
box-shadow: 0 8px 16px -4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.appItem:hover .appIconWrapper {
|
||||
transform: translateY(-8px) scale(1.05) rotate(2deg);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.appName {
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 18px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.footerBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.75rem;
|
||||
border-radius: 12px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.footerBtn:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: var(--pk-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.searchWrapper {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mainGrid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
184
src/app/page.tsx
Normal file
184
src/app/page.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
BarChart3,
|
||||
MessageSquare,
|
||||
Users,
|
||||
ShoppingCart,
|
||||
Package,
|
||||
Settings,
|
||||
Search,
|
||||
Grid,
|
||||
Bell,
|
||||
Calendar,
|
||||
LayoutDashboard,
|
||||
Wallet,
|
||||
Building2,
|
||||
Cpu,
|
||||
Utensils,
|
||||
LogOut
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { deleteCookie, getCookie } from 'cookies-next';
|
||||
import styles from './page.module.css';
|
||||
|
||||
// Restaurant-focused modules only
|
||||
const apps = [
|
||||
{ id: 'restaurant', name: 'Restaurant', icon: <Utensils size={32} />, color: '#F1C40F' },
|
||||
{ id: 'pos', name: 'POS', icon: <ShoppingCart size={32} />, color: '#27AE60' },
|
||||
{ id: 'inventory', name: 'Inventory', icon: <Package size={32} />, color: '#786FA6' },
|
||||
{ id: 'accounting', name: 'Accounting', icon: <Wallet size={32} />, color: '#546DE5' },
|
||||
{ id: 'calendar', name: 'Calendar', icon: <Calendar size={32} />, color: '#017E84' },
|
||||
{ id: 'settings', name: 'Settings', icon: <Settings size={32} />, color: '#7F8C8D' },
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [user, setUser] = useState<{ id: string; name: string; role: string; email: string } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const token = getCookie('auth_token');
|
||||
if (!token) {
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('http://localhost:5000/api/auth/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setUser(data);
|
||||
} else {
|
||||
deleteCookie('auth_token');
|
||||
setUser(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
deleteCookie('auth_token');
|
||||
localStorage.removeItem('user');
|
||||
setUser(null);
|
||||
router.push('/auth/login');
|
||||
};
|
||||
|
||||
const filteredApps = apps.filter(app => {
|
||||
const matchesSearch = app.name.toLowerCase().includes(search.toLowerCase());
|
||||
if (!matchesSearch) return false;
|
||||
if (loading) return true;
|
||||
if (!user) return true; // Show all for guests (landing page feel)
|
||||
if (user.role === 'admin') return true;
|
||||
|
||||
const rolePermissions: Record<string, string[]> = {
|
||||
'waiter': ['POS', 'Restaurant', 'Calendar'],
|
||||
'cashier': ['POS', 'Accounting', 'Calendar'],
|
||||
'manager': ['Restaurant', 'POS', 'Inventory', 'Accounting', 'Calendar', 'Settings'],
|
||||
};
|
||||
|
||||
const allowedApps = rolePermissions[user.role as keyof typeof rolePermissions] || [];
|
||||
return allowedApps.includes(app.name);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.bgGlow1} />
|
||||
<div className={styles.bgGlow2} />
|
||||
|
||||
<header className={styles.header}>
|
||||
<div className={styles.logoArea}>
|
||||
{/* <div className={styles.logoIcon}>O</div> */}
|
||||
<h1 className={styles.logoTitle}>Dine360</h1>
|
||||
</div>
|
||||
|
||||
<div className={styles.searchWrapper}>
|
||||
<Search className={styles.searchIcon} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search your apps..."
|
||||
className={styles.searchInput}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button className={styles.iconBtn}>
|
||||
<Bell size={20} />
|
||||
<div className={styles.badge} />
|
||||
</button>
|
||||
{user ? (
|
||||
<div className={styles.userSection}>
|
||||
<div className={styles.userAvatar} title={user.name}>
|
||||
{user.name.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<button className={styles.logoutBtn} onClick={handleLogout} title="Logout">
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Link href="/auth/login" className={styles.loginBtn}>Login</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className={styles.mainGrid}>
|
||||
{filteredApps.map((app, index) => {
|
||||
const content = (
|
||||
<div
|
||||
key={app.id}
|
||||
className={styles.appItem}
|
||||
style={{ animationDelay: `${index * 0.05}s` }}
|
||||
>
|
||||
<div
|
||||
className={styles.appIconWrapper}
|
||||
style={{
|
||||
backgroundColor: app.color,
|
||||
boxShadow: `0 12px 24px -8px ${app.color}60`
|
||||
}}
|
||||
>
|
||||
{app.icon}
|
||||
</div>
|
||||
<span className={styles.appName}>{app.name}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={app.id}
|
||||
href={`/${app.id === 'employees' ? 'payroll' : app.id === 'discuss' ? 'dashboard' : app.id}`}
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</main>
|
||||
|
||||
<div className={styles.footerActions}>
|
||||
<button className={styles.footerBtn} title="App Switcher">
|
||||
<Grid size={24} />
|
||||
</button>
|
||||
<button className={styles.footerBtn} title="Home Dashboard">
|
||||
<LayoutDashboard size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
src/app/payroll/page.tsx
Normal file
117
src/app/payroll/page.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import AppLayout from '@/components/AppLayout/AppLayout';
|
||||
import {
|
||||
Users,
|
||||
CreditCard,
|
||||
Wallet,
|
||||
Calendar,
|
||||
FileCheck,
|
||||
Plus,
|
||||
Search,
|
||||
TrendingUp,
|
||||
Download,
|
||||
AlertCircle,
|
||||
LayoutDashboard,
|
||||
Building2,
|
||||
CheckCircle2
|
||||
} from 'lucide-react';
|
||||
import styles from './payroll.module.css';
|
||||
|
||||
const sidebarItems = [
|
||||
{ icon: <LayoutDashboard size={20} />, label: 'Employee Dashboard' },
|
||||
{ icon: <CreditCard size={20} />, label: 'Payslips', active: true },
|
||||
{ icon: <Wallet size={20} />, label: 'Salary Structures' },
|
||||
{ icon: <Calendar size={20} />, label: 'Attendances' },
|
||||
];
|
||||
|
||||
const payslips = [
|
||||
{ id: 'PSL/001', employee: 'Alice Johnson', period: 'January 2026', basic: '$ 3,500.00', allowance: '$ 450.00', deduction: '$ 200.00', net: '$ 3,750.00', status: 'Paid' },
|
||||
{ id: 'PSL/002', employee: 'Bob Smith', period: 'January 2026', basic: '$ 2,800.00', allowance: '$ 200.00', deduction: '$ 150.00', net: '$ 2,850.00', status: 'Draft' },
|
||||
{ id: 'PSL/003', employee: 'Charlie Dave', period: 'January 2026', basic: '$ 2,200.00', allowance: '$ 150.00', deduction: '$ 100.00', net: '$ 2,250.00', status: 'Waiting' },
|
||||
{ id: 'PSL/004', employee: 'Diana Prince', period: 'January 2026', basic: '$ 4,200.00', allowance: '$ 600.00', deduction: '$ 350.00', net: '$ 4,450.00', status: 'Paid' },
|
||||
];
|
||||
|
||||
export default function PayrollPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
return (
|
||||
<AppLayout title="Payroll Management" sidebarItems={sidebarItems}>
|
||||
<div className={styles.container}>
|
||||
{/* Payroll Summary */}
|
||||
<div className={styles.summaryRow}>
|
||||
<div className={styles.summaryCard}>
|
||||
<div className={styles.cardInfo}>
|
||||
<span className={styles.clabel}>Total Payroll (Jan)</span>
|
||||
<span className={styles.cvalue}>$ 13,300.00</span>
|
||||
</div>
|
||||
<div className={styles.cardIcon} style={{ background: '#714b6715', color: '#714b67' }}><TrendingUp size={24} /></div>
|
||||
</div>
|
||||
<div className={styles.summaryCard}>
|
||||
<div className={styles.cardInfo}>
|
||||
<span className={styles.clabel}>Pending Payments</span>
|
||||
<span className={styles.cvalue}>2 Batches</span>
|
||||
</div>
|
||||
<div className={styles.cardIcon} style={{ background: '#f59e0b15', color: '#f59e0b' }}><AlertCircle size={24} /></div>
|
||||
</div>
|
||||
<div className={styles.summaryCard}>
|
||||
<div className={styles.cardInfo}>
|
||||
<span className={styles.clabel}>Employees Paid</span>
|
||||
<span className={styles.cvalue}>24 / 26</span>
|
||||
</div>
|
||||
<div className={styles.cardIcon} style={{ background: '#10b98115', color: '#10b981' }}><CheckCircle2 size={24} /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.searchWrapper}>
|
||||
<Search size={18} className={styles.searchIcon} />
|
||||
<input type="text" placeholder="Search employee payslips..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<button className={styles.batchBtn}><FileCheck size={18} /> Generate Batch</button>
|
||||
<button className={styles.printBtn}><Download size={18} /> Export</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payslip Table */}
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.payrollTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Reference</th>
|
||||
<th>Employee</th>
|
||||
<th>Period</th>
|
||||
<th>Basic Salary</th>
|
||||
<th>Allowances</th>
|
||||
<th>Deductions</th>
|
||||
<th>Net Wage</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payslips.map((slip) => (
|
||||
<tr key={slip.id} className={styles.row}>
|
||||
<td className={styles.ref}>{slip.id}</td>
|
||||
<td className={styles.employeeName}>{slip.employee}</td>
|
||||
<td>{slip.period}</td>
|
||||
<td>{slip.basic}</td>
|
||||
<td style={{ color: '#10b981' }}>+ {slip.allowance}</td>
|
||||
<td style={{ color: '#ef4444' }}>- {slip.deduction}</td>
|
||||
<td className={styles.netWage}>{slip.net}</td>
|
||||
<td>
|
||||
<span className={`${styles.statusBadge} ${styles[slip.status.toLowerCase()]}`}>
|
||||
{slip.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
185
src/app/payroll/payroll.module.css
Normal file
185
src/app/payroll/payroll.module.css
Normal file
@ -0,0 +1,185 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.summaryRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.summaryCard {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.cardInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.clabel {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cvalue {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.searchWrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.searchWrapper input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 2.5rem;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.batchBtn {
|
||||
background: var(--pk-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.printBtn {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.payrollTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.payrollTable th {
|
||||
padding: 1rem 1.5rem;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.payrollTable td {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 0.9rem;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.ref {
|
||||
font-weight: 700;
|
||||
color: var(--pk-primary);
|
||||
}
|
||||
|
||||
.employeeName {
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.netWage {
|
||||
font-weight: 800;
|
||||
color: var(--pk-secondary);
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.paid {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.draft {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.waiting {
|
||||
background: #fef9c3;
|
||||
color: #854d0e;
|
||||
}
|
||||
89
src/app/pos/PaymentCheckout.tsx
Normal file
89
src/app/pos/PaymentCheckout.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
|
||||
|
||||
interface PaymentCheckoutProps {
|
||||
amount: number;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function PaymentCheckout({ amount, onSuccess, onCancel }: PaymentCheckoutProps) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const { error, paymentIntent } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
// Return URL is required for some payment methods, but we try to handle inline
|
||||
return_url: window.location.origin + "/pos",
|
||||
},
|
||||
redirect: 'if_required',
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setMessage(error.message || "An unexpected error occurred.");
|
||||
setIsLoading(false);
|
||||
} else if (paymentIntent && paymentIntent.status === 'succeeded') {
|
||||
onSuccess();
|
||||
// Do not setIsLoading(false) here to prevent flicker before modal closes
|
||||
} else {
|
||||
// Unexpected status
|
||||
setMessage(`Payment Status: ${paymentIntent.status}`);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={{ width: '100%', padding: '1rem' }}>
|
||||
<PaymentElement />
|
||||
{message && <div style={{ color: '#ef4444', marginTop: '1rem', fontSize: '0.9rem', fontWeight: 600 }}>{message}</div>}
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem', marginTop: '2rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '1rem',
|
||||
background: 'transparent',
|
||||
border: '2px solid #e2e8f0',
|
||||
borderRadius: '16px',
|
||||
color: '#64748b',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!stripe || isLoading}
|
||||
style={{
|
||||
flex: 2,
|
||||
padding: '1rem',
|
||||
background: '#0f172a',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '16px',
|
||||
fontWeight: 800,
|
||||
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||
opacity: isLoading ? 0.7 : 1
|
||||
}}
|
||||
>
|
||||
{isLoading ? "Processing..." : `Pay ₹${amount.toFixed(0)}`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
1198
src/app/pos/page.tsx
Normal file
1198
src/app/pos/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1296
src/app/pos/pos.module.css
Normal file
1296
src/app/pos/pos.module.css
Normal file
File diff suppressed because it is too large
Load Diff
253
src/app/restaurant/menu/menu.module.css
Normal file
253
src/app/restaurant/menu/menu.module.css
Normal file
@ -0,0 +1,253 @@
|
||||
.menuContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.searchWrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.searchWrapper input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 2.5rem;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.viewToggle {
|
||||
display: flex;
|
||||
background: #f1f5f9;
|
||||
padding: 4px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.viewToggle button {
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.activeView {
|
||||
background: white !important;
|
||||
color: var(--pk-primary) !important;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.filterBtn,
|
||||
.addBtn {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filterBtn {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
background: var(--pk-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.menuGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.menuCard {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
transition: var(--transition);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menuCard:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.imagePlaceholder {
|
||||
height: 160px;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.cardInfo {
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.category {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--pk-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.cardFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.price {
|
||||
font-weight: 800;
|
||||
color: var(--pk-primary);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.editBtn {
|
||||
background: #f1f5f9;
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.available {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.limited {
|
||||
background: #fef9c3;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
.soldout {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.menuList {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.menuListItem {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr;
|
||||
padding: 1rem 1.5rem;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.itemMain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.listIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.itemDetail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.itemDetail strong {
|
||||
color: #1e293b;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.itemDetail span {
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.listPrice {
|
||||
font-weight: 700;
|
||||
color: var(--pk-primary);
|
||||
}
|
||||
|
||||
.listActions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.listActions button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
112
src/app/restaurant/menu/page.tsx
Normal file
112
src/app/restaurant/menu/page.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import AppLayout from '@/components/AppLayout/AppLayout';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Image as ImageIcon,
|
||||
Edit2,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
Grid,
|
||||
List,
|
||||
LayoutDashboard,
|
||||
Utensils,
|
||||
Monitor
|
||||
} from 'lucide-react';
|
||||
import styles from './menu.module.css';
|
||||
|
||||
const sidebarItems = [
|
||||
{ icon: <LayoutDashboard size={20} />, label: 'Floor Plan' },
|
||||
{ icon: <Utensils size={20} />, label: 'Orders' },
|
||||
{ icon: <Monitor size={20} />, label: 'KDS (Kitchen)' },
|
||||
{ icon: <Plus size={20} />, label: 'Menu Management', active: true },
|
||||
];
|
||||
|
||||
const mockMenu = [
|
||||
{ id: 1, name: 'Classic Burger', category: 'Main Course', price: 12.99, image: '🍔', status: 'Available' },
|
||||
{ id: 2, name: 'Margherita Pizza', category: 'Main Course', price: 14.50, image: '🍕', status: 'Available' },
|
||||
{ id: 3, name: 'Caesar Salad', category: 'Appetizers', price: 8.00, image: '🥗', status: 'Available' },
|
||||
{ id: 4, name: 'French Fries', category: 'Sides', price: 4.50, image: '🍟', status: 'Available' },
|
||||
{ id: 5, name: 'Chocolate Cake', category: 'Desserts', price: 6.50, image: '🍰', status: 'Limited' },
|
||||
{ id: 6, name: 'Fresh Orange Juice', category: 'Beverages', price: 3.50, image: '🥤', status: 'Available' },
|
||||
];
|
||||
|
||||
export default function MenuManagementPage() {
|
||||
const [view, setView] = useState<'grid' | 'list'>('grid');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
return (
|
||||
<AppLayout title="Restaurant Menu" sidebarItems={sidebarItems}>
|
||||
<div className={styles.menuContainer}>
|
||||
{/* Top Header */}
|
||||
<div className={styles.header}>
|
||||
<div className={styles.searchWrapper}>
|
||||
<Search size={18} className={styles.searchIcon} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search items..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<div className={styles.viewToggle}>
|
||||
<button onClick={() => setView('grid')} className={view === 'grid' ? styles.activeView : ''}><Grid size={18} /></button>
|
||||
<button onClick={() => setView('list')} className={view === 'list' ? styles.activeView : ''}><List size={18} /></button>
|
||||
</div>
|
||||
<button className={styles.filterBtn}><Filter size={18} /> Category</button>
|
||||
<button className={styles.addBtn}><Plus size={18} /> New Item</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{view === 'grid' ? (
|
||||
<div className={styles.menuGrid}>
|
||||
{mockMenu.map(item => (
|
||||
<div key={item.id} className={styles.menuCard}>
|
||||
<div className={styles.imagePlaceholder}>{item.image}</div>
|
||||
<div className={styles.cardInfo}>
|
||||
<div className={styles.cardHeader}>
|
||||
<span className={styles.category}>{item.category}</span>
|
||||
<span className={`${styles.statusBadge} ${styles[item.status.toLowerCase()]}`}>{item.status}</span>
|
||||
</div>
|
||||
<h3 className={styles.name}>{item.name}</h3>
|
||||
<div className={styles.cardFooter}>
|
||||
<span className={styles.price}>$ {item.price.toFixed(2)}</span>
|
||||
<div className={styles.cardActions}>
|
||||
<button className={styles.editBtn}><Edit2 size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.menuList}>
|
||||
{mockMenu.map(item => (
|
||||
<div key={item.id} className={styles.menuListItem}>
|
||||
<div className={styles.itemMain}>
|
||||
<div className={styles.listIcon}>{item.image}</div>
|
||||
<div className={styles.itemDetail}>
|
||||
<strong>{item.name}</strong>
|
||||
<span>{item.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={styles.listPrice}>$ {item.price.toFixed(2)}</span>
|
||||
<span className={`${styles.statusBadge} ${styles[item.status.toLowerCase()]}`}>{item.status}</span>
|
||||
<div className={styles.listActions}>
|
||||
<button><Edit2 size={16} /></button>
|
||||
<button><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
236
src/app/restaurant/page.tsx
Normal file
236
src/app/restaurant/page.tsx
Normal file
@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import AppLayout from '@/components/AppLayout/AppLayout';
|
||||
import {
|
||||
Users,
|
||||
LayoutDashboard,
|
||||
Utensils,
|
||||
Plus,
|
||||
Settings,
|
||||
Grid,
|
||||
Monitor,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
Move,
|
||||
Trash2,
|
||||
Save,
|
||||
Maximize2
|
||||
} from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import styles from './restaurant.module.css';
|
||||
|
||||
const sidebarItems = [
|
||||
{ icon: <LayoutDashboard size={20} />, label: 'Floor Plan', active: true },
|
||||
{ icon: <Utensils size={20} />, label: 'Orders' },
|
||||
{ icon: <Monitor size={20} />, label: 'KDS (Kitchen)' },
|
||||
{ icon: <Plus size={20} />, label: 'Menu Management' },
|
||||
];
|
||||
|
||||
const initialTables = [
|
||||
{ id: 1, name: 'T1', seats: 2, status: 'available', x: 100, y: 100, shape: 'rect' as const },
|
||||
{ id: 2, name: 'T2', seats: 4, status: 'occupied', x: 250, y: 100, shape: 'rect' as const, orderTotal: '$ 45.00' },
|
||||
{ id: 3, name: 'T3', seats: 6, status: 'available', x: 450, y: 100, shape: 'circle' as const },
|
||||
{ id: 4, name: 'T4', seats: 4, status: 'reserved', x: 100, y: 300, shape: 'rect' as const },
|
||||
{ id: 5, name: 'T5', seats: 2, status: 'occupied', x: 300, y: 300, shape: 'circle' as const, orderTotal: '$ 12.50' },
|
||||
];
|
||||
|
||||
export default function RestaurantPage() {
|
||||
const [activeFloor, setActiveFloor] = useState('Main Floor');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [tables, setTables] = useState(initialTables);
|
||||
const [selectedTable, setSelectedTable] = useState<number | null>(null);
|
||||
const [user, setUser] = useState<{ name: string; role: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.user) {
|
||||
setUser(data.user);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const canEdit = user?.role === 'admin' || user?.role === 'manager';
|
||||
|
||||
const handleDrag = (id: number, info: any) => {
|
||||
setTables(prev => prev.map(t =>
|
||||
t.id === id ? { ...t, x: t.x + info.delta.x, y: t.y + info.delta.y } : t
|
||||
));
|
||||
};
|
||||
|
||||
const addTable = () => {
|
||||
const newId = tables.length > 0 ? Math.max(...tables.map(t => t.id)) + 1 : 1;
|
||||
setTables([...tables, {
|
||||
id: newId,
|
||||
name: `T${newId}`,
|
||||
seats: 4,
|
||||
status: 'available',
|
||||
x: 50,
|
||||
y: 50,
|
||||
shape: 'rect'
|
||||
}]);
|
||||
setSelectedTable(newId);
|
||||
};
|
||||
|
||||
const deleteTable = (id: number) => {
|
||||
setTables(tables.filter(t => t.id !== id));
|
||||
setSelectedTable(null);
|
||||
};
|
||||
|
||||
const updateTable = (id: number, updates: any) => {
|
||||
setTables(tables.map(t => t.id === id ? { ...t, ...updates } : t));
|
||||
};
|
||||
|
||||
const currentTable = tables.find(t => t.id === selectedTable);
|
||||
|
||||
return (
|
||||
<AppLayout title="Restaurant" sidebarItems={sidebarItems}>
|
||||
<div className={styles.restaurantContainer}>
|
||||
{/* Floor Navigation */}
|
||||
<div className={styles.floorNav}>
|
||||
{['Main Floor', 'Terrace', 'Bar'].map(floor => (
|
||||
<button
|
||||
key={floor}
|
||||
className={`${styles.floorBtn} ${activeFloor === floor ? styles.activeFloor : ''}`}
|
||||
onClick={() => setActiveFloor(floor)}
|
||||
>
|
||||
{floor}
|
||||
</button>
|
||||
))}
|
||||
<button className={styles.addFloorBtn}><Plus size={16} /></button>
|
||||
|
||||
{canEdit && (
|
||||
<div className={styles.editToggleSection}>
|
||||
<button
|
||||
className={`${styles.editToggle} ${isEditing ? styles.activeEdit : ''}`}
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
>
|
||||
{isEditing ? <><Save size={16} /> Save Plan</> : <><Settings size={16} /> Edit Floor</>}
|
||||
</button>
|
||||
{isEditing && (
|
||||
<button className={styles.addTableBtn} onClick={addTable}>
|
||||
<Plus size={16} /> Add Table
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Floor Area */}
|
||||
<div className={styles.floorPlan}>
|
||||
{!isEditing && (
|
||||
<div className={styles.legend}>
|
||||
<div className={styles.legendItem}><span className={styles.statusDot} style={{ backgroundColor: '#10b981' }}></span> Available</div>
|
||||
<div className={styles.legendItem}><span className={styles.statusDot} style={{ backgroundColor: '#ef4444' }}></span> Occupied</div>
|
||||
<div className={styles.legendItem}><span className={styles.statusDot} style={{ backgroundColor: '#f59e0b' }}></span> Reserved</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.canvas}>
|
||||
{tables.map(table => (
|
||||
<motion.div
|
||||
key={table.id}
|
||||
drag={isEditing}
|
||||
dragMomentum={false}
|
||||
onDrag={(e, info) => handleDrag(table.id, info)}
|
||||
className={`${styles.table} ${styles[table.status]} ${styles[table.shape]} ${selectedTable === table.id && isEditing ? styles.selectedTable : ''}`}
|
||||
style={{ left: table.x, top: table.y, position: 'absolute' }}
|
||||
onClick={() => isEditing && setSelectedTable(table.id)}
|
||||
>
|
||||
{!isEditing ? (
|
||||
<Link href={`/pos?table=${table.id}`} className={styles.tableInnerLink}>
|
||||
<div className={styles.tableInner}>
|
||||
<span className={styles.tableName}>{table.name}</span>
|
||||
<span className={styles.seats}>{table.seats} seats</span>
|
||||
{table.orderTotal && <span className={styles.orderTotal}>{table.orderTotal}</span>}
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className={styles.tableInner}>
|
||||
<div className={styles.dragHandle}><Move size={14} /></div>
|
||||
<span className={styles.tableName}>{table.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Designer Sidebar / KDS Sidebar */}
|
||||
<div className={styles.rightSidebar}>
|
||||
{isEditing && selectedTable ? (
|
||||
<div className={styles.propertyEditor}>
|
||||
<div className={styles.sidebarHeader}>
|
||||
<h3>Table Properties</h3>
|
||||
<button className={styles.deleteBtn} onClick={() => deleteTable(selectedTable)}>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.propGroup}>
|
||||
<label>Identifier</label>
|
||||
<input
|
||||
type="text"
|
||||
value={currentTable?.name}
|
||||
onChange={(e) => updateTable(selectedTable, { name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.propGroup}>
|
||||
<label>Seats</label>
|
||||
<input
|
||||
type="number"
|
||||
value={currentTable?.seats}
|
||||
onChange={(e) => updateTable(selectedTable, { seats: parseInt(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.propGroup}>
|
||||
<label>Shape</label>
|
||||
<div className={styles.shapeToggle}>
|
||||
<button
|
||||
className={currentTable?.shape === 'rect' ? styles.activeShape : ''}
|
||||
onClick={() => updateTable(selectedTable, { shape: 'rect' })}
|
||||
>
|
||||
Rectangle
|
||||
</button>
|
||||
<button
|
||||
className={currentTable?.shape === 'circle' ? styles.activeShape : ''}
|
||||
onClick={() => updateTable(selectedTable, { shape: 'circle' })}
|
||||
>
|
||||
Circle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.kdsPreview}>
|
||||
<div className={styles.sidebarHeader}>
|
||||
<h3>Kitchen Status</h3>
|
||||
<span className={styles.kdsCount}>3 Ready</span>
|
||||
</div>
|
||||
<div className={styles.kdsList}>
|
||||
<div className={styles.kdsItem}>
|
||||
<div className={styles.kdsItemInfo}>
|
||||
<strong>Burger x2</strong>
|
||||
<span>Table T2</span>
|
||||
</div>
|
||||
<CheckCircle2 size={18} color="#10b981" />
|
||||
</div>
|
||||
<div className={styles.kdsItem}>
|
||||
<div className={styles.kdsItemInfo}>
|
||||
<strong>Pizza x1</strong>
|
||||
<span>Table T5</span>
|
||||
</div>
|
||||
<Clock size={18} color="#f59e0b" />
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/restaurant/kds" className={styles.viewFullKds}>View Full KDS <ArrowRight size={14} /></Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
424
src/app/restaurant/restaurant.module.css
Normal file
424
src/app/restaurant/restaurant.module.css
Normal file
@ -0,0 +1,424 @@
|
||||
.restaurantContainer {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
height: calc(100vh - 160px);
|
||||
}
|
||||
|
||||
.floorNav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.floorBtn {
|
||||
padding: 0.75rem 1rem;
|
||||
background: #ffffff;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 14px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.floorBtn:hover {
|
||||
background: #f9fafb;
|
||||
color: #2dd4bf;
|
||||
border-color: #2dd4bf;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.activeFloor {
|
||||
background: linear-gradient(135deg, #2dd4bf, #14b8a6) !important;
|
||||
color: white !important;
|
||||
border-color: #2dd4bf;
|
||||
box-shadow: 0 4px 12px rgba(45, 212, 191, 0.3);
|
||||
}
|
||||
|
||||
.addFloorBtn {
|
||||
padding: 0.75rem;
|
||||
background: #f9fafb;
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 14px;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.addFloorBtn:hover {
|
||||
background: #ffffff;
|
||||
border-color: #2dd4bf;
|
||||
color: #2dd4bf;
|
||||
}
|
||||
|
||||
.editToggleSection {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.editToggle {
|
||||
padding: 0.75rem;
|
||||
border-radius: 14px;
|
||||
font-weight: 700;
|
||||
border: 2px solid #e5e7eb;
|
||||
background: #ffffff;
|
||||
color: #374151;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.editToggle:hover {
|
||||
border-color: #2dd4bf;
|
||||
color: #2dd4bf;
|
||||
}
|
||||
|
||||
.activeEdit {
|
||||
background: linear-gradient(135deg, #14b8a6, #0d9488);
|
||||
color: white;
|
||||
border-color: #14b8a6;
|
||||
box-shadow: 0 4px 12px rgba(20, 184, 166, 0.3);
|
||||
}
|
||||
|
||||
.addTableBtn {
|
||||
padding: 0.75rem;
|
||||
background: linear-gradient(135deg, #2dd4bf, #14b8a6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(45, 212, 191, 0.3);
|
||||
}
|
||||
|
||||
.addTableBtn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(45, 212, 191, 0.4);
|
||||
}
|
||||
|
||||
.floorPlan {
|
||||
flex: 1;
|
||||
background: linear-gradient(135deg, #f0fdfa 0%, #ecfeff 100%);
|
||||
border-radius: 24px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 2px solid #e5e7eb;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image:
|
||||
radial-gradient(circle, #d1d5db 1px, transparent 1px);
|
||||
background-size: 30px 30px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.legend {
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.legendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.table:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.tableInnerLink {
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tableInner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.rect .tableInner {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.circle .tableInner {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.available .tableInner {
|
||||
background: linear-gradient(135deg, #ffffff, #f0fdf4);
|
||||
border: 3px solid #10b981;
|
||||
box-shadow: 0 6px 0 #059669, 0 8px 20px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.available:hover .tableInner {
|
||||
box-shadow: 0 6px 0 #059669, 0 10px 25px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.occupied .tableInner {
|
||||
background: linear-gradient(135deg, #fee2e2, #fecaca);
|
||||
border: 3px solid #ef4444;
|
||||
color: #991b1b;
|
||||
box-shadow: 0 6px 0 #dc2626, 0 8px 20px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.occupied:hover .tableInner {
|
||||
box-shadow: 0 6px 0 #dc2626, 0 10px 25px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.reserved .tableInner {
|
||||
background: linear-gradient(135deg, #fef3c7, #fde68a);
|
||||
border: 3px solid #f59e0b;
|
||||
color: #92400e;
|
||||
box-shadow: 0 6px 0 #d97706, 0 8px 20px rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.reserved:hover .tableInner {
|
||||
box-shadow: 0 6px 0 #d97706, 0 10px 25px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.selectedTable .tableInner {
|
||||
border-color: #2dd4bf !important;
|
||||
box-shadow: 0 0 0 4px rgba(45, 212, 191, 0.3), 0 8px 20px rgba(45, 212, 191, 0.4) !important;
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--pk-primary);
|
||||
color: white;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.tableName {
|
||||
font-weight: 800;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.seats {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.orderTotal {
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.rightSidebar {
|
||||
width: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.propertyEditor,
|
||||
.kdsPreview {
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
padding: 1.5rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.sidebarHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebarHeader h3 {
|
||||
font-size: 1rem;
|
||||
color: #111827;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.propGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.propGroup label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.propGroup input {
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
background: #f9fafb;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.propGroup input:focus {
|
||||
border-color: #2dd4bf;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 3px rgba(45, 212, 191, 0.1);
|
||||
}
|
||||
|
||||
.shapeToggle {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
background: #f3f4f6;
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.shapeToggle button {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 10px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.shapeToggle button:hover {
|
||||
color: #2dd4bf;
|
||||
}
|
||||
|
||||
.activeShape {
|
||||
background: white !important;
|
||||
color: #2dd4bf !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
border: 2px solid #fecaca;
|
||||
padding: 0.5rem;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.deleteBtn:hover {
|
||||
background: #fecaca;
|
||||
border-color: #dc2626;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.kdsCount {
|
||||
font-size: 0.75rem;
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.kdsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.kdsItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.kdsItemInfo strong {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.kdsItemInfo span {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.viewFullKds {
|
||||
margin-top: auto;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--pk-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
88
src/app/restaurant/waitstaff/page.tsx
Normal file
88
src/app/restaurant/waitstaff/page.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import AppLayout from '@/components/AppLayout/AppLayout';
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
Map,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
ChevronRight,
|
||||
MoreVertical,
|
||||
LayoutDashboard,
|
||||
Utensils,
|
||||
Monitor,
|
||||
Plus
|
||||
} from 'lucide-react';
|
||||
import styles from './waitstaff.module.css';
|
||||
|
||||
const sidebarItems = [
|
||||
{ icon: <LayoutDashboard size={20} />, label: 'Floor Plan' },
|
||||
{ icon: <Users size={20} />, label: 'Waitstaff Assignment', active: true },
|
||||
{ icon: <Utensils size={20} />, label: 'Orders' },
|
||||
{ icon: <Monitor size={20} />, label: 'KDS (Kitchen)' },
|
||||
];
|
||||
|
||||
const staffList = [
|
||||
{ id: 1, name: 'Alice Johnson', role: 'Senior Waiter', tables: ['T1', 'T2', 'T3'], status: 'active', avatar: 'AJ' },
|
||||
{ id: 2, name: 'Bob Smith', role: 'Waiter', tables: ['T4', 'T5'], status: 'active', avatar: 'BS' },
|
||||
{ id: 3, name: 'Charlie Dave', role: 'Junior Waiter', tables: ['T6'], status: 'on_break', avatar: 'CD' },
|
||||
{ id: 4, name: 'Diana Prince', role: 'Senior Waiter', tables: [], status: 'inactive', avatar: 'DP' },
|
||||
];
|
||||
|
||||
export default function WaitstaffPage() {
|
||||
return (
|
||||
<AppLayout title="Waitstaff Management" sidebarItems={sidebarItems}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titleArea}>
|
||||
<h2>Team Assignment</h2>
|
||||
<p>Manage waitstaff table zones and current shifts</p>
|
||||
</div>
|
||||
<button className={styles.addBtn}><UserPlus size={18} /> Add Member</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.staffGrid}>
|
||||
{staffList.map(staff => (
|
||||
<div key={staff.id} className={styles.staffCard}>
|
||||
<div className={styles.cardHeader}>
|
||||
<div className={styles.avatar}>{staff.avatar}</div>
|
||||
<div className={styles.statusBadge}>
|
||||
{staff.status === 'active' ? <CheckCircle2 size={14} color="#10b981" /> : <XCircle size={14} color="#94a3b8" />}
|
||||
<span className={styles[staff.status]}>{staff.status.replace('_', ' ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.staffInfo}>
|
||||
<h3 className={styles.staffName}>{staff.name}</h3>
|
||||
<span className={styles.staffRole}>{staff.role}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.assignmentSection}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Map size={14} /> Assigned Tables
|
||||
</div>
|
||||
<div className={styles.tableBadges}>
|
||||
{staff.tables.length > 0 ? (
|
||||
staff.tables.map(table => (
|
||||
<span key={table} className={styles.tableBadge}>{table}</span>
|
||||
))
|
||||
) : (
|
||||
<span className={styles.noTables}>No tables assigned</span>
|
||||
)}
|
||||
<button className={styles.assignMore}><Plus size={12} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<button className={styles.viewPerf}>View Performance</button>
|
||||
<MoreVertical size={18} className={styles.moreIcon} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
206
src/app/restaurant/waitstaff/waitstaff.module.css
Normal file
206
src/app/restaurant/waitstaff/waitstaff.module.css
Normal file
@ -0,0 +1,206 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.titleArea h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.titleArea p {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
background: var(--pk-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.staffGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.staffCard {
|
||||
background: white;
|
||||
border-radius: 24px;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
transition: var(--transition);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.staffCard:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: var(--pk-primary);
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
background: #f1f5f9;
|
||||
border: 2px solid white;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
color: var(--pk-primary);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: #f8fafc;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.statusBadge span {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.on_break {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.inactive {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.staffInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.staffName {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 800;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.staffRole {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.assignmentSection {
|
||||
background: #f8fafc;
|
||||
padding: 1rem;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tableBadges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tableBadge {
|
||||
background: white;
|
||||
color: #1e293b;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.noTables {
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.assignMore {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.viewPerf {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--pk-primary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.viewPerf:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.moreIcon {
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
}
|
||||
70
src/app/sales/page.tsx
Normal file
70
src/app/sales/page.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import AppLayout from '@/components/AppLayout/AppLayout';
|
||||
import {
|
||||
BarChart3,
|
||||
Users,
|
||||
ShoppingCart,
|
||||
Package,
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
CreditCard,
|
||||
Truck
|
||||
} from 'lucide-react';
|
||||
import styles from './sales.module.css';
|
||||
|
||||
const sidebarItems = [
|
||||
{ icon: <LayoutDashboard size={20} />, label: 'Dashboard' },
|
||||
{ icon: <FileText size={20} />, label: 'Quotations', active: true },
|
||||
{ icon: <ShoppingCart size={20} />, label: 'Orders' },
|
||||
{ icon: <Users size={20} />, label: 'Customers' },
|
||||
{ icon: <Package size={20} />, label: 'Products' },
|
||||
];
|
||||
|
||||
const mockOrders = [
|
||||
{ id: 'S0001', customer: 'Azure Interior', date: '2026-01-20', salesman: 'James Doe', total: '$ 1,250.00', status: 'Quotation' },
|
||||
{ id: 'S0002', customer: 'Deco Addict', date: '2026-01-21', salesman: 'Alice Smith', total: '$ 4,500.00', status: 'Sales Order' },
|
||||
{ id: 'S0003', customer: 'Ready Mat', date: '2026-01-21', salesman: 'James Doe', total: '$ 800.00', status: 'Sent' },
|
||||
{ id: 'S0004', customer: 'Gemini Corp', date: '2026-01-22', salesman: 'James Doe', total: '$ 12,000.00', status: 'Sales Order' },
|
||||
{ id: 'S0005', customer: 'The Corner', date: '2026-01-22', salesman: 'Bob Wilson', total: '$ 320.00', status: 'Cancelled' },
|
||||
];
|
||||
|
||||
export default function SalesPage() {
|
||||
return (
|
||||
<AppLayout title="Sales" sidebarItems={sidebarItems}>
|
||||
<div className={styles.tableContainer}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" /></th>
|
||||
<th>Number</th>
|
||||
<th>Customer</th>
|
||||
<th>Order Date</th>
|
||||
<th>Salesperson</th>
|
||||
<th>Total</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockOrders.map((order) => (
|
||||
<tr key={order.id} className={styles.row}>
|
||||
<td><input type="checkbox" /></td>
|
||||
<td className={styles.orderId}>{order.id}</td>
|
||||
<td>{order.customer}</td>
|
||||
<td>{order.date}</td>
|
||||
<td>{order.salesman}</td>
|
||||
<td className={styles.total}>{order.total}</td>
|
||||
<td>
|
||||
<span className={`${styles.status} ${styles[order.status.replace(' ', '').toLowerCase()]}`}>
|
||||
{order.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
70
src/app/sales/sales.module.css
Normal file
70
src/app/sales/sales.module.css
Normal file
@ -0,0 +1,70 @@
|
||||
.tableContainer {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.table th {
|
||||
padding: 1rem;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 0.9rem;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: #f8fafc;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.orderId {
|
||||
font-weight: 600;
|
||||
color: var(--pk-primary);
|
||||
}
|
||||
|
||||
.total {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quotation {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.salesorder {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.sent {
|
||||
background: #fef9c3;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
.cancelled {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
243
src/components/AppLayout/AppLayout.module.css
Normal file
243
src/components/AppLayout/AppLayout.module.css
Normal file
@ -0,0 +1,243 @@
|
||||
.appContainer {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 64px;
|
||||
background-color: var(--bg-sidebar);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
gap: 1.5rem;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.appSwitcher {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.appSwitcher:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.sidebarMenu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sidebarItem {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.sidebarItem:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.contentWrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
height: 56px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
.navLeft {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.appTitle {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--pk-primary);
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.current {
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.navCenter {
|
||||
flex: 1;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.searchBar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem 0.5rem 2.5rem;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
background: white;
|
||||
border-color: var(--pk-primary);
|
||||
box-shadow: 0 0 0 3px rgba(113, 75, 103, 0.1);
|
||||
}
|
||||
|
||||
.navRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.navBtn:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--pk-primary);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
height: 56px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
.actionLeft {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
background: var(--pk-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.primaryBtn:hover {
|
||||
background: var(--pk-primary-hover);
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
background: white;
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.secondaryBtn:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.viewToggle {
|
||||
display: flex;
|
||||
background: #f1f5f9;
|
||||
padding: 3px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.viewBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.viewBtn:first-child {
|
||||
background: white;
|
||||
color: var(--text-main);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
135
src/components/AppLayout/AppLayout.tsx
Normal file
135
src/components/AppLayout/AppLayout.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Grid,
|
||||
Settings,
|
||||
Search,
|
||||
Bell,
|
||||
Plus,
|
||||
Filter,
|
||||
ArrowUpDown,
|
||||
MoreVertical
|
||||
} from 'lucide-react';
|
||||
import styles from './AppLayout.module.css';
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
sidebarItems: { icon: React.ReactNode; label: string; active?: boolean }[];
|
||||
}
|
||||
|
||||
export default function AppLayout({ children, title, sidebarItems }: AppLayoutProps) {
|
||||
const [user, setUser] = useState<{ name: string; role: string } | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.user) {
|
||||
setUser(data.user);
|
||||
} else {
|
||||
router.push('/auth/login');
|
||||
}
|
||||
})
|
||||
.catch(() => router.push('/auth/login'));
|
||||
}, [router]);
|
||||
|
||||
const filteredSidebarItems = sidebarItems.filter(item => {
|
||||
if (!user) return false;
|
||||
if (user.role === 'admin') return true;
|
||||
|
||||
// Define restrictions
|
||||
const restrictions: Record<string, string[]> = {
|
||||
'waiter': ['Floor Plan', 'Orders', 'KDS (Kitchen)'],
|
||||
'cashier': ['Orders', 'POS Dashboard'],
|
||||
'manager': ['Floor Plan', 'Orders', 'KDS (Kitchen)', 'Menu Management', 'Waitstaff Assignment'],
|
||||
};
|
||||
|
||||
const allowedItems = restrictions[user.role as keyof typeof restrictions] || [];
|
||||
return allowedItems.includes(item.label);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.appContainer}>
|
||||
{/* Sidebar */}
|
||||
<aside className={styles.sidebar}>
|
||||
<Link href="/" className={styles.appSwitcher}>
|
||||
<Grid size={24} color="white" />
|
||||
</Link>
|
||||
<div className={styles.sidebarMenu}>
|
||||
{filteredSidebarItems.map((item: any, idx: number) => {
|
||||
let href = '#';
|
||||
if (item.label === 'Floor Plan') href = '/restaurant';
|
||||
else if (item.label === 'Menu Management') href = '/restaurant/menu';
|
||||
else if (item.label === 'Waitstaff Assignment') href = '/restaurant/waitstaff';
|
||||
else if (item.label === 'Financial Reports') href = '/financials';
|
||||
else if (item.label === 'Journal Entries') href = '/accounting';
|
||||
else if (item.label === 'Payslips') href = '/payroll';
|
||||
else if (item.label === 'Accounting Dashboard') href = '/accounting';
|
||||
else if (item.label === 'Employee Dashboard') href = '/payroll';
|
||||
|
||||
return (
|
||||
<Link key={idx} href={href} style={{ textDecoration: 'none' }}>
|
||||
<div className={`${styles.sidebarItem} ${item.active ? styles.active : ''}`} title={item.label}>
|
||||
{item.icon}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.sidebarBottom}>
|
||||
<Settings size={20} color="#94a3b8" />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className={styles.contentWrapper}>
|
||||
{/* Navbar */}
|
||||
<nav className={styles.navbar}>
|
||||
<div className={styles.navLeft}>
|
||||
<span className={styles.appTitle}>{title}</span>
|
||||
<div className={styles.breadcrumbs}>
|
||||
<span>Orders</span> / <span className={styles.current}>Sales Orders</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.navCenter}>
|
||||
<div className={styles.searchBar}>
|
||||
<Search size={16} className={styles.searchIcon} />
|
||||
<input type="text" placeholder="Search orders..." className={styles.searchInput} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.navRight}>
|
||||
<button className={styles.navBtn}><Bell size={20} /></button>
|
||||
<div className={styles.avatar}>{user?.name?.substring(0, 2).toUpperCase() || 'AR'}</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Action Bar */}
|
||||
<div className={styles.actionBar}>
|
||||
<div className={styles.actionLeft}>
|
||||
<button className={styles.primaryBtn}><Plus size={18} /> New</button>
|
||||
<button className={styles.secondaryBtn}>Import</button>
|
||||
</div>
|
||||
<div className={styles.actionRight}>
|
||||
<div className={styles.viewToggle}>
|
||||
<button className={styles.viewBtn}>List</button>
|
||||
<button className={styles.viewBtn}>Kanban</button>
|
||||
<button className={styles.viewBtn}>Graph</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className={styles.mainContent}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/lib/mongodb.ts
Normal file
40
src/lib/mongodb.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/odoo_next';
|
||||
|
||||
if (!MONGODB_URI) {
|
||||
throw new Error('Please define the MONGODB_URI environment variable inside .env.local');
|
||||
}
|
||||
|
||||
let cached = (global as any).mongoose;
|
||||
|
||||
if (!cached) {
|
||||
cached = (global as any).mongoose = { conn: null, promise: null };
|
||||
}
|
||||
|
||||
async function dbConnect() {
|
||||
if (cached.conn) {
|
||||
return cached.conn;
|
||||
}
|
||||
|
||||
if (!cached.promise) {
|
||||
const opts = {
|
||||
bufferCommands: false,
|
||||
};
|
||||
|
||||
cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => {
|
||||
return mongoose;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
cached.conn = await cached.promise;
|
||||
} catch (e) {
|
||||
cached.promise = null;
|
||||
throw e;
|
||||
}
|
||||
|
||||
return cached.conn;
|
||||
}
|
||||
|
||||
export default dbConnect;
|
||||
56
src/models/Restaurant.ts
Normal file
56
src/models/Restaurant.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
// --- Category ---
|
||||
const CategorySchema = new mongoose.Schema({
|
||||
name: { type: String, required: true },
|
||||
icon: String,
|
||||
color: String,
|
||||
});
|
||||
|
||||
export const Category = mongoose.models.Category || mongoose.model('Category', CategorySchema);
|
||||
|
||||
// --- Product ---
|
||||
const ProductSchema = new mongoose.Schema({
|
||||
name: { type: String, required: true },
|
||||
price: { type: Number, required: true },
|
||||
category: { type: mongoose.Schema.Types.ObjectId, ref: 'Category' },
|
||||
image: String,
|
||||
available: { type: Boolean, default: true },
|
||||
isAddon: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
export const Product = mongoose.models.Product || mongoose.model('Product', ProductSchema);
|
||||
|
||||
// --- Table ---
|
||||
const TableSchema = new mongoose.Schema({
|
||||
name: { type: String, required: true },
|
||||
seats: { type: Number, default: 4 },
|
||||
status: { type: String, enum: ['available', 'occupied', 'reserved'], default: 'available' },
|
||||
x: Number,
|
||||
y: Number,
|
||||
width: Number,
|
||||
height: Number,
|
||||
shape: { type: String, enum: ['rect', 'circle'], default: 'rect' },
|
||||
currentOrderId: { type: mongoose.Schema.Types.ObjectId, ref: 'Order' },
|
||||
});
|
||||
|
||||
export const Table = mongoose.models.Table || mongoose.model('Table', TableSchema);
|
||||
|
||||
// --- Order ---
|
||||
const OrderSchema = new mongoose.Schema({
|
||||
tableId: { type: mongoose.Schema.Types.ObjectId, ref: 'Table' },
|
||||
waiterId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
|
||||
items: [{
|
||||
productId: { type: mongoose.Schema.Types.ObjectId, ref: 'Product' },
|
||||
quantity: { type: Number, default: 1 },
|
||||
price: Number, // snapshot price
|
||||
note: String,
|
||||
}],
|
||||
status: { type: String, enum: ['draft', 'sent', 'paid', 'cancelled'], default: 'draft' },
|
||||
subtotal: Number,
|
||||
tax: Number,
|
||||
total: Number,
|
||||
paymentMethod: String,
|
||||
}, { timestamps: true });
|
||||
|
||||
export const Order = mongoose.models.Order || mongoose.model('Order', OrderSchema);
|
||||
13
src/models/User.ts
Normal file
13
src/models/User.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const UserSchema = new mongoose.Schema({
|
||||
name: { type: String, required: true },
|
||||
email: { type: String, required: true, unique: true },
|
||||
password: { type: String, required: true },
|
||||
role: { type: String, enum: ['admin', 'manager', 'waiter', 'cashier'], default: 'admin' },
|
||||
avatar: String,
|
||||
resetPasswordToken: String,
|
||||
resetPasswordExpires: Date,
|
||||
}, { timestamps: true });
|
||||
|
||||
export default mongoose.models.User || mongoose.model('User', UserSchema);
|
||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user