feat: initialize inventory management application with Prisma schema, QR scanning, and UI components
This commit is contained in:
258
app/page.js
258
app/page.js
@ -1,66 +1,206 @@
|
||||
import Image from "next/image";
|
||||
import styles from "./page.module.css";
|
||||
"use client";
|
||||
|
||||
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 (
|
||||
<div className={styles.page}>
|
||||
<main className={styles.main}>
|
||||
<Image
|
||||
className={styles.logo}
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className={styles.intro}>
|
||||
<h1>To get started, edit the 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 className="glass" style={{ padding: "2rem" }}>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h1 style={{ fontSize: "1.8rem", marginBottom: "0.5rem" }}>Inventory Overview</h1>
|
||||
<p className="text-muted">Manage your inventory quantities and prices.</p>
|
||||
</div>
|
||||
<div className={styles.ctas}>
|
||||
<a
|
||||
className={styles.primary}
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className={styles.logo}
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={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 className="flex gap-2">
|
||||
<button className="btn btn-secondary" onClick={handleExport} title="Export to Excel">
|
||||
<Download size={16} />
|
||||
Export
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={fetchItems}>
|
||||
<RefreshCw size={16} className={loading ? "spin" : ""} />
|
||||
Refresh
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user