feat: initialize inventory management application with Prisma schema, QR scanning, and UI components
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user