Files
inventory/app/print/page.js
2026-09-23 16:57:44 -04:00

486 lines
18 KiB
JavaScript

"use client";
import { useEffect, useState } from "react";
import QRCode from "react-qr-code";
import { Printer, AlertCircle, Filter, Search, Tag, CheckCircle2 } from "lucide-react";
export default function PrintPage() {
const [items, setItems] = useState([]);
const [selectedIds, setSelectedIds] = useState(new Set());
const [selectedCategory, setSelectedCategory] = useState("");
const [searchQuery, setSearchQuery] = 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);
setSelectedIds(new Set(data.map(i => i.id)));
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchItems();
}, []);
const uniqueCategories = [
...new Set(items.map(item => item.category?.trim() || "Uncategorized"))
].filter(Boolean).sort();
const filteredItems = items.filter(item => {
const itemCat = item.category?.trim() || "Uncategorized";
const matchesCategory = selectedCategory === "" || itemCat === selectedCategory;
const matchesSearch = searchQuery === "" ||
item.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
(item.qrCodeId && item.qrCodeId.toLowerCase().includes(searchQuery.toLowerCase()));
return matchesCategory && matchesSearch;
});
const isAllFilteredSelected = filteredItems.length > 0 && filteredItems.every(i => selectedIds.has(i.id));
const handlePrint = () => {
if (selectedIds.size === 0) return alert("Please select at least one label to print.");
window.print();
};
const toggleSelection = (id) => {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelectedIds(next);
};
const toggleFilteredAll = () => {
const next = new Set(selectedIds);
if (isAllFilteredSelected) {
filteredItems.forEach(i => next.delete(i.id));
} else {
filteredItems.forEach(i => next.add(i.id));
}
setSelectedIds(next);
};
const selectOnlyCurrentFiltered = () => {
const next = new Set();
filteredItems.forEach(i => next.add(i.id));
setSelectedIds(next);
};
const selectAll = () => {
setSelectedIds(new Set(items.map(i => i.id)));
};
const deselectAll = () => {
setSelectedIds(new Set());
};
const itemsToPrint = items.filter(i => selectedIds.has(i.id));
const sheetCount = Math.ceil(itemsToPrint.length / 30);
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: "1.5rem" }}>
<div className="flex justify-between items-center flex-wrap gap-4">
<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>
<div className="flex items-center gap-3">
<div className="text-right" style={{ fontSize: "0.9rem" }}>
<div style={{ fontWeight: 600, color: itemsToPrint.length > 0 ? "var(--text-main)" : "var(--danger)" }}>
{itemsToPrint.length} of {items.length} selected
</div>
<div className="text-muted" style={{ fontSize: "0.8rem" }}>
{sheetCount} {sheetCount === 1 ? "sheet" : "sheets"} required
</div>
</div>
<button className="btn btn-primary" onClick={handlePrint} disabled={itemsToPrint.length === 0}>
<Printer size={18} /> Print Now
</button>
</div>
</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>
{/* Filter and Category Selection Toolbar (Hidden in Print) */}
<div className="glass no-print mb-6" style={{ padding: "1.5rem" }}>
<div className="flex justify-between items-center flex-wrap gap-4 mb-4">
<div className="flex items-center gap-2">
<Filter size={18} className="text-primary" />
<h3 style={{ fontSize: "1.1rem", margin: 0 }}>Filter & Select Labels</h3>
</div>
<div className="flex items-center gap-2 flex-wrap">
{selectedCategory && (
<button
className="btn btn-secondary"
style={{ padding: "0.4rem 0.8rem", fontSize: "0.85rem", borderColor: "var(--primary)" }}
onClick={selectOnlyCurrentFiltered}
title={`Select only items in "${selectedCategory}"`}
>
<CheckCircle2 size={15} className="text-primary" />
Select Only "{selectedCategory}" ({filteredItems.length})
</button>
)}
<button
className="btn btn-secondary"
style={{ padding: "0.4rem 0.8rem", fontSize: "0.85rem" }}
onClick={selectAll}
>
Select All ({items.length})
</button>
<button
className="btn btn-secondary"
style={{ padding: "0.4rem 0.8rem", fontSize: "0.85rem" }}
onClick={deselectAll}
>
Deselect All
</button>
</div>
</div>
{/* Filter Inputs */}
<div className="flex gap-4 flex-wrap" style={{ alignItems: "center" }}>
<div style={{ position: "relative", flex: 1, minWidth: "220px" }}>
<Search size={16} className="text-muted" style={{ position: "absolute", left: "12px", top: "50%", transform: "translateY(-50%)" }} />
<input
type="text"
placeholder="Search by name or QR..."
className="input-field"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ width: "100%", paddingLeft: "36px" }}
/>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", minWidth: "220px" }}>
<Tag size={16} className="text-muted flex-shrink-0" />
<select
className="input-field"
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
style={{ width: "100%" }}
>
<option value="">All Categories ({items.length})</option>
{uniqueCategories.map(cat => {
const count = items.filter(i => (i.category?.trim() || "Uncategorized") === cat).length;
return (
<option key={cat} value={cat}>
{cat} ({count})
</option>
);
})}
</select>
</div>
</div>
{/* Quick Category Badges */}
{uniqueCategories.length > 1 && (
<div className="flex gap-2 mt-3 flex-wrap" style={{ alignItems: "center" }}>
<span className="text-muted" style={{ fontSize: "0.8rem", marginRight: "4px" }}>Quick Category Filter:</span>
<button
onClick={() => setSelectedCategory("")}
className="btn btn-secondary"
style={{
padding: "0.2rem 0.6rem",
fontSize: "0.78rem",
borderRadius: "20px",
background: selectedCategory === "" ? "var(--primary)" : "rgba(255, 255, 255, 0.05)",
color: selectedCategory === "" ? "white" : "var(--text-muted)",
border: "1px solid " + (selectedCategory === "" ? "var(--primary)" : "var(--border)")
}}
>
All ({items.length})
</button>
{uniqueCategories.map(cat => {
const count = items.filter(i => (i.category?.trim() || "Uncategorized") === cat).length;
const isSelected = selectedCategory === cat;
return (
<button
key={cat}
onClick={() => setSelectedCategory(isSelected ? "" : cat)}
className="btn btn-secondary"
style={{
padding: "0.2rem 0.6rem",
fontSize: "0.78rem",
borderRadius: "20px",
background: isSelected ? "var(--primary)" : "rgba(255, 255, 255, 0.05)",
color: isSelected ? "white" : "var(--text-muted)",
border: "1px solid " + (isSelected ? "var(--primary)" : "var(--border)")
}}
>
{cat} ({count})
</button>
);
})}
</div>
)}
</div>
{/* Item Selection Table (Hidden in Print) */}
<div className="glass no-print mb-6">
<div className="flex justify-between items-center mb-3" style={{ padding: "1rem 1rem 0" }}>
<div className="flex items-center gap-2">
<span className="text-muted" style={{ fontSize: "0.9rem" }}>
Showing {filteredItems.length} of {items.length} items
{selectedCategory && ` in "${selectedCategory}"`}
</span>
</div>
<button
className="btn btn-secondary"
style={{ padding: "0.25rem 0.75rem", fontSize: "0.85rem" }}
onClick={toggleFilteredAll}
>
{isAllFilteredSelected ? "Deselect Visible" : "Select Visible"}
</button>
</div>
<div className="table-container" style={{ maxHeight: "380px", overflowY: "auto" }}>
<table className="data-table">
<thead style={{ position: "sticky", top: 0, zIndex: 1, backgroundColor: "var(--bg-card)" }}>
<tr>
<th style={{ width: "50px", textAlign: "center" }}>
<input
type="checkbox"
checked={isAllFilteredSelected}
onChange={toggleFilteredAll}
/>
</th>
<th>Name</th>
<th>QR ID</th>
<th>Category</th>
<th>Location</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{filteredItems.length === 0 ? (
<tr>
<td colSpan="6" style={{ textAlign: "center", padding: "2rem", color: "var(--text-muted)" }}>
No items found matching the selected filters.
</td>
</tr>
) : (
filteredItems.map(item => {
const isSelected = selectedIds.has(item.id);
return (
<tr
key={item.id}
style={{
cursor: "pointer",
backgroundColor: isSelected ? "rgba(59, 130, 246, 0.08)" : "transparent",
opacity: isSelected ? 1 : 0.6
}}
onClick={() => toggleSelection(item.id)}
>
<td style={{ textAlign: "center" }} onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelection(item.id)}
/>
</td>
<td style={{ fontWeight: 500 }}>{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>
<span style={{
padding: "2px 8px",
borderRadius: "12px",
fontSize: "0.8rem",
background: "rgba(255, 255, 255, 0.07)",
border: "1px solid var(--border)"
}}>
{item.category || "Uncategorized"}
</span>
</td>
<td className="text-muted">{item.location || "-"}</td>
<td>
{isSelected ? (
<span className="text-success" style={{ fontSize: "0.8rem", fontWeight: 500 }}>
To Print
</span>
) : (
<span className="text-muted" style={{ fontSize: "0.8rem" }}>
Excluded
</span>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* Printable Sheet Area */}
{itemsToPrint.length === 0 ? (
<div className="glass text-center p-8 no-print" style={{ padding: "3rem", color: "var(--text-muted)" }}>
<p>No labels selected to print. Select items above to generate printable sheets.</p>
</div>
) : (
Array.from({ length: Math.ceil(itemsToPrint.length / 30) }, (_, pageIndex) => (
<div key={pageIndex} className="print-container">
{itemsToPrint.slice(pageIndex * 30, (pageIndex + 1) * 30).map((item) => (
<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>
{item.category && <div className="item-category">{item.category}</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;
margin-bottom: 2rem;
}
.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.55rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-sku {
font-size: 0.65rem;
color: #555;
margin-top: 0.1rem;
word-break: break-all;
}
.item-category {
font-size: 0.5rem;
color: #777;
margin-top: 0.05rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media print {
@page {
size: letter;
margin: 0; /* Reset browser margins entirely */
}
body {
background-color: white !important;
-webkit-print-color-adjust: exact;
margin: 0;
padding: 0;
}
.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.1250in; /* Top/Bottom 0.5", Left/Right approx 0.1250" */
width: 8.5in;
height: 11in;
background: white;
border: none;
border-radius: 0;
break-after: page;
margin-bottom: 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>
</>
);
}