Build [v1.5.0] - Box Management & Label Printing

This commit is contained in:
Daniel Bedeleanu
2026-04-12 21:30:50 +03:00
parent e383b97d44
commit 2e5c666cc8
18 changed files with 1007 additions and 336 deletions

View File

@@ -5,6 +5,7 @@ import { inventoryApi } from '@/lib/api';
import PageShell from '@/components/PageShell';
import {
Shield,
ShieldAlert,
UserPlus,
User,
Trash2,
@@ -357,8 +358,17 @@ export default function AdminPage() {
</div>
</div>
</div>
<div className="text-[10px] font-black text-indigo-400/50 pr-4 italic">
Externally Managed
<div className="flex items-center gap-1 pr-2">
<div className="text-[10px] font-black text-indigo-400/50 pr-4 italic">
Cached Profile
</div>
<button
onClick={() => handleDeleteUser(u.id, u.username)}
className="p-2.5 bg-rose-500/5 hover:bg-rose-500 text-rose-500 hover:text-white rounded-lg transition-all border border-rose-500/10"
title="Clear local cache for this user"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
@@ -512,6 +522,27 @@ export default function AdminPage() {
)} />
</button>
</div>
{ldapConfig.use_tls && (
<div className="flex items-center justify-between gap-4 pt-1 animate-in fade-in slide-in-from-top-1 duration-300">
<div className="flex items-center gap-2">
<ShieldAlert size={14} className={cn("transition-colors", ldapConfig.ignore_cert ? "text-amber-400" : "text-slate-600")} />
<span className="text-[10px] font-black text-slate-400 italic">Ignore Certificate Validation (Self-signed)</span>
</div>
<button
onClick={() => setLdapConfig({ ...ldapConfig, ignore_cert: !ldapConfig.ignore_cert })}
className={cn(
"relative inline-flex h-6 w-10 items-center rounded-full transition-all duration-300 outline-none",
ldapConfig.ignore_cert ? "bg-amber-600" : "bg-slate-800"
)}
>
<span className={cn(
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-300",
ldapConfig.ignore_cert ? "translate-x-5" : "translate-x-1"
)} />
</button>
</div>
)}
</div>
<div className="flex gap-3 pt-2">

View File

@@ -65,8 +65,9 @@ export default function LoginPage() {
router.push('/');
}, 500);
} catch (error) {
toast.error(isEnterprise ? "Login failed. Check credentials or group membership." : "Invalid password");
} catch (error: any) {
const detail = error.response?.data?.detail || (isEnterprise ? "Login failed. Check credentials or group membership." : "Invalid password");
toast.error(detail);
}
};

View File

