feat: initialize inventory management application with Prisma schema, QR scanning, and UI components
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@ -39,3 +39,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/app/generated/prisma
|
||||||
|
|||||||
207
app/add/page.js
Normal file
207
app/add/page.js
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Upload, Plus, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
|
import * as xlsx from "xlsx";
|
||||||
|
|
||||||
|
export default function AddImportPage() {
|
||||||
|
const [formData, setFormData] = useState({ name: "", qrCodeId: "", quantity: 0, price: 0.0, category: "", description: "" });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [status, setStatus] = useState({ type: "", message: "" });
|
||||||
|
const [importStats, setImportStats] = useState(null);
|
||||||
|
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManualSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setStatus({ type: "", message: "" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...formData,
|
||||||
|
quantity: parseInt(formData.quantity) || 0,
|
||||||
|
price: parseFloat(formData.price) || 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch("/api/inventory", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || "Failed to create item");
|
||||||
|
|
||||||
|
setStatus({ type: "success", message: `Successfully added ${data.name}.` });
|
||||||
|
setFormData({ name: "", qrCodeId: "", quantity: 0, price: 0.0, category: "", description: "" });
|
||||||
|
} catch (err) {
|
||||||
|
setStatus({ type: "error", message: err.message });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setStatus({ type: "", message: "" });
|
||||||
|
setImportStats(null);
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (evt) => {
|
||||||
|
try {
|
||||||
|
const bstr = evt.target.result;
|
||||||
|
const wb = xlsx.read(bstr, { type: "binary" });
|
||||||
|
const wsname = wb.SheetNames[0];
|
||||||
|
const ws = wb.Sheets[wsname];
|
||||||
|
const data = xlsx.utils.sheet_to_json(ws);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const row of data) {
|
||||||
|
try {
|
||||||
|
// Normalize row keys to prevent trailing spaces or casing errors
|
||||||
|
const normalizedRow = {};
|
||||||
|
for (const key in row) {
|
||||||
|
if (row.hasOwnProperty(key)) {
|
||||||
|
normalizedRow[key.trim().toLowerCase()] = row[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
qrCodeId: (normalizedRow["qr id"] || normalizedRow["qrcodeid"] || normalizedRow["id"] || normalizedRow["sku"])?.toString(),
|
||||||
|
name: normalizedRow["name"] || normalizedRow["item"],
|
||||||
|
quantity: parseInt(normalizedRow["quantity"] || normalizedRow["qty"]) || 0,
|
||||||
|
price: parseFloat(normalizedRow["price"] || normalizedRow["cost"]) || 0.0,
|
||||||
|
category: normalizedRow["category"] || "",
|
||||||
|
description: normalizedRow["description"] || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!payload.qrCodeId || !payload.name) {
|
||||||
|
errorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch("/api/inventory", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setImportStats({ successCount, errorCount, total: data.length });
|
||||||
|
setStatus({ type: "success", message: `Import completed.` });
|
||||||
|
} catch (err) {
|
||||||
|
setStatus({ type: "error", message: "Failed to parse or process the Excel file." });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsBinaryString(file);
|
||||||
|
e.target.value = ""; // reset file input
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "2rem" }}>
|
||||||
|
{/* Manual Entry Form */}
|
||||||
|
<div className="glass" style={{ padding: "2rem" }}>
|
||||||
|
<h2 className="flex items-center gap-2 mb-4">
|
||||||
|
<Plus size={24} className="text-primary" />
|
||||||
|
Add Item Manually
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={handleManualSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Item Name *</label>
|
||||||
|
<input required type="text" name="name" className="input-field" value={formData.name} onChange={handleInputChange} placeholder="e.g. Widget A" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>QR Code ID / SKU *</label>
|
||||||
|
<input required type="text" name="qrCodeId" className="input-field" value={formData.qrCodeId} onChange={handleInputChange} placeholder="Unique identifier" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Quantity</label>
|
||||||
|
<input type="number" name="quantity" className="input-field" value={formData.quantity} onChange={handleInputChange} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Price</label>
|
||||||
|
<input type="number" step="0.01" name="price" className="input-field" value={formData.price} onChange={handleInputChange} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Category</label>
|
||||||
|
<input type="text" name="category" className="input-field" value={formData.category} onChange={handleInputChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="btn btn-primary mt-4" style={{ width: "100%", justifyContent: "center" }} disabled={loading}>
|
||||||
|
{loading ? "Saving..." : "Add to Inventory"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Excel Import Panel */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
|
||||||
|
<div className="glass" style={{ padding: "2rem", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", minHeight: "300px", textAlign: "center", borderStyle: "dashed" }}>
|
||||||
|
<Upload size={48} className="text-primary m-auto mb-4" />
|
||||||
|
<h2 style={{ marginBottom: "0.5rem" }}>Import via Excel</h2>
|
||||||
|
<p className="text-muted mb-4">Upload an .xlsx file to bulk import inventory.</p>
|
||||||
|
|
||||||
|
<label className="btn btn-secondary cursor-pointer" style={{ cursor: "pointer" }}>
|
||||||
|
Select Excel File
|
||||||
|
<input type="file" accept=".xlsx, .xls, .csv" style={{ display: "none" }} onChange={handleFileUpload} disabled={loading} />
|
||||||
|
</label>
|
||||||
|
<div className="mt-4" style={{ fontSize: "0.85rem", color: "var(--text-muted)" }}>
|
||||||
|
Make sure your file has headers like: <strong>Name, QR ID, Quantity, Price</strong>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Reporting */}
|
||||||
|
{status.message && (
|
||||||
|
<div className="glass flex items-center gap-4" style={{ padding: "1.5rem", borderLeft: `4px solid ${status.type === 'error' ? 'var(--danger)' : 'var(--success)'}` }}>
|
||||||
|
{status.type === 'error' ? <AlertCircle className="text-danger" size={32} /> : <CheckCircle2 className="text-success" size={32} />}
|
||||||
|
<div>
|
||||||
|
<h4 style={{ color: status.type === 'error' ? 'var(--danger)' : 'var(--success)' }}>
|
||||||
|
{status.type === 'error' ? 'Error' : 'Success'}
|
||||||
|
</h4>
|
||||||
|
<p className="text-muted" style={{ fontSize: "0.95rem" }}>{status.message}</p>
|
||||||
|
{importStats && (
|
||||||
|
<div style={{ marginTop: "0.5rem", fontSize: "0.85rem" }}>
|
||||||
|
<p>Total Items: {importStats.total}</p>
|
||||||
|
<p className="text-success">Successful: {importStats.successCount}</p>
|
||||||
|
<p className="text-danger">Failed/Skipped: {importStats.errorCount}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
div[style*="grid-template-columns: 1fr 1fr"] {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
app/api/inventory/route.js
Normal file
86
app/api/inventory/route.js
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// GET all inventory items
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const items = await prisma.inventoryItem.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
return NextResponse.json(items);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Failed to fetch inventory" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST a new item (or update if exists)
|
||||||
|
export async function POST(req) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { qrCodeId, name, quantity, price, category, description } = body;
|
||||||
|
|
||||||
|
const newItem = await prisma.inventoryItem.upsert({
|
||||||
|
where: { qrCodeId },
|
||||||
|
update: {
|
||||||
|
name,
|
||||||
|
quantity: quantity || 0,
|
||||||
|
price: price || 0.0,
|
||||||
|
category: category || "",
|
||||||
|
description: description || "",
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
qrCodeId,
|
||||||
|
name,
|
||||||
|
quantity: quantity || 0,
|
||||||
|
price: price || 0.0,
|
||||||
|
category: category || "",
|
||||||
|
description: description || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(newItem, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Failed to create or update item", details: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT (Update) an existing item
|
||||||
|
export async function PUT(req) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { id, ...updateData } = body;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "Item ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedItem = await prisma.inventoryItem.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updatedItem, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Failed to update item", details: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE an item
|
||||||
|
export async function DELETE(req) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const id = searchParams.get("id");
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "Item ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.inventoryItem.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ message: "Item deleted successfully" }, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: "Failed to delete item", details: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
284
app/globals.css
284
app/globals.css
@ -1,34 +1,23 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--bg-color: #0f172a;
|
||||||
--foreground: #171717;
|
--bg-secondary: #1e293b;
|
||||||
}
|
--text-main: #f8fafc;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
|
||||||
|
--primary: #3b82f6;
|
||||||
|
--primary-hover: #2563eb;
|
||||||
|
--secondary: #8b5cf6;
|
||||||
|
|
||||||
|
--danger: #ef4444;
|
||||||
|
--success: #10b981;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
--border: #334155;
|
||||||
:root {
|
--glass-bg: rgba(30, 41, 59, 0.7);
|
||||||
--background: #0a0a0a;
|
--glass-border: rgba(255, 255, 255, 0.1);
|
||||||
--foreground: #ededed;
|
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
max-width: 100vw;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
color: var(--foreground);
|
|
||||||
background: var(--background);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@ -37,13 +26,244 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
ul {
|
||||||
html {
|
list-style: none;
|
||||||
color-scheme: dark;
|
}
|
||||||
|
|
||||||
|
/* Glassmorphism Classes */
|
||||||
|
.glass {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Core Layout */
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main-content {
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Navbar */
|
||||||
|
.navbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(to right, var(--primary), var(--secondary));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
color: var(--text-main);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--danger);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: var(--danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background-color: rgba(15, 23, 42, 0.5);
|
||||||
|
color: var(--text-main);
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-field:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table / Grid list for mobile */
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile optimizations */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.nav-links span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.nav-links {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.nav-link {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.data-table th, .data-table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utilities */
|
||||||
|
.text-danger { color: var(--danger); }
|
||||||
|
.text-success { color: var(--success); }
|
||||||
|
.text-muted { color: var(--text-muted); }
|
||||||
|
.text-primary { color: var(--primary); }
|
||||||
|
|
||||||
|
.flex { display: flex; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.gap-2 { gap: 0.5rem; }
|
||||||
|
.gap-4 { gap: 1rem; }
|
||||||
|
.mt-4 { margin-top: 1rem; }
|
||||||
|
.mb-4 { margin-bottom: 1rem; }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
|||||||
@ -1,25 +1,22 @@
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import Navigation from "@/components/Navigation";
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Create Next App",
|
title: "Inventory Management App",
|
||||||
description: "Generated by create next app",
|
description: "Track your items reliably with QR integrations",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }) {
|
export default function RootLayout({ children }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}>
|
<html lang="en">
|
||||||
<body>{children}</body>
|
<body>
|
||||||
|
<div className="app-container">
|
||||||
|
<Navigation />
|
||||||
|
<main className="main-content">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
258
app/page.js
258
app/page.js
@ -1,66 +1,206 @@
|
|||||||
import Image from "next/image";
|
"use client";
|
||||||
import styles from "./page.module.css";
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Edit2, Package, RefreshCw, Trash2, Download } from "lucide-react";
|
||||||
|
import * as xlsx from "xlsx";
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [editForm, setEditForm] = useState({});
|
||||||
|
|
||||||
|
const fetchItems = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/inventory");
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch inventory from database");
|
||||||
|
const data = await res.json();
|
||||||
|
setItems(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchItems();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteItem = async (id) => {
|
||||||
|
if (!confirm("Are you sure you want to delete this item?")) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/inventory?id=${id}`, { method: "DELETE" });
|
||||||
|
if (res.ok) {
|
||||||
|
setItems(items.filter(item => item.id !== id));
|
||||||
|
} else {
|
||||||
|
alert("Error deleting item");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert("Error deleting item: " + error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (item) => {
|
||||||
|
setEditingId(item.id);
|
||||||
|
setEditForm({ name: item.name, category: item.category || "", quantity: item.quantity, price: item.price });
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async (id) => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
id,
|
||||||
|
name: editForm.name,
|
||||||
|
category: editForm.category,
|
||||||
|
quantity: parseInt(editForm.quantity) || 0,
|
||||||
|
price: parseFloat(editForm.price) || 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch("/api/inventory", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const updatedItem = await res.json();
|
||||||
|
setItems(items.map(item => item.id === id ? updatedItem : item));
|
||||||
|
setEditingId(null);
|
||||||
|
} else {
|
||||||
|
alert("Failed to save changes");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert("Error saving: " + err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (items.length === 0) return alert("No items to export");
|
||||||
|
const ws = xlsx.utils.json_to_sheet(items.map(i => ({
|
||||||
|
"QR ID": i.qrCodeId,
|
||||||
|
"Name": i.name,
|
||||||
|
"Quantity": i.quantity,
|
||||||
|
"Price": i.price,
|
||||||
|
"Category": i.category || "",
|
||||||
|
"Description": i.description || ""
|
||||||
|
})));
|
||||||
|
const wb = xlsx.utils.book_new();
|
||||||
|
xlsx.utils.book_append_sheet(wb, ws, "Inventory");
|
||||||
|
xlsx.writeFile(wb, "Inventory_Export.xlsx");
|
||||||
|
};
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className="glass" style={{ padding: "2rem" }}>
|
||||||
<main className={styles.main}>
|
<div className="flex justify-between items-center mb-4">
|
||||||
<Image
|
<div>
|
||||||
className={styles.logo}
|
<h1 style={{ fontSize: "1.8rem", marginBottom: "0.5rem" }}>Inventory Overview</h1>
|
||||||
src="/next.svg"
|
<p className="text-muted">Manage your inventory quantities and prices.</p>
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className={styles.intro}>
|
|
||||||
<h1>To get started, edit the page.js file.</h1>
|
|
||||||
<p>
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.ctas}>
|
<div className="flex gap-2">
|
||||||
<a
|
<button className="btn btn-secondary" onClick={handleExport} title="Export to Excel">
|
||||||
className={styles.primary}
|
<Download size={16} />
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
Export
|
||||||
target="_blank"
|
</button>
|
||||||
rel="noopener noreferrer"
|
<button className="btn btn-secondary" onClick={fetchItems}>
|
||||||
>
|
<RefreshCw size={16} className={loading ? "spin" : ""} />
|
||||||
<Image
|
Refresh
|
||||||
className={styles.logo}
|
</button>
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className={styles.secondary}
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="text-danger glass" style={{ padding: "1rem", backgroundColor: "rgba(239, 68, 68, 0.1)" }}>
|
||||||
|
<p><strong>Error:</strong> {error}</p>
|
||||||
|
<p style={{ marginTop: "0.5rem", fontSize: "0.9rem" }}>Ensure your PostgreSQL database is running and DATABASE_URL is configured in your .env or prisma.config.ts</p>
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<div className="text-center" style={{ padding: "3rem" }}>
|
||||||
|
<RefreshCw size={32} className="spin text-primary" style={{ animation: "spin 1s linear infinite" }} />
|
||||||
|
<p className="mt-4 text-muted">Loading inventory...</p>
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin { 100% { transform: rotate(360deg); } }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="text-center glass" style={{ padding: "4rem 2rem", borderStyle: "dashed" }}>
|
||||||
|
<Package size={48} className="text-muted m-auto mb-4" />
|
||||||
|
<h2 style={{ marginBottom: "1rem" }}>No items found</h2>
|
||||||
|
<p className="text-muted">Start by adding a new item or importing an Excel sheet.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-container mt-4">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>QR ID</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Quantity</th>
|
||||||
|
<th>Price</th>
|
||||||
|
<th style={{ textAlign: "right" }}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map(item => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td style={{ fontWeight: 500 }}>
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<input className="input-field" style={{ padding: "0.2rem 0.5rem" }} value={editForm.name} onChange={e => setEditForm({...editForm, name: e.target.value})} />
|
||||||
|
) : item.name}
|
||||||
|
</td>
|
||||||
|
<td><code className="text-muted" style={{ background: "rgba(255,255,255,0.05)", padding: "2px 6px", borderRadius: "4px" }}>{item.qrCodeId}</code></td>
|
||||||
|
<td>
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<input className="input-field" style={{ padding: "0.2rem 0.5rem", width: "100%" }} value={editForm.category} onChange={e => setEditForm({...editForm, category: e.target.value})} />
|
||||||
|
) : (item.category || "-")}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<input type="number" className="input-field" style={{ padding: "0.2rem 0.5rem", width: "80px" }} value={editForm.quantity} onChange={e => setEditForm({...editForm, quantity: e.target.value})} />
|
||||||
|
) : (
|
||||||
|
<span className={item.quantity <= 5 ? "text-danger" : "text-success"} style={{ fontWeight: 600 }}>
|
||||||
|
{item.quantity}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<input type="number" step="0.01" className="input-field" style={{ padding: "0.2rem 0.5rem", width: "80px" }} value={editForm.price} onChange={e => setEditForm({...editForm, price: e.target.value})} />
|
||||||
|
) : `$${Number(item.price).toFixed(2)}`}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: "right" }}>
|
||||||
|
<div className="flex" style={{ gap: "0.5rem", justifyContent: "flex-end" }}>
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-primary" style={{ padding: "0.4rem 0.6rem" }} onClick={() => saveEdit(item.id)}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" style={{ padding: "0.4rem 0.6rem" }} onClick={() => setEditingId(null)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-secondary" style={{ padding: "0.4rem 0.6rem" }} onClick={() => startEdit(item)}>
|
||||||
|
<Edit2 size={14} />
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-danger" style={{ padding: "0.4rem 0.6rem" }} onClick={() => deleteItem(item.id)}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
186
app/print/page.js
Normal file
186
app/print/page.js
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import QRCode from "react-qr-code";
|
||||||
|
import { Printer, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
export default function PrintPage() {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchItems = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/inventory");
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch inventory from database");
|
||||||
|
const data = await res.json();
|
||||||
|
setItems(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchItems();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="p-8 text-center">Loading inventory codes...</div>;
|
||||||
|
if (error) return <div className="p-8 text-danger">Error: {error}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* On-screen Controls (Hidden in Print) */}
|
||||||
|
<div className="glass no-print" style={{ padding: "2rem", marginBottom: "2rem" }}>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center gap-2 mb-2">
|
||||||
|
<Printer size={24} className="text-primary" />
|
||||||
|
Print QR Labels
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted">
|
||||||
|
Designed for <strong>Avery 5160</strong> standard labels (1" x 2-5/8", 30 per sheet).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={handlePrint}>
|
||||||
|
<Printer size={18} /> Print Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-4 mt-4" style={{ backgroundColor: "rgba(59, 130, 246, 0.1)", padding: "1rem", borderRadius: "8px", border: "1px solid var(--primary)" }}>
|
||||||
|
<AlertCircle size={24} className="text-primary flex-shrink-0" />
|
||||||
|
<div style={{ fontSize: "0.9rem" }}>
|
||||||
|
<strong>Printer Settings to check before printing:</strong>
|
||||||
|
<ul style={{ listStyleType: "disc", marginLeft: "1.5rem", marginTop: "0.5rem" }}>
|
||||||
|
<li>Scale: <strong>100%</strong> (Do not "Fit to Page")</li>
|
||||||
|
<li>Margins: <strong>Custom / None</strong> (Layout handles margins natively)</li>
|
||||||
|
<li>Paper Size: <strong>Letter (8.5x11)</strong></li>
|
||||||
|
<li>Headers and Footers: <strong>Disable</strong></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Printable Sheet Area */}
|
||||||
|
<div className="print-container">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<div key={item.id} className="label-cell">
|
||||||
|
<div className="qr-wrapper">
|
||||||
|
<QRCode
|
||||||
|
value={item.qrCodeId || item.id}
|
||||||
|
size={64}
|
||||||
|
level="H"
|
||||||
|
style={{ height: "auto", maxWidth: "100%", width: "100%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="label-text">
|
||||||
|
<div className="item-name">{item.name}</div>
|
||||||
|
<div className="item-sku">{item.qrCodeId}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
/* Standard View Adjustments */
|
||||||
|
.print-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-height: 50vh;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-cell {
|
||||||
|
width: calc(33.333% - 1rem); /* Desktop preview layout */
|
||||||
|
height: 1in;
|
||||||
|
border: 1px dashed #ccc;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.125in;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: black;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-wrapper {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 0.75in;
|
||||||
|
height: 0.75in;
|
||||||
|
margin-right: 0.1in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-sku {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #555;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
@page {
|
||||||
|
size: letter;
|
||||||
|
margin: 0; /* Reset browser margins entirely */
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: white !important;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-print, nav, .navbar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avery 5160 Layout Grid */
|
||||||
|
.print-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 2.625in);
|
||||||
|
grid-template-rows: repeat(10, 1in);
|
||||||
|
gap: 0;
|
||||||
|
padding: 0.5in 0.21975in; /* Top/Bottom 0.5", Left/Right approx 0.22" */
|
||||||
|
width: 8.5in;
|
||||||
|
height: 11in;
|
||||||
|
background: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.print-container {
|
||||||
|
column-gap: 0.125in;
|
||||||
|
row-gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-cell {
|
||||||
|
width: 2.625in !important;
|
||||||
|
height: 1in !important;
|
||||||
|
border: none !important; /* Remove borders for actual print */
|
||||||
|
margin: 0;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
224
app/scan/page.js
Normal file
224
app/scan/page.js
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import { Html5QrcodeScanner, Html5QrcodeSupportedFormats } from "html5-qrcode";
|
||||||
|
import { Scan as ScanIcon, Check, Minus, Plus, AlertTriangle, X } from "lucide-react";
|
||||||
|
|
||||||
|
export default function ScanPage() {
|
||||||
|
const [scannedData, setScannedData] = useState(null);
|
||||||
|
const [item, setItem] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const [quantityAdjust, setQuantityAdjust] = useState(0);
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
|
||||||
|
const scannerRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only initialize scanner if not already scanning a specific item
|
||||||
|
if (scannedData) return;
|
||||||
|
|
||||||
|
const onScanSuccess = (decodedText) => {
|
||||||
|
setScannedData(decodedText);
|
||||||
|
scannerRef.current?.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
scannerRef.current = new Html5QrcodeScanner(
|
||||||
|
"reader",
|
||||||
|
{
|
||||||
|
fps: 10,
|
||||||
|
qrbox: { width: 250, height: 250 },
|
||||||
|
formatsToSupport: [ Html5QrcodeSupportedFormats.QR_CODE ]
|
||||||
|
},
|
||||||
|
/* verbose= */ false
|
||||||
|
);
|
||||||
|
scannerRef.current.render(onScanSuccess, () => {});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scannerRef.current?.clear().catch(e => console.error("Failed to clear scanner", e));
|
||||||
|
};
|
||||||
|
}, [scannedData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scannedData) return;
|
||||||
|
|
||||||
|
const lookupItem = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Fetch items and find the matched one
|
||||||
|
const res = await fetch("/api/inventory");
|
||||||
|
const list = await res.json();
|
||||||
|
|
||||||
|
const matchedItem = list.find(i => i.qrCodeId === scannedData || i.id === scannedData);
|
||||||
|
if (matchedItem) {
|
||||||
|
setItem(matchedItem);
|
||||||
|
setQuantityAdjust(0);
|
||||||
|
} else {
|
||||||
|
setError(`No item found in database matching: ${scannedData}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
lookupItem();
|
||||||
|
}, [scannedData]);
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
if (!item) return;
|
||||||
|
setUpdating(true);
|
||||||
|
try {
|
||||||
|
const updatedQuantity = item.quantity + quantityAdjust;
|
||||||
|
const res = await fetch("/api/inventory", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: item.id,
|
||||||
|
quantity: updatedQuantity
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Failed to update quantity");
|
||||||
|
|
||||||
|
const updated = await res.json();
|
||||||
|
setItem(updated);
|
||||||
|
setQuantityAdjust(0);
|
||||||
|
alert(`Updated successfully. New quantity: ${updated.quantity}`);
|
||||||
|
|
||||||
|
// Optionally continue scanning
|
||||||
|
resetScanner();
|
||||||
|
} catch (err) {
|
||||||
|
alert("Error: " + err.message);
|
||||||
|
} finally {
|
||||||
|
setUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetScanner = () => {
|
||||||
|
setScannedData(null);
|
||||||
|
setItem(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: "600px", margin: "0 auto" }}>
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="flex items-center gap-2 mb-2">
|
||||||
|
<ScanIcon size={28} className="text-primary" />
|
||||||
|
Scan QR
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted">Use your camera scanner to locate and update items.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!scannedData ? (
|
||||||
|
<div className="glass overflow-hidden">
|
||||||
|
<div id="reader" style={{ width: "100%", border: "none" }}></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="glass" style={{ padding: "2rem" }}>
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center p-8">Loading inventory details...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center p-8">
|
||||||
|
<AlertTriangle size={48} className="text-danger m-auto mb-4" />
|
||||||
|
<h3 className="text-danger mb-2">Item not found</h3>
|
||||||
|
<p className="text-muted mb-6">{error}</p>
|
||||||
|
<button className="btn btn-secondary w-full" onClick={resetScanner}>
|
||||||
|
Scan Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : item && (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-start mb-6">
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: "1.5rem", marginBottom: "0.25rem" }}>{item.name}</h3>
|
||||||
|
<code className="text-muted" style={{ background: "rgba(255,255,255,0.05)", padding: "2px 6px", borderRadius: "4px" }}>
|
||||||
|
ID: {item.qrCodeId}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-danger" onClick={resetScanner} style={{ padding: "0.5rem" }} title="Cancel">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem", marginBottom: "2rem" }}>
|
||||||
|
<div style={{ padding: "1rem", backgroundColor: "rgba(255,255,255,0.05)", borderRadius: "8px" }}>
|
||||||
|
<div className="text-muted" style={{ fontSize: "0.85rem", textTransform: "uppercase" }}>Current Quant.</div>
|
||||||
|
<div style={{ fontSize: "2rem", fontWeight: "bold" }}>{item.quantity}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: "1rem", backgroundColor: "rgba(255,255,255,0.05)", borderRadius: "8px" }}>
|
||||||
|
<div className="text-muted" style={{ fontSize: "0.85rem", textTransform: "uppercase" }}>Price</div>
|
||||||
|
<div style={{ fontSize: "2rem", fontWeight: "bold" }}>${Number(item.price).toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="text-muted mb-2 block text-center">Adjust Quantity</label>
|
||||||
|
<div className="flex justify-center items-center gap-4">
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
style={{ padding: "1rem", borderRadius: "50%" }}
|
||||||
|
onClick={() => setQuantityAdjust(q => q - 1)}
|
||||||
|
>
|
||||||
|
<Minus size={24} />
|
||||||
|
</button>
|
||||||
|
<div style={{ fontSize: "2.5rem", width: "80px", textAlign: "center", fontWeight: "bold" }}>
|
||||||
|
{quantityAdjust > 0 ? `+${quantityAdjust}` : quantityAdjust}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-success"
|
||||||
|
style={{ padding: "1rem", borderRadius: "50%", backgroundColor: "var(--success)", color: "white", border: "none" }}
|
||||||
|
onClick={() => setQuantityAdjust(q => q + 1)}
|
||||||
|
>
|
||||||
|
<Plus size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mt-4">
|
||||||
|
<span className="text-muted">New Quantity will be: </span>
|
||||||
|
<strong style={{ fontSize: "1.2rem", color: (item.quantity + quantityAdjust) < 0 ? "var(--danger)" : "var(--text-main)" }}>
|
||||||
|
{item.quantity + quantityAdjust}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ width: "100%", justifyContent: "center", padding: "1rem" }}
|
||||||
|
onClick={handleUpdate}
|
||||||
|
disabled={updating || quantityAdjust === 0 || (item.quantity + quantityAdjust) < 0}
|
||||||
|
>
|
||||||
|
{updating ? "Saving..." : <><Check size={20} /> Confirm Update</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
/* Scanner UI Overrides */
|
||||||
|
#reader__dashboard_section_csr span { color: var(--text-main) !important; }
|
||||||
|
#reader__dashboard_section_swaplink { color: var(--primary) !important; }
|
||||||
|
#reader button {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
#reader {
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
components/Navigation.js
Normal file
35
components/Navigation.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { Package, PlusCircle, Scan, FileDown, Printer } from "lucide-react";
|
||||||
|
|
||||||
|
export default function Navigation() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: "/", label: "Dashboard", icon: <Package size={20} /> },
|
||||||
|
{ href: "/add", label: "Add / Import", icon: <PlusCircle size={20} /> },
|
||||||
|
{ href: "/scan", label: "Scan QR", icon: <Scan size={20} /> },
|
||||||
|
{ href: "/print", label: "Print Labels", icon: <Printer size={20} /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="navbar glass">
|
||||||
|
<div className="nav-brand">InventoryPro</div>
|
||||||
|
<ul className="nav-links">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={`nav-link ${pathname === item.href ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
lib/prisma.js
Normal file
18
lib/prisma.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
|
||||||
|
const prismaClientSingleton = () => {
|
||||||
|
return new PrismaClient({ adapter });
|
||||||
|
};
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis;
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? prismaClientSingleton();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||||
@ -1,6 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
|
allowedDevOrigins: ['192.168.1.122', 'invent.salesfam.cloud'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
1436
package-lock.json
generated
1436
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@ -9,9 +9,17 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/adapter-pg": "^7.6.0",
|
||||||
|
"@prisma/client": "^7.6.0",
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
"next": "16.2.2",
|
"next": "16.2.2",
|
||||||
|
"pg": "^8.20.0",
|
||||||
|
"prisma": "^7.6.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"react-qr-code": "^2.0.18",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
|
|||||||
14
prisma.config.ts
Normal file
14
prisma.config.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
// This file was generated by Prisma, and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env["DATABASE_URL"],
|
||||||
|
},
|
||||||
|
});
|
||||||
24
prisma/schema.prisma
Normal file
24
prisma/schema.prisma
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
// Get a free hosted Postgres database in seconds: `npx create-db`
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
model InventoryItem {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
qrCodeId String @unique
|
||||||
|
name String
|
||||||
|
quantity Int @default(0)
|
||||||
|
price Float @default(0.0)
|
||||||
|
category String?
|
||||||
|
description String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user