Files
inventory/app/page.js
2026-04-13 15:54:00 -04:00

299 lines
12 KiB
JavaScript

"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 [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
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 || "", location: item.location || "", quantity: item.quantity, price: item.price });
};
const saveEdit = async (id) => {
try {
const parsedQty = parseInt(editForm.quantity, 10);
const payload = {
id,
name: editForm.name,
category: editForm.category,
location: editForm.location,
quantity: isNaN(parsedQty) ? 0 : parsedQty,
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);
}
};
// Filter and Paginate Items
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, selectedCategory]);
const uniqueCategories = [...new Set(items.map(item => item.category?.trim() || "Uncategorized"))].filter(c => c).sort();
const filteredItems = items.filter(item => {
const matchesSearch = item.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.qrCodeId.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = selectedCategory === "" || (item.category?.trim() || "Uncategorized") === selectedCategory;
return matchesSearch && matchesCategory;
});
const totalPages = Math.max(1, Math.ceil(filteredItems.length / itemsPerPage));
const startIndex = (currentPage - 1) * itemsPerPage;
const paginatedItems = filteredItems.slice(startIndex, startIndex + itemsPerPage);
const handleExport = () => {
if (filteredItems.length === 0) return alert("No items to export");
const ws = xlsx.utils.json_to_sheet(filteredItems.map(i => ({
"QR ID": i.qrCodeId,
"Name": i.name,
"Quantity": i.quantity,
"Price": i.price,
"Category": i.category || "",
"Location": i.location || "",
"Description": i.description || "",
"Last Updated": i.updatedAt ? new Date(i.updatedAt).toLocaleString() : ""
})));
const wb = xlsx.utils.book_new();
xlsx.utils.book_append_sheet(wb, ws, "Inventory");
xlsx.writeFile(wb, "Inventory_Export.xlsx");
};
return (
<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="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>
</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">
<div className="flex gap-4 mb-4" style={{ alignItems: "center" }}>
<input
type="text"
placeholder="Search by name or QR..."
className="input-field"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ flex: 1 }}
/>
<select
className="input-field"
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
style={{ width: "200px" }}
>
<option value="">All Categories</option>
{uniqueCategories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>QR ID</th>
<th>Category</th>
<th>Location</th>
<th>Quantity</th>
<th>Price</th>
<th>Last Updated</th>
<th style={{ textAlign: "right" }}>Actions</th>
</tr>
</thead>
<tbody>
{paginatedItems.length === 0 ? (
<tr>
<td colSpan="8" style={{ textAlign: "center", padding: "2rem", color: "var(--text-muted)" }}>
No items match your filters.
</td>
</tr>
) : paginatedItems.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 className="input-field" style={{ padding: "0.2rem 0.5rem", width: "100%" }} value={editForm.location} onChange={e => setEditForm({...editForm, location: e.target.value})} />
) : (item.location || "-")}
</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 className="text-muted" style={{ fontSize: "0.85rem" }}>
{item.updatedAt ? new Date(item.updatedAt).toLocaleDateString() + " " + new Date(item.updatedAt).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}) : "N/A"}
</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>
{filteredItems.length > 0 && (
<div className="flex justify-between items-center mt-4" style={{ padding: "1rem 0 0 0" }}>
<span className="text-muted" style={{ fontSize: "0.9rem" }}>
Showing {startIndex + 1} to {Math.min(startIndex + itemsPerPage, filteredItems.length)} of {filteredItems.length} entries
</span>
<div className="flex gap-2">
<button
className="btn btn-secondary"
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
Previous
</button>
<span className="flex items-center text-muted" style={{ padding: "0 0.5rem", fontSize: "0.9rem" }}>
Page {currentPage} of {totalPages}
</span>
<button
className="btn btn-secondary"
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
>
Next
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}