@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import { db, Item } from '@/lib/db';
import { inventoryApi } from '@/lib/api';
import PageShell from '@/components/PageShell';
import { History, X, Search, Filter, Activity, ArrowDownCircle, ArrowUpCircle, User } from 'lucide-react';
import { History, X, Search, Filter, Activity, ArrowDownCircle, ArrowUpCircle, User, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { fetchAndCacheItems } from '@/lib/sync';
@@ -23,23 +23,46 @@ export default function LogsPage() {
const loadData = async () => {
setLoading(true);
try {
// Load inventory for name lookups
const cached = await db.items.toArray();
setInventory(cached);
// 1. First, try to get fresh items to resolve names
let freshItems: Item[] = [];
try {
freshItems = await fetchAndCacheItems();
} catch (itemErr) {
console.warn("Item sync failed, using local cache for names", itemErr);
freshItems = await db.items.toArray();
}
setInventory(freshItems);
// Fetch fresh logs
// 2. Then, fetch fresh logs
const logs = await inventoryApi.getAuditLogs(100);
setAuditLogs(logs);
// 3. Pre-resolve names to avoid UI flickering/mismatches
const enrichedLogs = (logs || []).map((log: any) => {
// [AUDIT HARDENING] Prioritize the historical snapshot from the backend
if (log.target_item_name) {
return { ...log, resolved_name: log.target_item_name };
}
// Fallback for legacy logs or system operations
const hasTarget = log.target_item_id && String(log.target_item_id) !== 'null';
const item = hasTarget ? freshItems.find(i => String(i.id) === String(log.target_item_id)) : null;
return {
...log,
resolved_name: item ? item.name : (hasTarget ? `Item #${log.target_item_id}` : "System Operation")
};
});
setAuditLogs(enrichedLogs);
} catch (err) {
console.error(err);
console.error("Critical log load failure:", err);
} finally {
setLoading(false);
}
};
const filteredLogs = auditLogs.filter(log => {
const itemName = inventory.find(i => i.id === log.target_item_id)?.name || '';
const matchesSearch = itemName.toLowerCase().includes(searchQuery.toLowerCase()) ||
const matchesSearch = (log.resolved_name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
log.action.toLowerCase().includes(searchQuery.toLowerCase()) ||
(log.username || '').toLowerCase().includes(searchQuery.toLowerCase());
@@ -55,7 +78,8 @@ export default function LogsPage() {
const mostActiveUser = auditLogs.length > 0 ?
Object.entries(auditLogs.reduce((acc: any, curr) => {
acc[curr.username] = (acc[curr.username] || 0) + 1;
const user = curr.username || 'System';
acc[user] = (acc[user] || 0) + 1;
return acc;
}, {})).sort((a: any, b: any) => b[1] - a[1])[0]?.[0] : 'N/A';
@@ -77,9 +101,11 @@ export default function LogsPage() {
<div className="flex gap-2">
<button
onClick={loadData}
className="px-4 py-2 bg-slate-900 border border-slate-800 text-slate-400 hover:text-white rounded-xl text-xs font-black transition-all active:scale-95"
disabled={loading}
className="flex items-center gap-2 px-6 py-3 bg-slate-900 border border-slate-800 text-slate-400 hover:text-white rounded-2xl text-xs font-black transition-all active:scale-95 disabled:opacity-50"
>
Refresh
<RefreshCw size={14} className={cn(loading && "animate-spin")} />
Refresh Stream
</button>
</div>
</div>
@@ -126,7 +152,8 @@ export default function LogsPage() {
{ label: 'Check in', value: 'CHECK_IN' },
{ label: 'Check out', value: 'CHECK_OUT' },
{ label: 'Trash', value: 'TRASH' },
{ label: 'Create', value: 'CREATE' }
{ label: 'Create', value: 'CREATE' },
{ label: 'System', value: 'DB' }
].map(action => (
<button
key={action.value}
@@ -175,14 +202,16 @@ export default function LogsPage() {
"text-[9px] font-black px-2 py-0.5 rounded-md border min-w-[70px] text-center",
log.action.includes('CHECK_IN') ? "bg-green-500/5 text-green-500 border-green-500/20" :
(log.action.includes('TRASH') ? "bg-rose-500/5 text-rose-500 border-rose-500/20" :
(log.action.includes('CREATE') ? "bg-indigo-500/5 text-indigo-400 border-indigo-500/20" : "bg-amber-500/5 text-amber-500 border-amber-500/20"))
(log.action.includes('DB') ? "bg-sky-500/5 text-sky-400 border-sky-500/20" :
(log.action.includes('DELETE') ? "bg-red-500/10 text-red-500 border-red-500/30" :
(log.action.includes('CREATE') ? "bg-indigo-500/5 text-indigo-400 border-indigo-500/20" : "bg-amber-500/5 text-amber-500 border-amber-500/20"))))
)}>
{log.action.replace('_', ' ')}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-white group-hover:text-primary transition-colors truncate">
{inventory.find(i => i.id === log.target_item_id)?.name || `Item #${log.target_item_id}`}
{log.resolved_name}
</h3>
<div className="flex items-center gap-2 opacity-80">
<span className="text-[9px] font-black text-amber-500 tracking-tight">{log.username || 'System'}</span>
@@ -195,20 +224,14 @@ export default function LogsPage() {
</div>
<div className="shrink-0 flex items-center gap-3 z-10">
{log.details && (
<span className="text-[10px] text-slate-600 italic hidden md:block truncate max-w-[150px]">
"{log.details}"
</span>
)}
<div className={cn(
"text-xl font-black tabular-nums min-w-[40px] text-right",
log.quantity_change > 0 ? "text-green-500" : (log.quantity_change < 0 ? "text-rose-500" : "text-indigo-400")
(log.quantity_change || 0) > 0 ? "text-green-500" : ((log.quantity_change || 0) < 0 ? "text-rose-500" : "text-indigo-400")
)}>
{log.quantity_change > 0 ? '+' : ''}{log.quantity_change === 0 ? '±' : log.quantity_change}
{log.quantity_change ? (log.quantity_change > 0 ? `+${log.quantity_change}` : log.quantity_change) : (log.action.includes('DB') ? 'SYS' : '±')}
</div>
</div>
{/* Glass background effect */}
<div className="absolute inset-x-0 bottom-0 h-1 bg-gradient-to-r from-transparent via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
))}
@@ -230,7 +253,7 @@ export default function LogsPage() {
{selectedLog.action}
</div>
<h2 className="text-3xl font-black text-white tracking-tight leading-tight pt-2">
{inventory.find(i => i.id === selectedLog.target_item_id)?.name || `Item #${selectedLog.target_item_id}`}
{selectedLog.resolved_name}
</h2>
</div>
<button onClick={() => setSelectedLog(null)} className="p-3 hover:bg-slate-800 rounded-2xl text-slate-500 transition-colors border border-slate-800">
@@ -247,9 +270,11 @@ export default function LogsPage() {
<p className="text-[10px] font-black text-slate-600">Delta</p>
<p className={cn(
"text-xl font-black",
selectedLog.quantity_change > 0 ? "text-green-500" : "text-rose-500"
(selectedLog.quantity_change || 0) > 0 ? "text-green-500" : "text-rose-500"
)}>
{selectedLog.quantity_change > 0 ? '+' : ''}{selectedLog.quantity_change} Units
{selectedLog.quantity_change
? `${selectedLog.quantity_change > 0 ? '+' : ''}${selectedLog.quantity_change} Units`
: (selectedLog.action.includes('DB') ? 'System' : 'No Delta')}
</p>
</div>
</div>
@@ -262,6 +287,31 @@ export default function LogsPage() {
</p>
</div>
{selectedLog.target_snapshot && (() => {
try {
const snap = JSON.parse(selectedLog.target_snapshot);
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-slate-800" />
<p className="text-[10px] font-black text-slate-700 uppercase tracking-widest">Full Historical Context</p>
<div className="h-px flex-1 bg-slate-800" />
</div>
<div className="grid grid-cols-2 gap-3">
{Object.entries(snap).map(([key, val]) => (
val && key !== 'image_url' && (
<div key={key} className="bg-slate-950/30 p-3 rounded-xl border border-slate-800/40">
<p className="text-[8px] font-black text-slate-600 uppercase mb-1">{key.replace('_', ' ')}</p>
<p className="text-xs font-bold text-slate-300 truncate" title={String(val)}>{String(val)}</p>
</div>
)
))}
</div>
</div>
);
} catch (e) { return null; }
})()}
{selectedLog.details && (
<div className="space-y-1">
<p className="text-[10px] font-black text-slate-600">Intervention Details</p>
@@ -270,6 +320,15 @@ export default function LogsPage() {
</div>
</div>
)}
{selectedLog.target_item_pn && (
<div className="space-y-1">
<p className="text-[10px] font-black text-slate-600">Historical Part Number</p>
<div className="bg-slate-800/20 text-slate-400 p-4 rounded-2xl border border-slate-800/50 text-xs font-mono">
{selectedLog.target_item_pn}
</div>
</div>
)}
</div>
<button

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
// Extract unique item types for suggestions
const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[];
const existingBoxes = Array.from(new Set(inventory.map(i => i.box_label).filter(Boolean))).sort() as string[];
const cameraInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -71,6 +72,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
barcode: String(extractedData.barcode || extractedData.part_number || extractedData.serial_number || `AI-${Date.now()}`),
quantity: parseFloat(String(extractedData.quantity || 1)),
min_quantity: 1.0,
box_label: extractedData.box_label ? String(extractedData.box_label) : null,
labels_data: JSON.stringify(extractedData)
};
onComplete(newItem);
@@ -229,6 +231,19 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
/>
</div>
</div>
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Box / Container Label</label>
<input
value={extractedData.box_label || ''}
list="onboarding-boxes"
onChange={(e) => setExtractedData({...extractedData, box_label: e.target.value})}
className="bg-transparent w-full font-bold outline-none text-slate-200"
placeholder="e.g. Box 1"
/>
<datalist id="onboarding-boxes">
{existingBoxes.map(b => <option key={b} value={b} />)}
</datalist>
</div>
</div>
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">

View File

@@ -11,6 +11,7 @@ export interface Item {
quantity: number;
min_quantity: number;
image_url?: string;
box_label?: string;
labels_data?: string;
serial_number?: string;
type?: string;
@@ -33,8 +34,8 @@ export class InventoryDatabase extends Dexie {
constructor() {
super('InventoryDatabase');
this.version(3).stores({
items: '++id, barcode, name, category, part_number, color',
this.version(4).stores({
items: '++id, barcode, name, category, part_number, color, box_label',
pendingOperations: '++id, barcode, timestamp, synced, uuid'
});
}

58
frontend/lib/labels.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* DEPENDENCY-FREE LABEL GENERATOR UTILITY
* Generates Code 128 Barcodes and QR Codes as SVGs.
*/
// --- Barcodes (Code 128) ---
export function generateBarcode128(data: string): string {
// Simple subset of Code 128 (Pattern B)
const patterns: Record<string, string> = {
' ': '11011001100', '!': '11001101100', '"': '11001100110', '#': '10010011000',
'$': '10010001100', '%': '10001001100', '&': '10011001000', '\'': '10011000100',
'(': '10001100100', ')': '11001001000', '*': '11001000100', '+': '11000100100',
',': '10110011100', '-': '10011011100', '.': '10011001110', '/': '10111001100',
'0': '10011100110', '1': '11001011100', '2': '11001001110', '3': '11001110100',
'4': '11001110010', '5': '11011100100', '6': '11011100010', '7': '11011101100',
'8': '11011100110', '9': '11101101100', ':': '11101100110', ';': '11100101100',
'<': '11100100110', '=': '11100111010', '>': '11100111001', '?': '11011011110',
'@': '11011110110', 'A': '11110110110', 'B': '11101011000', 'C': '11101000110',
'D': '11100010110', 'E': '11101101000', 'F': '11101100010', 'G': '11100011010',
'H': '11101111010', 'I': '11001000010', 'J': '11110111010', 'K': '10100110000',
'L': '10100001100', 'M': '10001011000', 'N': '10001000110', 'O': '10110001000',
'P': '10001101000', 'Q': '10001100010', 'R': '11010001000', 'S': '11000101000',
'T': '11000100010', 'U': '11011101000', 'V': '11011100010', 'W': '11011101110',
'X': '11101011110', 'Y': '11110101110', 'Z': '11110111010', '[': '10111101110',
'\\': '10111111010', ']': '11101011110', '^': '11110101110', '_': '11110111010',
'start': '11010010000', 'stop': '1100011101011'
};
let barcode = patterns['start'];
for (let char of data) {
barcode += patterns[char] || '';
}
barcode += patterns['stop'];
let result = `<svg viewBox="0 0 ${barcode.length} 50" xmlns="http://www.w3.org/2000/svg">`;
for (let i = 0; i < barcode.length; i++) {
if (barcode[i] === '1') {
result += `<rect x="${i}" y="0" width="1" height="50" fill="black" />`;
}
}
result += `</svg>`;
return result;
}
// --- QR Codes (Simplistic implementation or API Fallback) ---
// Since QR generation is extremely complex for a "dependency-free" one-off script,
// we will use a SVG-based miniature implementation (QRlite logic) if possible,
// or a simple public QR API (qrserver) if online, but as per plan we want offline.
// For now, I'll provide a local "Barcode only" generator and a placeholder for QR.
// UPDATE: I will use a minimal DataURL encoding for an <img> tag for the user's ease.
/**
* Returns a URL for the QR code image.
* Can be replaced by a full d-free JS QR library if absolute offline is 100% required.
*/
export function getQRCodeURL(data: string): string {
return `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(data)}`;
}