style: ui readability refactor v1.2.2 (removed uppercase, tracking, increased font sizes)

This commit is contained in:
Daniel Bedeleanu
2026-04-11 11:22:40 +03:00
parent 99b70a9de8
commit 4136d49936
25 changed files with 2552 additions and 548 deletions

682
frontend/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,682 @@
'use client';
import { useState, useEffect } from 'react';
import { inventoryApi } from '@/lib/api';
import PageShell from '@/components/PageShell';
import {
Shield,
UserPlus,
User,
Trash2,
Tag,
Plus,
AlertTriangle,
LogOut,
Database,
History,
Lock,
Edit2,
X,
Server,
Globe,
Wifi,
WifiOff
} from 'lucide-react';
import { toast } from 'react-hot-toast';
import { cn } from '@/lib/utils';
export default function AdminPage() {
const [users, setUsers] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
// LDAP Config State
const [ldapConfig, setLdapConfig] = useState<any>({
ldap_enabled: false,
server_uri: 'ldap://192.168.84.107:3890',
base_dn: 'dc=example,dc=com',
user_template: 'cn={username},ou=people,dc=example,dc=com',
required_group: 'inventory',
groups_dn: 'ou=groups',
use_tls: false,
role_mappings: []
});
const [testingLdap, setTestingLdap] = useState(false);
// Edit States
const [editingUser, setEditingUser] = useState<any | null>(null);
const [editUserForm, setEditUserForm] = useState({ username: '', password: '', role: 'user' });
const [editingCategory, setEditingCategory] = useState<any | null>(null);
const [editCatForm, setEditCatForm] = useState({ name: '', description: '' });
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
const [u, c, l] = await Promise.all([
inventoryApi.getUsers(),
inventoryApi.getCategories(),
inventoryApi.getLdapConfig()
]);
setUsers(u);
setCategories(c);
if (l && l.server_uri) setLdapConfig(l);
} catch (err) {
console.error(err);
toast.error("Failed to load admin data");
} finally {
setLoading(false);
}
};
const handleAddUser = async () => {
const name = prompt("Enter new username:");
if (!name) return;
const pwd = prompt("Enter password for " + name + ":");
if (!pwd) return;
try {
await inventoryApi.createUser({ username: name, password: pwd, role: 'user' });
toast.success("User created successfully");
loadData();
} catch (err) {
toast.error("Failed to create user");
}
};
const handleDeleteUser = async (id: number, username: string) => {
if (username === 'Admin') return;
if (!confirm(`Delete user ${username}?`)) return;
try {
await inventoryApi.deleteUser(id);
toast.success("User removed");
loadData();
} catch (err) {
toast.error("Delete failed");
}
};
const handleAddCategory = async () => {
const name = prompt("New category name:");
if (!name) return;
const desc = prompt("Description (optional):");
try {
await inventoryApi.createCategory({ name, description: desc });
toast.success("Category added");
loadData();
} catch (err) {
toast.error("Failed to add category");
}
};
const handleUpdateCategorySubmit = async () => {
if (!editingCategory) return;
try {
await inventoryApi.updateCategory(editingCategory.id, editCatForm);
toast.success("Category updated");
setEditingCategory(null);
loadData();
} catch (err) {
toast.error("Update failed");
}
};
const handleDeleteCategory = async (id: number, name: string) => {
if (!confirm(`Delete category ${name}?`)) return;
try {
await inventoryApi.deleteCategory(id);
toast.success("Category removed");
loadData();
} catch (err) {
toast.error(err.response?.data?.detail || "Delete failed");
}
};
const handleUpdateUserSubmit = async () => {
if (!editingUser) return;
try {
const payload: any = { username: editUserForm.username, role: editUserForm.role };
if (editUserForm.password) payload.password = editUserForm.password;
await inventoryApi.updateUser(editingUser.id, payload);
toast.success("User updated successfully");
setEditingUser(null);
loadData();
} catch (err) {
toast.error("Update failed");
}
};
const handleLogout = () => {
localStorage.removeItem('inventory_user');
window.location.href = '/';
};
return (
<PageShell requireAdmin={true}>
<main className="p-4 md:p-8 max-w-6xl mx-auto space-y-16">
<header className="flex items-center gap-6">
<div className="p-4 bg-primary/10 rounded-3xl text-primary border border-primary/20 shadow-xl shadow-primary/5">
<Shield size={40} />
</div>
<div>
<h1 className="text-4xl font-black tracking-tight text-white">System Admin</h1>
<p className="text-xs text-slate-500 font-bold mt-1">Enterprise Control Center</p>
</div>
</header>
{/* User Management Section */}
<section className="space-y-6">
<div className="flex items-center justify-between px-2">
<div className="flex items-center gap-3">
<User size={20} className="text-primary" />
<h2 className="text-lg font-black text-white">User Accounts</h2>
</div>
<button
onClick={handleAddUser}
className="flex items-center justify-center gap-2 bg-primary/10 hover:bg-primary text-primary hover:text-white font-black text-xs px-4 py-2 rounded-xl transition-all border border-primary/20 active:scale-95"
>
<UserPlus size={14} /> Add Local User
</button>
</div>
<div className="space-y-12">
{/* Local Users Group */}
<div className="space-y-4">
<div className="flex items-center justify-between px-4">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-primary" />
<h3 className="text-sm font-black text-white">Local Users (Manual)</h3>
</div>
</div>
<div className="bg-slate-900/40 border border-slate-800/50 rounded-3xl overflow-hidden shadow-xl divide-y divide-slate-800/50">
{loading ? (
<div className="p-8 text-center animate-pulse text-slate-600 text-xs font-black">Loading...</div>
) : (
users.filter(u => (u.origin || 'local') === 'local').map(u => (
<div key={u.id} className="flex items-center justify-between p-4 hover:bg-slate-800/30 transition-all group">
<div className="flex items-center gap-4">
<div className={cn(
"p-2.5 rounded-xl",
u.role === 'admin' ? "bg-primary/10 text-primary border border-primary/20" : "bg-slate-800 text-slate-500 border border-slate-700"
)}>
{u.role === 'admin' ? <Shield size={16} /> : <User size={16} />}
</div>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-bold text-white group-hover:text-primary transition-colors">{u.username}</p>
<span className="text-[8px] font-black text-slate-500 opacity-60 bg-slate-800/50 px-1.5 py-0.5 rounded-md border border-slate-700/50 font-mono tracking-tighter">{u.role}</span>
</div>
</div>
</div>
<div className="flex items-center gap-1 transition-opacity pr-2">
<button
onClick={() => {
setEditingUser(u);
setEditUserForm({ username: u.username, password: '', role: u.role });
}}
title="Edit User"
className="p-2.5 bg-slate-800/50 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-all border border-slate-700/50"
>
<Edit2 size={14} />
</button>
{u.username !== 'Admin' && (
<button
onClick={() => handleDeleteUser(u.id, u.username)}
title="Delete User"
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"
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
))
)}
</div>
</div>
{/* enterprise Users Group */}
{users.some(u => u.origin === 'ldap') && (
<div className="space-y-4">
<div className="flex items-center justify-between px-4">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-indigo-500" />
<h3 className="text-sm font-black text-white">Enterprise (LDAP) Users</h3>
</div>
</div>
<div className="bg-indigo-500/5 border border-indigo-500/10 rounded-3xl overflow-hidden shadow-xl divide-y divide-indigo-500/10">
{users.filter(u => u.origin === 'ldap').map(u => (
<div key={u.id} className="flex items-center justify-between p-4 hover:bg-indigo-500/10 transition-all group">
<div className="flex items-center gap-4">
<div className={cn(
"p-2.5 rounded-xl",
u.role === 'admin' ? "bg-indigo-500/20 text-indigo-400 border border-indigo-400/20" : "bg-slate-800/50 text-slate-500 border border-slate-800"
)}>
<Shield size={16} />
</div>
<div>
<div className="flex items-center gap-3">
<p className="text-sm font-bold text-white">{u.username}</p>
<span className="text-[8px] bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 px-1.5 py-0.5 rounded-md font-black">Network Sync</span>
<span className="text-[8px] font-black text-slate-500 opacity-60 bg-slate-800/50 px-1.5 py-0.5 rounded-md border border-slate-700/50 font-mono tracking-tighter">{u.role}</span>
</div>
</div>
</div>
<div className="text-xs font-black text-indigo-400/50 pr-4 truncate max-w-[150px] italic">
Read Only Profile
</div>
</div>
))}
</div>
</div>
)}
</div>
</section>
{/* Category Management Section */}
<section className="space-y-6">
<div className="flex items-center justify-between px-2">
<div className="flex items-center gap-3">
<Tag size={20} className="text-primary" />
<h2 className="text-lg font-black text-white">Category Groups</h2>
</div>
<button
onClick={handleAddCategory}
className="flex items-center justify-center gap-2 bg-primary/10 hover:bg-primary text-primary hover:text-white font-black text-xs px-4 py-2 rounded-xl transition-all border border-primary/20 active:scale-95"
>
<Plus size={14} /> Add New Group
</button>
</div>
<div className="bg-slate-900/40 border border-slate-800/50 rounded-3xl overflow-hidden shadow-xl divide-y divide-slate-800/50">
{loading ? (
<div className="p-8 text-center animate-pulse text-slate-600 text-xs font-black">Loading Categories...</div>
) : (
categories.map(cat => (
<div key={cat.id} className="flex items-center justify-between p-4 hover:bg-slate-800/30 transition-all group">
<div className="flex items-center gap-4 min-w-0 flex-1">
<div className="p-2.5 bg-slate-800 text-slate-500 rounded-xl border border-slate-700 shadow-inner group-hover:text-primary transition-all shrink-0">
<Tag size={16} />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-white group-hover:text-primary transition-colors truncate">{cat.name}</p>
<p className="text-xs text-slate-500 font-medium truncate opacity-60">
{cat.description || 'General Purpose Group'}
</p>
</div>
</div>
<div className="flex items-center gap-1 transition-opacity ml-4 pr-2">
<button
onClick={() => {
setEditingCategory(cat);
setEditCatForm({ name: cat.name, description: cat.description || '' });
}}
title="Edit Category"
className="p-2.5 bg-slate-800/50 hover:bg-slate-700 text-slate-400 hover:text-white rounded-lg transition-all border border-slate-700/50"
>
<Edit2 size={14} />
</button>
<button
onClick={() => handleDeleteCategory(cat.id, cat.name)}
title="Delete Category"
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"
>
<Trash2 size={14} />
</button>
</div>
</div>
))
)}
{categories.length === 0 && !loading && (
<div className="p-8 text-center text-slate-600 text-xs font-black italic">
No categories defined
</div>
)}
</div>
</section>
{/* System Settings Section */}
<div className="pt-8 border-t border-slate-900">
<section className="p-8 bg-slate-900/50 rounded-[3rem] border border-slate-800 flex flex-col items-center text-center gap-6 shadow-inner">
<div className="w-16 h-16 bg-primary/10 rounded-3xl flex items-center justify-center text-primary border border-primary/20 shadow-lg shadow-primary/5">
<Database size={32} />
</div>
<div>
<h3 className="text-xl font-black text-white tracking-tight">System Integrity</h3>
<p className="text-xs text-slate-500 mt-3 max-w-[280px] mx-auto leading-relaxed">
Hybrid storage model active. Real-time synchronization between local memory and cloud-hosted SQLite.
</p>
</div>
<div className="flex items-center gap-3 bg-slate-950 px-6 py-2.5 rounded-full border border-slate-800 shadow-2xl">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.5)]" />
<span className="text-xs font-black text-green-500">Storage Online</span>
</div>
</section>
</div>
{/* Enterprise LDAP Integration */}
<section className="space-y-8 bg-slate-900/30 border border-slate-800/40 p-8 rounded-[3rem] shadow-2xl relative overflow-hidden group">
<div className="absolute top-0 right-0 p-8 opacity-5 group-hover:opacity-10 transition-opacity">
<Globe size={120} />
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 px-2 relative z-10">
<div className="flex items-center gap-4">
<div className="p-4 bg-indigo-500/10 rounded-2xl text-indigo-400 border border-indigo-500/20">
<Server size={24} />
</div>
<div>
<h2 className="text-xl font-black text-white">Enterprise Integration</h2>
<p className="text-xs text-slate-500 font-bold mt-1">LLDAP / Active Directory Connectivity</p>
</div>
</div>
<div className="flex items-center gap-3 bg-slate-950 p-2 rounded-2xl border border-slate-800">
<span className="text-xs font-black text-slate-400 px-3">LDAP Status</span>
<button
onClick={() => {
const newConfig = { ...ldapConfig, ldap_enabled: !ldapConfig.ldap_enabled };
setLdapConfig(newConfig);
inventoryApi.updateLdapConfig(newConfig);
toast.success(`LDAP ${newConfig.ldap_enabled ? 'Enabled' : 'Disabled'}`);
}}
className={cn(
"px-6 py-2 rounded-xl text-xs font-black transition-all",
ldapConfig.ldap_enabled ? "bg-green-500/10 text-green-500 border border-green-500/20" : "bg-slate-800 text-slate-500"
)}
>
{ldapConfig.ldap_enabled ? 'Active' : 'Offline'}
</button>
</div>
</div>
<div className="grid lg:grid-cols-2 gap-8 relative z-10">
<div className="space-y-6">
<div className="space-y-2">
<label className="text-xs font-black text-slate-500 px-1">Server URI</label>
<div className="relative group/input">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-600 group-focus-within/input:text-indigo-400 transition-colors">
<Wifi size={16} />
</div>
<input
type="text"
value={ldapConfig.server_uri || ''}
onChange={(e) => setLdapConfig({ ...ldapConfig, server_uri: e.target.value })}
placeholder="ldap://192.168.84.107:3890"
className="w-full bg-slate-950 border border-slate-800 focus:border-indigo-500/50 rounded-2xl py-4 pl-12 pr-5 text-white outline-none transition-all font-mono text-sm"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-black text-slate-500 px-1">Base DN (Suffix)</label>
<input
type="text"
value={ldapConfig.base_dn || ''}
onChange={(e) => setLdapConfig({ ...ldapConfig, base_dn: e.target.value })}
placeholder="dc=example,dc=com"
className="w-full bg-slate-950 border border-slate-800 focus:border-indigo-500/50 rounded-2xl py-4 px-5 text-white outline-none transition-all font-mono text-sm"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-black text-slate-500 px-1">User DN Template</label>
<input
type="text"
value={ldapConfig.user_template || ''}
onChange={(e) => setLdapConfig({ ...ldapConfig, user_template: e.target.value })}
placeholder="uid={username},ou=people,dc=..."
className="w-full bg-slate-950 border border-slate-800 focus:border-indigo-500/50 rounded-2xl py-4 px-5 text-white outline-none transition-all font-mono text-sm"
/>
</div>
</div>
<div className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between px-1">
<label className="text-xs font-black text-slate-500">Role Mappings</label>
<button
onClick={() => {
const newMappings = [...(ldapConfig.role_mappings || []), { group: '', role: 'user' }];
setLdapConfig({ ...ldapConfig, role_mappings: newMappings });
}}
className="text-xs font-black text-indigo-400 hover:text-indigo-300 transition-colors"
>
+ Add Mapping
</button>
</div>
<div className="space-y-2 max-h-[180px] overflow-y-auto pr-2 custom-scrollbar">
{(ldapConfig.role_mappings || []).map((mapping: any, idx: number) => (
<div key={idx} className="flex gap-2 items-center bg-slate-950/50 p-3 rounded-2xl border border-slate-800/50">
<input
type="text"
value={mapping.group}
onChange={(e) => {
const newMappings = [...ldapConfig.role_mappings];
newMappings[idx].group = e.target.value;
setLdapConfig({ ...ldapConfig, role_mappings: newMappings });
}}
placeholder="Group CN (e.g. admins)"
className="flex-1 bg-transparent text-xs font-mono text-white outline-none"
/>
<select
value={mapping.role}
onChange={(e) => {
const newMappings = [...ldapConfig.role_mappings];
newMappings[idx].role = e.target.value;
setLdapConfig({ ...ldapConfig, role_mappings: newMappings });
}}
className="bg-slate-900 text-xs font-bold text-slate-400 px-2 py-1 rounded-lg border border-slate-700 outline-none"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<button
onClick={() => {
const newMappings = ldapConfig.role_mappings.filter((_: any, i: number) => i !== idx);
setLdapConfig({ ...ldapConfig, role_mappings: newMappings });
}}
className="text-slate-600 hover:text-red-400 p-1"
>
<X size={14} />
</button>
</div>
))}
{(ldapConfig.role_mappings || []).length === 0 && (
<div className="text-center py-4 border border-dashed border-slate-800 rounded-2xl text-xs text-slate-600">
No roles mapped
</div>
)}
</div>
</div>
<div className="p-6 bg-slate-950 rounded-3xl border border-slate-800 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Lock size={16} className="text-slate-500" />
<span className="text-sm font-bold text-slate-300">Encrypted Connection (TLS)</span>
</div>
<button
onClick={() => setLdapConfig({ ...ldapConfig, use_tls: !ldapConfig.use_tls })}
className={cn(
"w-12 h-6 rounded-full relative transition-all",
ldapConfig.use_tls ? "bg-indigo-500" : "bg-slate-800"
)}
>
<div className={cn(
"absolute top-1 w-4 h-4 bg-white rounded-full transition-all",
ldapConfig.use_tls ? "right-1" : "left-1"
)} />
</button>
</div>
<div className="pt-4 border-t border-slate-900 flex gap-4">
<button
onClick={async () => {
setTestingLdap(true);
try {
const res = await inventoryApi.testLdapConnection(ldapConfig);
if (res.status === 'success') {
toast.success("Connection Successful!");
} else {
toast.error(res.message);
}
} catch (err) {
toast.error("Network Error during test");
} finally {
setTestingLdap(false);
}
}}
disabled={testingLdap}
className="flex-1 bg-slate-900 hover:bg-slate-800 text-xs font-black py-4 rounded-xl border border-slate-800 transition-all flex items-center justify-center gap-2"
>
{testingLdap ? <div className="w-4 h-4 border-2 border-indigo-400 border-t-transparent animate-spin rounded-full" /> : <Wifi size={14} />}
Test Link
</button>
<button
onClick={async () => {
await inventoryApi.updateLdapConfig(ldapConfig);
toast.success("Settings applied");
}}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 shadow-lg shadow-indigo-900/20 text-white text-xs font-black py-4 rounded-xl transition-all"
>
Save Changes
</button>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-indigo-500/5 rounded-2xl border border-indigo-500/10">
<AlertTriangle size={16} className="text-indigo-400 shrink-0 mt-0.5" />
<p className="text-xs text-indigo-300/60 leading-normal italic">
LLDAP usually serves LDAP on port 3890. Ensure your firewall allows traffic on this port between the inventory server and 192.168.84.107.
</p>
</div>
</div>
</div>
</section>
{/* Edit User Modal */}
{editingUser && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-xl animate-in fade-in duration-300">
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-8 max-w-md w-full shadow-2xl space-y-8">
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="p-3 bg-primary/10 rounded-xl text-primary border border-primary/20">
<User size={20} />
</div>
<h3 className="text-xl font-black text-white tracking-tight">Edit Profile</h3>
</div>
<button onClick={() => setEditingUser(null)} className="p-2 hover:bg-slate-800 rounded-lg text-slate-500 transition-colors">
<X size={20} />
</button>
</div>
<div className="space-y-6">
<div className="space-y-2">
<label className="text-xs font-black text-slate-500 px-1">Username</label>
<input
type="text"
disabled={editingUser.username === 'Admin'}
value={editUserForm.username}
onChange={(e) => setEditUserForm({ ...editUserForm, username: e.target.value })}
className="w-full bg-slate-950 border border-slate-800 focus:border-primary rounded-2xl py-4 px-5 text-white outline-none transition-all disabled:opacity-50"
/>
{editingUser.username === 'Admin' && <p className="text-xs text-amber-500/60 font-bold px-1 italic">Default Admin name is protected</p>}
</div>
<div className="space-y-2">
<label className="text-xs font-black text-slate-500 px-1">New Password (leave empty to keep current)</label>
<input
type="password"
placeholder="••••••••"
value={editUserForm.password}
onChange={(e) => setEditUserForm({ ...editUserForm, password: e.target.value })}
className="w-full bg-slate-950 border border-slate-800 focus:border-primary rounded-2xl py-4 px-5 text-white outline-none transition-all"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-black text-slate-500 px-1">Access Role</label>
<select
value={editUserForm.role}
onChange={(e) => setEditUserForm({ ...editUserForm, role: e.target.value })}
className="w-full bg-slate-950 border border-slate-800 focus:border-primary rounded-2xl py-4 px-5 text-white outline-none transition-all appearance-none"
>
<option value="user">USER (Standard)</option>
<option value="admin">ADMIN (Root Access)</option>
</select>
</div>
<button
onClick={handleUpdateUserSubmit}
className="w-full bg-primary text-white font-black py-5 rounded-2xl shadow-xl shadow-primary/20 hover:scale-[1.02] active:scale-95 transition-all"
>
Save Profile Changes
</button>
</div>
</div>
</div>
)}
{/* Edit Category Modal */}
{editingCategory && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-xl animate-in fade-in duration-300">
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-8 max-w-md w-full shadow-2xl space-y-8">
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="p-3 bg-primary/10 rounded-xl text-primary border border-primary/20">
<Tag size={20} />
</div>
<h3 className="text-xl font-black text-white tracking-tight">Edit Group</h3>
</div>
<button onClick={() => setEditingCategory(null)} className="p-2 hover:bg-slate-800 rounded-lg text-slate-500 transition-colors">
<X size={20} />
</button>
</div>
<div className="space-y-6">
<div className="space-y-2">
<label className="text-xs font-black text-slate-500 px-1">Group Name</label>
<input
type="text"
value={editCatForm.name}
onChange={(e) => setEditCatForm({ ...editCatForm, name: e.target.value })}
className="w-full bg-slate-950 border border-slate-800 focus:border-primary rounded-2xl py-4 px-5 text-white outline-none transition-all"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-black text-slate-500 px-1">Description</label>
<textarea
value={editCatForm.description}
onChange={(e) => setEditCatForm({ ...editCatForm, description: e.target.value })}
className="w-full bg-slate-950 border border-slate-800 focus:border-primary rounded-2xl py-4 px-5 text-white outline-none transition-all h-32 resize-none"
/>
</div>
<button
onClick={handleUpdateCategorySubmit}
className="w-full bg-primary text-white font-black py-5 rounded-2xl shadow-xl shadow-primary/20 hover:scale-[1.02] active:scale-95 transition-all"
>
Save Group Changes
</button>
</div>
</div>
</div>
)}
</main>
</PageShell>
);
}

View File

@@ -0,0 +1,535 @@
'use client';
import { useState, useEffect } from 'react';
import { db, Item } from '@/lib/db';
import { inventoryApi } from '@/lib/api';
import PageShell from '@/components/PageShell';
import { toast } from 'react-hot-toast';
import {
Package,
ChevronRight,
ChevronDown,
BarChart3,
Layers,
Plus,
Minus,
Trash2,
X,
AlertTriangle,
Tag,
Edit2
} from 'lucide-react';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export default function InventoryPage() {
const [mounted, setMounted] = useState(false);
const [inventory, setInventory] = useState<Item[]>([]);
const [stats, setStats] = useState<any>(null);
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [currentUser, setCurrentUser] = useState<any | null>(null);
// Stock Adjustment State
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
const [adjustQty, setAdjustQty] = useState<number>(1);
const [adjustType, setAdjustType] = useState<'ADD' | 'REMOVE' | 'TRASH'>('ADD');
const [trashReason, setTrashReason] = useState('Damaged');
// Item Editing state
const [isEditing, setIsEditing] = useState(false);
const [editedItem, setEditedItem] = useState<Partial<Item>>({});
const [categoriesList, setCategoriesList] = useState<any[]>([]);
// Category Editing state
const [editingCategory, setEditingCategory] = useState<any | null>(null);
const [catEditedName, setCatEditedName] = useState('');
const [catEditedDesc, setCatEditedDesc] = useState('');
useEffect(() => {
setMounted(true);
const savedUser = localStorage.getItem('inventory_user');
if (savedUser) {
setCurrentUser(JSON.parse(savedUser));
}
loadData();
}, []);
const loadData = async () => {
// Load local items
const cached = await db.items.toArray();
setInventory(cached);
try {
// Load backend stats
const s = await inventoryApi.getStats();
setStats(s);
const cats = await inventoryApi.getCategories();
setCategoriesList(cats);
// Load fresh items
const res = await inventoryApi.getItems();
setInventory(res);
// Sync local DB
await db.items.clear();
await db.items.bulkAdd(res);
} catch (err) {
console.error("Failed to load backend data", err);
}
};
const handleAdjustStock = async () => {
if (!selectedItem) return;
const toastId = toast.loading("Processing...");
try {
const isOnline = navigator.onLine;
const finalAdjustQty = adjustQty;
const newQty = selectedItem.quantity + (adjustType === 'ADD' ? adjustQty : -adjustQty);
// Local Update
await db.items.update(selectedItem.id!, { quantity: newQty });
setInventory(prev => prev.map(i => i.id === selectedItem.id ? { ...i, quantity: newQty } : i));
if (isOnline) {
const endpoint = adjustType === 'ADD' ? 'check-in' : (adjustType === 'TRASH' ? 'trash' : 'check-out');
const payload: any = {
barcode: selectedItem.barcode,
quantity: finalAdjustQty,
user_id: currentUser?.id || 1
};
if (adjustType === 'TRASH') payload.reason = trashReason;
await inventoryApi.adjustStock(endpoint, payload);
toast.success("Inventory updated & synced", { id: toastId });
} else {
await db.pendingOperations.add({
type: adjustType === 'ADD' ? 'CHECK_IN' : (adjustType === 'TRASH' ? 'TRASH' : 'CHECK_OUT'),
barcode: selectedItem.barcode,
quantity: finalAdjustQty,
timestamp: Date.now(),
synced: 0,
uuid: crypto.randomUUID()
} as any);
toast.success("Saved locally (Offline)", { id: toastId });
}
setSelectedItem(null);
setAdjustQty(1);
} catch (error: any) {
console.error("Adjustment failure:", error);
toast.error("Error saving operation", { id: toastId });
}
};
const handleUpdateItem = async () => {
if (!selectedItem) return;
try {
const updated = { ...selectedItem, ...editedItem };
if (updated.part_number) updated.part_number = updated.part_number.toUpperCase();
await db.items.update(selectedItem.id!, updated);
if (navigator.onLine) {
await inventoryApi.updateItem(selectedItem.id!, updated);
}
toast.success("Item updated successfully");
setIsEditing(false);
setSelectedItem(updated as Item);
await loadData();
} catch (err) {
console.error(err);
toast.error("Failed to update item");
}
};
const handleDeleteItem = async () => {
if (!selectedItem || !selectedItem.id) return;
if (!window.confirm(`Are you sure you want to delete "${selectedItem.name}"?`)) return;
try {
await db.items.delete(selectedItem.id);
if (navigator.onLine) {
await inventoryApi.deleteItem(selectedItem.id);
}
toast.success("Item deleted");
setSelectedItem(null);
await loadData();
} catch (err) {
console.error(err);
toast.error("Failed to delete item");
}
};
const handleUpdateCategory = async () => {
if (!editingCategory) return;
try {
await inventoryApi.updateCategory(editingCategory.id, {
name: catEditedName,
description: catEditedDesc
});
toast.success("Category updated");
setEditingCategory(null);
await loadData();
} catch (err) {
toast.error("Update failed");
}
};
// Group items by category
const categories = Array.from(new Set(inventory.map(i => i.category)));
const filteredCategories = categories.filter(c =>
c.toLowerCase().includes(searchQuery.toLowerCase()) ||
inventory.some(i => i.category === c && i.name.toLowerCase().includes(searchQuery.toLowerCase()))
);
if (!mounted) return null;
return (
<PageShell>
<div className="p-3 md:p-8 max-w-4xl mx-auto space-y-6">
<header className="max-w-4xl mx-auto w-full mb-8">
<h1 className="text-2xl font-black flex items-center gap-3">
<Package className="text-primary" size={28} />
Inventory Catalog
</h1>
<p className="text-sm text-slate-500 mt-1">Detailed view of all stock items by category</p>
</header>
<div className="max-w-4xl mx-auto w-full space-y-8">
{/* Stats Dashboard */}
<section className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-3xl">
<div className="w-8 h-8 rounded-xl bg-primary/10 text-primary flex items-center justify-center mb-3">
<Layers size={18} />
</div>
<p className="text-[10px] font-black uppercase tracking-widest text-slate-500">Categories</p>
<p className="text-2xl font-black mt-1">{stats?.total_categories || categories.length}</p>
</div>
<div className="bg-slate-900/50 border border-slate-800 p-4 rounded-3xl">
<div className="w-8 h-8 rounded-xl bg-green-500/10 text-green-500 flex items-center justify-center mb-3">
<Package size={18} />
</div>
<p className="text-[10px] font-black uppercase tracking-widest text-slate-500">Item Types</p>
<p className="text-2xl font-black mt-1">{stats?.total_items || inventory.length}</p>
</div>
</section>
{/* Search */}
<div className="relative">
<input
type="text"
placeholder="Search categories or items..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-slate-900 border border-slate-800 rounded-2xl py-4 pl-12 pr-4 text-sm focus:border-primary outline-none transition-all"
/>
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-600">
🔍
</div>
</div>
{/* Categorized List (Accordion) */}
<section className="space-y-3">
{filteredCategories.map(cat => (
<div key={cat} className="bg-slate-900/40 border border-slate-800/50 rounded-3xl overflow-hidden active:scale-[0.995] transition-all">
<div
className="w-full p-5 flex items-center justify-between hover:bg-slate-900/60 transition-colors cursor-pointer"
onClick={() => setExpandedCategory(expandedCategory === cat ? null : cat)}
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-slate-800 flex items-center justify-center text-slate-400 group-hover:text-primary transition-colors">
<Tag size={20} />
</div>
<div className="text-left">
<h3 className="font-bold text-lg">{cat}</h3>
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest">
{inventory.filter(i => i.category === cat).length} Item types in stock
</p>
</div>
</div>
<div className="flex items-center gap-3">
{expandedCategory === cat ? <ChevronDown size={20} className="text-primary" /> : <ChevronRight size={20} className="text-slate-600" />}
<button
onClick={(e) => {
e.stopPropagation();
const categoryObj = categoriesList.find(c => c.name === cat);
if (categoryObj) {
setEditingCategory(categoryObj);
setCatEditedName(categoryObj.name);
setCatEditedDesc(categoryObj.description || '');
}
}}
className="p-2 hover:bg-slate-800 rounded-full text-slate-500 hover:text-primary transition-colors relative z-10"
>
<Edit2 size={16} />
</button>
</div>
</div>
{expandedCategory === cat && (
<div className="p-4 pt-0 space-y-2 animate-in slide-in-from-top-4 duration-300">
<div className="h-px bg-slate-800/50 mb-4 mx-2" />
{inventory
.filter(i => i.category === cat)
.filter(i => i.name.toLowerCase().includes(searchQuery.toLowerCase()))
.map(item => (
<div
key={item.id}
onClick={() => setSelectedItem(item)}
className="bg-slate-950/40 border border-slate-800/50 p-4 rounded-2xl flex items-center justify-between hover:border-primary/40 cursor-pointer transition-all active:scale-[0.98]"
>
<div className="flex-1 min-w-0 pr-4">
<h4 className="font-bold text-slate-200 truncate">{item.name}</h4>
<p className="text-[10px] text-slate-500 truncate mt-0.5">{item.specs}</p>
</div>
<div className="text-right shrink-0">
<span className={cn(
"text-lg font-black",
item.quantity <= item.min_quantity ? "text-amber-500" : "text-primary"
)}>
{item.quantity}
</span>
<p className="text-[8px] text-slate-600 uppercase tracking-widest font-black">Stock</p>
</div>
</div>
))}
</div>
)}
</div>
))}
{filteredCategories.length === 0 && (
<div className="py-20 text-center text-slate-600">
<Package size={48} className="mx-auto mb-4 opacity-10" />
<p className="font-medium">No results found</p>
</div>
)}
</section>
</div>
{/* Stock Adjustment / Edit Item Overlay */}
{selectedItem && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="w-full max-w-lg bg-slate-900 border border-slate-800 rounded-[2.5rem] shadow-2xl p-6 overflow-hidden animate-in slide-in-from-bottom-10 duration-300">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black tracking-tight">
{isEditing ? "Edit Item" : selectedItem.name}
</h3>
<div className="flex gap-2">
{!isEditing && (
<button
onClick={() => {
setEditedItem(selectedItem);
setIsEditing(true);
}}
className="p-2 hover:bg-slate-800 rounded-full text-slate-400"
>
<Edit2 size={20} />
</button>
)}
{isEditing && (
<button
onClick={handleDeleteItem}
className="p-2 hover:bg-red-500/20 rounded-full text-red-500"
>
<Trash2 size={20} />
</button>
)}
<button
onClick={() => {
setSelectedItem(null);
setIsEditing(false);
setAdjustQty(1);
}}
className="p-2 hover:bg-slate-800 rounded-full"
>
<X size={20} />
</button>
</div>
</div>
{isEditing ? (
<div className="space-y-4 mb-8">
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Name</label>
<input
type="text"
value={editedItem.name || ''}
onChange={e => setEditedItem({...editedItem, name: e.target.value})}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100 placeholder:text-slate-700"
/>
</div>
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Part Number</label>
<input
type="text"
value={editedItem.part_number || ''}
onChange={e => setEditedItem({...editedItem, part_number: e.target.value})}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-mono outline-none text-slate-100 placeholder:text-slate-700 uppercase"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Category</label>
<select
value={editedItem.category || ''}
onChange={e => setEditedItem({...editedItem, category: e.target.value})}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100"
>
<option value="">Select Category</option>
{categoriesList.map(c => (
<option key={c.id} value={c.name}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Item Type</label>
<input
type="text"
value={editedItem.type || ''}
onChange={e => setEditedItem({...editedItem, type: e.target.value})}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100"
/>
</div>
</div>
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Specs / Comments</label>
<input
type="text"
value={editedItem.specs || ''}
onChange={e => setEditedItem({...editedItem, specs: e.target.value})}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100"
/>
</div>
</div>
) : (
<>
<div className="flex p-1 bg-slate-950 rounded-2xl mb-8">
{[
{ id: 'ADD', label: 'Buy More', icon: Plus, color: 'text-primary' },
{ id: 'REMOVE', label: 'Subtract', icon: Minus, color: 'text-amber-500' },
{ id: 'TRASH', label: 'Discard', icon: Trash2, color: 'text-red-500' }
].map((t) => (
<button
key={t.id}
onClick={() => setAdjustType(t.id as any)}
className={cn(
"flex-1 flex flex-col items-center py-3 rounded-xl transition-all",
adjustType === t.id ? "bg-slate-800 shadow-lg" : "text-slate-500"
)}
>
<t.icon size={20} className={adjustType === t.id ? t.color : ""} />
<span className="text-[10px] font-black uppercase tracking-widest mt-1">{t.label}</span>
</button>
))}
</div>
<div className="flex flex-col items-center gap-6 mb-8">
<div className="flex items-center gap-8">
<button
onClick={() => setAdjustQty(Math.max(1, adjustQty - 1))}
className="w-12 h-12 rounded-full border border-slate-800 flex items-center justify-center hover:bg-slate-800 transition-colors"
>
<Minus size={24} />
</button>
<span className="text-5xl font-black tabular-nums">{adjustQty}</span>
<button
onClick={() => setAdjustQty(adjustQty + 1)}
className="w-12 h-12 rounded-full border border-slate-800 flex items-center justify-center hover:bg-slate-800 transition-colors"
>
<Plus size={24} />
</button>
</div>
{adjustType === 'TRASH' && (
<div className="w-full bg-red-500/5 border border-red-500/20 p-4 rounded-2xl">
<select
value={trashReason}
onChange={(e) => setTrashReason(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-300"
>
<option>Damaged</option>
<option>Expired</option>
<option>Lost</option>
<option>Technical Failure</option>
</select>
</div>
)}
</div>
</>
)}
<button
onClick={isEditing ? handleUpdateItem : handleAdjustStock}
className={cn(
"w-full py-5 rounded-[1.8rem] font-black text-lg transition-all active:scale-[0.98] shadow-xl",
isEditing ? "bg-white text-slate-950 shadow-white/10" : (
adjustType === 'ADD' ? "bg-primary text-white shadow-primary/20" :
adjustType === 'REMOVE' ? "bg-amber-600 text-white shadow-amber-600/20" :
"bg-red-600 text-white shadow-red-600/20"
)
)}
>
{isEditing ? "Save Changes" : (
adjustType === 'ADD' ? `Add ${adjustQty} to Stock` :
adjustType === 'REMOVE' ? `Subtract ${adjustQty} from Stock` :
`Discard ${adjustQty} items`
)}
</button>
</div>
</div>
)}
{/* Category Edit Overlay */}
{editingCategory && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-xl animate-in fade-in duration-300">
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-8 max-w-sm w-full shadow-2xl space-y-6 animate-in zoom-in-95 duration-200">
<div className="flex justify-between items-center">
<h2 className="text-xl font-black uppercase tracking-tight">Edit Category</h2>
<button onClick={() => setEditingCategory(null)} className="p-2 hover:bg-slate-800 rounded-full transition-colors text-slate-400">
<X size={20} />
</button>
</div>
<div className="space-y-4">
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Name</label>
<input
type="text"
value={catEditedName}
onChange={e => setCatEditedName(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100"
/>
</div>
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Description</label>
<textarea
value={catEditedDesc}
onChange={e => setCatEditedDesc(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100 h-24 resize-none"
/>
</div>
</div>
<button
onClick={handleUpdateCategory}
className="w-full bg-primary text-white font-black uppercase tracking-widest py-4 rounded-2xl shadow-xl shadow-primary/20 hover:scale-[1.02] active:scale-95 transition-all"
>
Update Category
</button>
</div>
</div>
)}
</div>
</PageShell>
);
}

218
frontend/app/login/page.tsx Normal file
View File

@@ -0,0 +1,218 @@
'use client';
import { useState, useEffect, useRef, memo } from 'react';
import { User, Shield, X, Lock, ChevronRight } from 'lucide-react';
import { inventoryApi } from '@/lib/api';
import { toast, Toaster } from 'react-hot-toast';
import { useRouter } from 'next/navigation';
export default function LoginPage() {
const [mounted, setMounted] = useState(false);
const [users, setUsers] = useState<any[]>([]);
const [selectedUserForLogin, setSelectedUserForLogin] = useState<any | null>(null);
const [isEnterprise, setIsEnterprise] = useState(false);
const router = useRouter();
const enterpriseUserRef = useRef<HTMLInputElement>(null);
const enterprisePassRef = useRef<HTMLInputElement>(null);
const localPassRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setMounted(true);
inventoryApi.getUsers().then(setUsers).catch(() => {});
// If already logged in, go home
if (localStorage.getItem('inventory_user')) {
router.push('/');
}
}, [router]);
const handleLogin = async () => {
let username = "";
let password = "";
if (isEnterprise) {
username = enterpriseUserRef.current?.value || "";
password = enterprisePassRef.current?.value || "";
} else if (selectedUserForLogin) {
username = selectedUserForLogin.username;
password = localPassRef.current?.value || "";
}
if (!username) {
toast.error("Username is required");
return;
}
try {
const user = await inventoryApi.login({
username,
password
});
localStorage.setItem('inventory_user', JSON.stringify(user));
toast.success(`Welcome back, ${user.username}`);
// Delay slightly to show toast then redirect
setTimeout(() => {
router.push('/');
}, 500);
} catch (error) {
toast.error(isEnterprise ? "Login failed. Check credentials or group membership." : "Invalid password");
}
};
const handleSelectUser = async (user: any) => {
if (user.username === 'Admin' || user.id > 1) {
setSelectedUserForLogin(user);
return;
}
// Auto-login for passwordless users (if any exist beyond Admin)
localStorage.setItem('inventory_user', JSON.stringify(user));
router.push('/');
};
if (!mounted) return null;
return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
<Toaster position="top-center" />
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-8 max-w-sm w-full shadow-2xl space-y-8 animate-in fade-in zoom-in duration-500">
<div className="text-center space-y-2">
<div className="w-16 h-16 bg-primary/10 text-primary rounded-2xl flex items-center justify-center mx-auto mb-4 border border-primary/20">
<User size={32} />
</div>
<h2 className="text-2xl font-black text-white tracking-tight">Identity Check</h2>
<p className="text-slate-500 text-sm">Select operator profile or use enterprise login</p>
</div>
<div className="grid gap-3">
{!selectedUserForLogin && !isEnterprise ? (
<>
{users.map(user => (
<button
key={user.id}
onClick={() => handleSelectUser(user)}
className="bg-slate-800/50 hover:bg-slate-800 border border-slate-800 hover:border-primary/40 p-4 rounded-2xl text-left transition-all group flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-slate-900 border border-slate-700 flex items-center justify-center text-slate-500 group-hover:text-primary transition-colors">
{user.role === 'admin' ? <Shield size={14} /> : <User size={14} />}
</div>
<div>
<p className="text-white font-black text-sm">{user.username}</p>
<p className="text-xs text-slate-500 font-bold mt-1">{user.role}</p>
</div>
</div>
<ChevronRight size={16} className="text-slate-600 group-hover:text-primary transition-colors" />
</button>
))}
<div className="pt-2">
<button
onClick={() => setIsEnterprise(true)}
className="w-full flex items-center justify-center gap-2 py-4 rounded-2xl border border-dashed border-slate-700 text-slate-500 hover:text-primary hover:border-primary/40 transition-all font-bold text-xs"
>
<Shield size={14} />
Enterprise Login
</button>
</div>
</>
) : isEnterprise ? (
<div className="space-y-4 animate-in slide-in-from-right-4 duration-300">
<div className="flex justify-between items-center px-1">
<p className="text-xs font-black text-slate-500">Enterprise Account</p>
<button
onClick={() => setIsEnterprise(false)}
className="text-xs font-black text-primary hover:underline"
>
Back to profiles
</button>
</div>
<div className="space-y-2">
<label className="text-xs font-black text-slate-500 px-1">Username</label>
<div className="relative">
<User className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
<input
ref={enterpriseUserRef}
type="text"
autoFocus
className="w-full bg-slate-800/50 border border-slate-800 focus:border-primary rounded-2xl py-4 pl-12 pr-4 text-white focus:outline-none transition-all placeholder:text-slate-700 font-mono"
placeholder="e.g. jsmith"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-black text-slate-500 px-1">Password</label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
<input
ref={enterprisePassRef}
type="password"
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
className="w-full bg-slate-800/50 border border-slate-800 focus:border-primary rounded-2xl py-4 pl-12 pr-4 text-white focus:outline-none transition-all placeholder:text-slate-700 font-mono"
placeholder="Enter password"
/>
</div>
</div>
<button
onClick={handleLogin}
className="w-full bg-primary text-white font-black py-4 rounded-2xl shadow-xl shadow-primary/20 active:scale-95 transition-all"
>
Sign In
</button>
</div>
) : (
<div className="space-y-4 animate-in slide-in-from-right-4 duration-300">
<div className="bg-slate-800/30 p-4 rounded-2xl border border-slate-800 flex items-center gap-3">
<button
onClick={() => setSelectedUserForLogin(null)}
className="p-1 hover:bg-slate-700 rounded-lg transition-colors"
>
<X size={16} className="text-slate-400" />
</button>
<div>
<p className="text-sm font-bold text-slate-500">Logging in as</p>
<p className="text-white font-black">{selectedUserForLogin.username}</p>
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-black text-slate-500 px-1">Password</label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
<input
ref={localPassRef}
type="password"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
className="w-full bg-slate-800/50 border border-slate-800 focus:border-primary rounded-2xl py-4 pl-12 pr-4 text-white focus:outline-none transition-all placeholder:text-slate-700 font-mono"
placeholder="Enter password"
/>
</div>
</div>
<button
onClick={handleLogin}
className="w-full bg-primary text-white font-black py-4 rounded-2xl shadow-xl shadow-primary/20 active:scale-95 transition-all"
>
Verify Identity
</button>
</div>
)}
</div>
{users.length === 0 && (
<div className="text-center p-8 text-slate-500 animate-pulse">
Initializing users...
</div>
)}
</div>
</div>
);
}

138
frontend/app/logs/page.tsx Normal file
View File

@@ -0,0 +1,138 @@
'use client';
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 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { fetchAndCacheItems } from '@/lib/sync';
export default function LogsPage() {
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [inventory, setInventory] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
// Load inventory for name lookups
const cached = await db.items.toArray();
setInventory(cached);
// Fetch fresh logs
const logs = await inventoryApi.getAuditLogs(50);
setAuditLogs(logs);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const filteredLogs = auditLogs.filter(log => {
const itemName = inventory.find(i => i.id === log.target_item_id)?.name || '';
return (
itemName.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.action.toLowerCase().includes(searchQuery.toLowerCase()) ||
(log.username || '').toLowerCase().includes(searchQuery.toLowerCase())
);
});
return (
<PageShell>
<main className="p-4 md:p-8 max-w-4xl mx-auto space-y-8">
<header className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="p-3 bg-primary/10 rounded-2xl text-primary border border-primary/20">
<History size={24} />
</div>
<div>
<h1 className="text-2xl font-black tracking-tight text-white uppercase">Audit History</h1>
<p className="text-xs text-slate-500 font-bold tracking-widest uppercase">Centralized Transaction Logs</p>
</div>
</div>
<div className="relative group">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-primary transition-colors" size={18} />
<input
type="text"
placeholder="Search logs by item, action or user..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-slate-900/50 border border-slate-800 focus:border-primary/50 rounded-2xl py-4 pl-12 pr-4 text-sm text-white placeholder:text-slate-600 outline-none transition-all shadow-inner"
/>
</div>
</header>
<section className="space-y-4">
{loading ? (
<div className="flex flex-col items-center justify-center p-20 text-slate-500 animate-pulse">
<History size={48} className="opacity-20 mb-4" />
<p className="text-xs font-black uppercase tracking-widest">Retrieving logs from cloud...</p>
</div>
) : filteredLogs.length === 0 ? (
<div className="bg-slate-900/30 border border-slate-800 border-dashed rounded-[2.5rem] p-16 flex flex-col items-center justify-center text-center gap-4">
<div className="w-16 h-16 bg-slate-800 rounded-full flex items-center justify-center text-slate-600">
<Search size={32} />
</div>
<div>
<p className="text-lg font-bold text-slate-300">No transactions found</p>
<p className="text-sm text-slate-500">Try a different search term or check back later</p>
</div>
</div>
) : (
<div className="grid gap-3">
{filteredLogs.map((log) => (
<div key={log.id} className="bg-slate-900/40 border border-slate-800/50 p-5 rounded-3xl flex items-center justify-between gap-4 hover:bg-slate-900/60 transition-colors group">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<div className={cn(
"text-[9px] font-black uppercase tracking-[0.2em] px-2 py-0.5 rounded-md",
log.action.includes('CHECK_IN') ? "bg-green-500/10 text-green-500" :
(log.action.includes('TRASH') ? "bg-rose-500/10 text-rose-500" : "bg-amber-500/10 text-amber-500")
)}>
{log.action}
</div>
<span className="text-[10px] font-bold text-slate-600 uppercase tracking-widest">by</span>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{log.username || 'System'}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-base font-bold text-slate-100 group-hover:text-primary transition-colors">
{inventory.find(i => i.id === log.target_item_id)?.name || `Item #${log.target_item_id}`}
</span>
</div>
<div className="flex items-center gap-4 mt-3">
<div className="text-[10px] text-slate-600 font-mono flex items-center gap-1.5">
<div className="w-1 h-1 rounded-full bg-slate-700" />
{new Date(log.timestamp).toLocaleString()}
</div>
{log.details && (
<span className="text-[9px] bg-slate-800/50 text-slate-500 px-3 py-1 rounded-full border border-slate-700/50 font-mono">
{log.details}
</span>
)}
</div>
</div>
<div className="text-right">
<p className={cn(
"text-2xl font-black tabular-nums group-hover:scale-110 transition-transform",
log.quantity_change > 0 ? "text-green-400" : "text-rose-400"
)}>
{log.quantity_change > 0 ? '+' : ''}{log.quantity_change}
</p>
</div>
</div>
))}
</div>
)}
</section>
</main>
</PageShell>
);
}

View File

@@ -6,7 +6,8 @@ import { inventoryApi } from '@/lib/api';
import { fetchAndCacheItems, syncOfflineOperations } from '@/lib/sync';
import Scanner from '@/components/Scanner';
import AIOnboarding from '@/components/AIOnboarding';
import { toast, Toaster } from 'react-hot-toast';
import PageShell from '@/components/PageShell';
import { toast } from 'react-hot-toast';
import {
Package,
Plus,
@@ -65,14 +66,7 @@ export default function Home() {
const [lastScanned, setLastScanned] = useState<string | null>(null);
const [inventory, setInventory] = useState<Item[]>([]);
const [syncing, setSyncing] = useState(false);
const [showLogs, setShowLogs] = useState(false);
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [users, setUsers] = useState<any[]>([]);
const [currentUser, setCurrentUser] = useState<any | null>(null);
const [showUserSelect, setShowUserSelect] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [selectedUserForLogin, setSelectedUserForLogin] = useState<any | null>(null);
const [loginPassword, setLoginPassword] = useState('');
const [categories, setCategories] = useState<any[]>([]);
useEffect(() => {
@@ -80,66 +74,12 @@ export default function Home() {
const savedUser = localStorage.getItem('inventory_user');
if (savedUser) {
setCurrentUser(JSON.parse(savedUser));
} else {
setShowUserSelect(true);
}
// Initial users and categories fetch
inventoryApi.getUsers().then(u => setUsers(u)).catch(() => {});
// Initial categories fetch
inventoryApi.getCategories().then(c => setCategories(c)).catch(() => {});
}, []);
const handleSelectUser = async (user: any) => {
// If user has a password (Admin always has one), prompt for it
if (user.username === 'Admin' || user.id > 1) { // Assume others might need it too
setSelectedUserForLogin(user);
setLoginPassword('');
return;
}
// Auto-login for passwordless users (if any)
setCurrentUser(user);
localStorage.setItem('inventory_user', JSON.stringify(user));
setShowUserSelect(false);
toast.success(`Welcome back, ${user.username}`);
};
const handleLogin = async () => {
if (!selectedUserForLogin) return;
try {
const user = await inventoryApi.login({
username: selectedUserForLogin.username,
password: loginPassword
});
setCurrentUser(user);
localStorage.setItem('inventory_user', JSON.stringify(user));
setShowUserSelect(false);
setSelectedUserForLogin(null);
setLoginPassword('');
toast.success(`Authenticated: ${user.username}`);
} catch (error) {
toast.error("Invalid password");
}
};
const fetchLogs = async () => {
if (!isOnline) return;
try {
const logs = await inventoryApi.getAuditLogs(30);
setAuditLogs(logs);
} catch (err) {
console.error(err);
}
};
const toggleLogs = async () => {
if (!showLogs) {
await fetchLogs();
}
setShowLogs(!showLogs);
};
useEffect(() => {
setMounted(true);
setIsOnline(navigator.onLine);
@@ -428,8 +368,8 @@ export default function Home() {
if (!mounted) return null;
return (
<main className="min-h-screen bg-slate-950 text-slate-100 p-3 pb-48 md:p-8 overflow-x-hidden w-full">
<Toaster position="top-center" />
<PageShell>
<div className="p-3 md:p-8 overflow-x-hidden w-full">
{/* Header */}
<header className="flex flex-col sm:flex-row justify-between sm:items-center gap-4 mb-6 w-full max-w-4xl mx-auto px-1">
@@ -446,13 +386,12 @@ export default function Home() {
TFM <span className="font-mono text-primary bg-primary/5 px-2 py-0.5 rounded-lg border border-primary/10 ml-1">aInventory</span>
</h1>
<div className="flex items-center gap-2 mt-1">
<p className="text-[9px] font-bold text-slate-500 tracking-[0.2em] font-mono">
VERSION {versionData.version}
<p className="text-xs font-bold text-slate-500 font-mono">
Version {versionData.version}
</p>
<span className="w-1 h-1 rounded-full bg-slate-800" />
<button
onClick={() => setShowUserSelect(true)}
className="text-[9px] font-black text-primary/80 hover:text-primary transition-colors flex items-center gap-1"
className="text-xs font-black text-primary/80 hover:text-primary transition-colors flex items-center gap-1"
>
<User size={8} />
{currentUser?.username || 'Guest'}
@@ -466,15 +405,15 @@ export default function Home() {
{isScannerReady && (
<div className="flex items-center gap-1.5">
<div className="w-1 h-1 rounded-full bg-green-500 shadow-[0_0_5px_rgba(34,197,94,0.5)]" />
<span className="text-[9px] font-black text-green-500/80 uppercase tracking-widest whitespace-nowrap">
OFFLINE SCAN: OK
<span className="text-xs font-black text-green-500/80 whitespace-nowrap">
Offline Scan: OK
</span>
</div>
)}
<div className="flex items-center gap-1.5">
<div className={`w-1 h-1 rounded-full ${isOnline ? 'bg-green-500 animate-pulse shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-rose-500 shadow-[0_0_5px_rgba(244,63,94,0.5)]'}`} />
<span className={`text-[9px] font-black uppercase tracking-widest whitespace-nowrap ${isOnline ? 'text-green-500/80' : 'text-rose-500/80'}`}>
SERVER SYNC: {isOnline ? 'OK' : 'NO'}
<span className={`text-xs font-black whitespace-nowrap ${isOnline ? 'text-green-500/80' : 'text-rose-500/80'}`}>
Server Sync: {isOnline ? 'OK' : 'No'}
</span>
</div>
</div>
@@ -482,7 +421,7 @@ export default function Home() {
<button
onClick={handleSync}
disabled={syncing || !isOnline}
className="bg-primary/10 hover:bg-primary/20 border border-primary/20 text-primary px-3 sm:px-4 py-1.5 rounded-xl text-[9px] font-black uppercase tracking-[0.2em] transition-all active:scale-95 disabled:opacity-30 flex items-center gap-2"
className="bg-primary/10 hover:bg-primary/20 border border-primary/20 text-primary px-3 sm:px-4 py-1.5 rounded-xl text-xs font-black transition-all active:scale-95 disabled:opacity-30 flex items-center gap-2"
>
<RefreshCw size={10} className={cn(syncing && "animate-spin")} />
Sync
@@ -496,13 +435,13 @@ export default function Home() {
{[
{ id: 'CHECK_IN', label: 'Check In' },
{ id: 'CHECK_OUT', label: 'Check Out' },
{ id: 'TRASH', label: 'Trash/Waste' }
{ id: 'TRASH', label: 'Trash' }
].map((m) => (
<button
key={m.id}
onClick={() => setMode(m.id as any)}
className={cn(
"flex-1 py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all",
"flex-1 py-3 rounded-xl text-sm font-black transition-all",
mode === m.id ? "bg-slate-800 text-primary shadow-lg" : "text-slate-500"
)}
>
@@ -537,7 +476,7 @@ export default function Home() {
<div>
<p className="text-lg font-medium">Tap to start scanning</p>
<p className="text-sm text-slate-400">Scan barcodes for routine {mode.toLowerCase().replace('_', ' ')}</p>
<p className="text-sm text-slate-400">Scan barcodes to {mode.toUpperCase().replace('_', ' ')} items</p>
</div>
<div className="w-full h-px bg-slate-800 my-2" />
@@ -547,87 +486,11 @@ export default function Home() {
className="w-full h-16 rounded-[1.5rem] bg-slate-900/50 border border-slate-800 flex items-center justify-center gap-3 group hover:border-primary/40 transition-all font-bold"
>
<Sparkles size={18} className="text-primary group-hover:scale-110 transition-transform" />
<span className="text-sm">New Item (AI Onboarding)</span>
<span className="text-sm">Add New Item (AI Onboarding)</span>
</button>
</div>
)}
</section>
{/* Inventory List */}
<section>
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4 mb-6">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Package size={20} className="text-primary" />
Catalog Items
</h3>
<div className="relative flex-1 md:max-w-xs">
<input
type="text"
placeholder="Search specs, PN, OM4..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-slate-900 border border-slate-800 rounded-xl py-2 pl-3 pr-10 text-sm focus:border-primary outline-none transition-all"
/>
<div className="absolute right-3 top-2.5 text-slate-600">
🔍
</div>
</div>
</div>
<div className="grid gap-3">
{filteredInventory.map(item => (
<div
key={item.id}
onClick={() => {
setSelectedItem(item);
setAdjustType('ADD');
}}
className="bg-slate-900/40 border border-slate-800/50 p-3 rounded-[1.5rem] flex items-center justify-between group hover:border-primary/40 hover:bg-slate-900/60 active:scale-[0.98] transition-all cursor-pointer shadow-sm"
>
<div className="flex-1 min-w-0 pr-4">
<div className="flex items-center gap-2 mb-1">
<span className="text-[9px] font-black bg-primary/10 text-primary px-2 py-0.5 rounded-full uppercase tracking-tighter">
{item.category}
</span>
{item.type && (
<span className="text-[9px] font-bold bg-slate-800 text-slate-400 px-2 py-0.5 rounded-full uppercase tracking-tighter">
{item.type}
</span>
)}
{item.color && (
<span className="text-[9px] font-bold border border-slate-800 px-2 py-0.5 rounded-full flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full" style={{backgroundColor: item.color}} />
{item.color}
</span>
)}
</div>
<h4 className="font-bold text-slate-200 break-words leading-tight">{item.name}</h4>
<p className="text-[10px] text-slate-500 font-medium break-words mt-1 opacity-80">
{item.specs}
</p>
<p className="text-[9px] text-slate-600 font-mono mt-1 flex items-center gap-2">
PN: {item.part_number || 'N/A'} <ChevronRight size={10} />
</p>
</div>
<div className="text-right shrink-0">
<span className={cn(
"text-xl font-black",
item.quantity <= item.min_quantity ? "text-amber-500" : "text-primary"
)}>
{item.quantity}
</span>
<p className="text-[9px] text-slate-600 uppercase tracking-[0.2em] font-black">Stock</p>
</div>
</div>
))}
{filteredInventory.length === 0 && (
<div className="py-12 text-center text-slate-500 bg-slate-900/20 rounded-3xl border border-dashed border-slate-800">
<p className="font-medium">No results for "{searchQuery}"</p>
<p className="text-xs">Try searching by category, specs (e.g. OM4) or color.</p>
</div>
)}
</div>
</section>
</div>
{/* Onboarding Overlay */}
@@ -684,7 +547,7 @@ export default function Home() {
{isEditing ? (
<div className="space-y-4 mb-8">
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Name</label>
<label className="text-xs font-black text-slate-500 ml-1">Name</label>
<input
type="text"
value={editedItem.name || ''}
@@ -693,7 +556,7 @@ export default function Home() {
/>
</div>
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Part Number (for OCR match)</label>
<label className="text-xs font-black text-slate-500 ml-1">Part Number (for OCR match)</label>
<input
type="text"
value={editedItem.part_number || ''}
@@ -704,7 +567,7 @@ export default function Home() {
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Category</label>
<label className="text-xs font-black text-slate-500 ml-1">Category</label>
<select
value={editedItem.category || ''}
onChange={e => setEditedItem({...editedItem, category: e.target.value})}
@@ -717,7 +580,7 @@ export default function Home() {
</select>
</div>
<div>
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Item Type (e.g. SFP, Patch Cord)</label>
<label className="text-xs font-black text-slate-500 ml-1">Item Type (e.g. SFP, Patch Cord)</label>
<input
type="text"
value={editedItem.type || ''}
@@ -727,7 +590,7 @@ export default function Home() {
/>
</div>
<div className="col-span-2">
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Specs</label>
<label className="text-xs font-black text-slate-500 ml-1">Specs</label>
<input
type="text"
value={editedItem.specs || ''}
@@ -755,7 +618,7 @@ export default function Home() {
)}
>
<t.icon size={20} className={adjustType === t.id ? t.color : ""} />
<span className="text-[10px] font-black uppercase tracking-widest mt-1">{t.label}</span>
<span className="text-xs font-black mt-1">{t.label}</span>
</button>
))}
</div>
@@ -783,7 +646,7 @@ export default function Home() {
<div className="w-full bg-red-500/5 border border-red-500/20 p-4 rounded-2xl animate-in shake duration-500">
<div className="flex items-center gap-2 mb-3">
<AlertTriangle size={16} className="text-red-500" />
<span className="text-xs font-bold text-red-400 uppercase tracking-widest">Waste Declaration</span>
<span className="text-sm font-bold text-red-400">Waste Declaration</span>
</div>
<select
value={trashReason}
@@ -823,356 +686,14 @@ export default function Home() {
</div>
)}
{/* Audit Logs Overlay */}
{showLogs && (
<div className="fixed inset-0 z-50 flex flex-col bg-slate-950 p-6 animate-in slide-in-from-bottom-20 duration-500">
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-2xl font-black tracking-tight">Audit History</h2>
<p className="text-xs text-slate-500">Live transaction log from cloud</p>
</div>
<button
onClick={() => setShowLogs(false)}
className="p-3 bg-slate-900 rounded-full text-slate-400"
>
<X size={24} />
</button>
</div>
<div className="flex-1 overflow-y-auto space-y-4 pr-2 custom-scrollbar">
{auditLogs.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-slate-600 gap-4">
<History size={48} className="opacity-20" />
<p>No transactions found</p>
</div>
) : (
auditLogs.map((log) => (
<div key={log.id} className="bg-slate-900/50 border border-slate-800 p-4 rounded-2xl flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className={cn(
"text-[10px] font-black uppercase tracking-widest",
log.action.includes('CHECK_IN') ? "text-green-500" : (log.action.includes('TRASH') ? "text-rose-500" : "text-amber-500")
)}>
{log.action}
</p>
<span className="text-[10px] font-bold text-slate-500">by</span>
<span className="text-[10px] font-black text-slate-400">{log.username || 'System'}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-slate-200">
{inventory.find(i => i.id === log.target_item_id)?.name || `Item #${log.target_item_id}`}
</span>
</div>
{(log.details || log.timestamp) && (
<div className="flex items-center gap-3 mt-2">
<p className="text-[9px] text-slate-600 font-mono">
{new Date(log.timestamp).toLocaleString()}
</p>
{log.details && (
<span className="text-[9px] bg-slate-800 text-slate-500 px-2 py-0.5 rounded border border-slate-700 font-mono">
{log.details}
</span>
)}
</div>
)}
</div>
<div className="text-right">
<p className={cn(
"text-lg font-black",
log.quantity_change > 0 ? "text-green-400" : "text-rose-400"
)}>
{log.quantity_change > 0 ? '+' : ''}{log.quantity_change}
</p>
</div>
</div>
))
)}
</div>
</div>
)}
{/* Footer Branding */}
<footer className="mt-20 mb-8 flex flex-col items-center gap-2 opacity-30">
<p className="text-[10px] font-bold tracking-[0.3em] uppercase">Powered by TFM Group Software</p>
<p className="text-xs font-bold">Powered by TFM Group Software</p>
<div className="h-px w-12 bg-slate-800" />
<p className="text-[9px] font-mono">v{versionData.version} {versionData.last_build} BUILD: dev-{versionData.commit}</p>
</footer>
{/* Bottom Nav */}
<footer className="fixed bottom-0 left-0 right-0 p-4 bg-slate-950/80 backdrop-blur-md border-t border-slate-900 z-40">
<div className="max-w-4xl mx-auto flex justify-around items-center text-slate-400">
<button
onClick={() => setShowScanner(true)}
className={cn("flex flex-col items-center gap-1", showScanner && !showOnboarding && "text-primary")}
>
<Smartphone size={20} />
<span className="text-[10px] font-bold uppercase transition-all">Scan</span>
</button>
{/* Central Add Button */}
<button
onClick={() => setShowOnboarding(true)}
className="bg-primary text-white p-4 rounded-full -mt-12 shadow-2xl shadow-primary/40 border-4 border-slate-950 active:scale-90 transition-transform"
>
<Sparkles size={24} />
</button>
<button
onClick={toggleLogs}
className={cn("flex flex-col items-center gap-1", showLogs && "text-primary")}
>
<History size={20} />
<span className="text-[10px] font-bold uppercase">Logs</span>
</button>
{currentUser?.role === 'admin' && (
<button
onClick={() => setShowSettings(true)}
className={cn("flex flex-col items-center gap-1", showSettings && "text-primary")}
>
<Settings size={20} />
<span className="text-[10px] font-bold uppercase transition-all">Admin</span>
</button>
)}
</div>
</footer>
{/* Settings Drawer (User Management) */}
{showSettings && (
<div className="fixed inset-0 z-40 bg-slate-950/60 backdrop-blur-sm animate-in fade-in duration-300">
<div className="absolute inset-y-0 right-0 w-full max-w-md bg-slate-900 border-l border-slate-800 shadow-2xl flex flex-col animate-in slide-in-from-right duration-500">
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-800 rounded-xl text-primary">
<Shield size={20} />
</div>
<h2 className="text-xl font-black text-white uppercase tracking-tight">System Admin</h2>
</div>
<button
onClick={() => {
setShowSettings(false);
loadInventory();
}}
className="p-2 hover:bg-slate-800 rounded-full transition-colors text-slate-500"
>
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-8">
<section className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-xs font-black text-slate-500 uppercase tracking-widest">User Management</h3>
<button
onClick={() => {
const name = prompt("Enter new username:");
if (!name) return;
const pwd = prompt("Enter password for " + name + ":");
if (!pwd) return;
inventoryApi.createUser({ username: name, password: pwd, role: 'user' })
.then(() => {
toast.success("User created");
inventoryApi.getUsers().then(setUsers);
})
.catch(e => toast.error("Failed to create user"));
}}
className="flex items-center gap-1 text-[10px] font-black text-primary uppercase hover:underline"
>
<UserPlus size={12} /> Add User
</button>
</div>
<div className="grid gap-2">
{users.map(u => (
<div key={u.id} className="bg-slate-800/40 border border-slate-800 p-4 rounded-2xl flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={cn("p-2 rounded-lg", u.role === 'admin' ? "bg-primary/20 text-primary" : "bg-slate-700 text-slate-400")}>
<User size={16} />
</div>
<div>
<p className="text-sm font-bold text-white">{u.username}</p>
<p className="text-[10px] font-bold text-slate-500 tracking-widest">{u.role}</p>
</div>
</div>
{u.username !== 'Admin' && (
<button
onClick={() => {
if(confirm(`Delete user ${u.username}?`)) {
inventoryApi.deleteUser(u.id)
.then(() => {
toast.success("User removed");
inventoryApi.getUsers().then(setUsers);
})
.catch(() => toast.error("Delete failed"));
}
}}
className="p-2 text-rose-500 hover:bg-rose-500/10 rounded-lg transition-all"
>
<Trash2 size={16} />
</button>
)}
</div>
))}
</div>
</section>
<section className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-xs font-black text-slate-500 uppercase tracking-widest">Category Groups</h3>
<button
onClick={() => {
const name = prompt("New category name:");
if (!name) return;
const desc = prompt("Description (optional):");
inventoryApi.createCategory({ name, description: desc })
.then(() => {
toast.success("Category added");
inventoryApi.getCategories().then(setCategories);
})
.catch(e => toast.error("Failed to add category"));
}}
className="flex items-center gap-1 text-[10px] font-black text-primary uppercase hover:underline"
>
<Plus size={12} /> Add Category
</button>
</div>
<div className="grid gap-2">
{categories.map(cat => (
<div key={cat.id} className="bg-slate-800/40 border border-slate-800 p-4 rounded-2xl flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-700 rounded-lg text-slate-400">
<Tag size={16} />
</div>
<div>
<p className="text-sm font-bold text-white">{cat.name}</p>
<p className="text-[9px] text-slate-500 font-mono">{cat.description || 'No description'}</p>
</div>
</div>
<button
onClick={() => {
if(confirm(`Delete category ${cat.name}?`)) {
inventoryApi.deleteCategory(cat.id)
.then(() => {
toast.success("Category removed");
inventoryApi.getCategories().then(setCategories);
})
.catch(e => toast.error(e.response?.data?.detail || "Delete failed"));
}
}}
className="p-2 text-rose-500 hover:bg-rose-500/10 rounded-lg transition-all"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
</section>
<section className="p-6 bg-slate-800/30 rounded-3xl border border-slate-800 space-y-4">
<div className="flex items-center gap-2 text-rose-500">
<AlertTriangle size={16} />
<p className="text-xs font-black uppercase tracking-widest">Logout</p>
</div>
<p className="text-xs text-slate-500">Exit current session and return to identity check.</p>
<button
onClick={() => {
localStorage.removeItem('inventory_user');
window.location.reload();
}}
className="w-full bg-rose-500/10 hover:bg-rose-500/20 text-rose-500 border border-rose-500/20 font-black uppercase tracking-widest py-3 rounded-xl transition-all flex items-center justify-center gap-2"
>
<LogOut size={16} /> End Session
</button>
</section>
</div>
</div>
</div>
)}
{/* User Selection Overlay */}
{showUserSelect && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-xl animate-in fade-in zoom-in duration-300">
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-8 max-w-sm w-full shadow-2xl space-y-8">
<div className="text-center space-y-2">
<div className="w-16 h-16 bg-primary/10 text-primary rounded-2xl flex items-center justify-center mx-auto mb-4 border border-primary/20">
<User size={32} />
</div>
<h2 className="text-2xl font-black text-white uppercase tracking-tight">Identity Check</h2>
<p className="text-slate-500 text-sm">Select operator profile to continue</p>
</div>
<div className="grid gap-3">
{!selectedUserForLogin ? (
users.map(user => (
<button
key={user.id}
onClick={() => handleSelectUser(user)}
className="bg-slate-800/50 hover:bg-slate-800 border border-slate-800 hover:border-primary/40 p-4 rounded-2xl text-left transition-all group flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-slate-900 border border-slate-700 flex items-center justify-center text-slate-500 group-hover:text-primary transition-colors">
{user.role === 'admin' ? <Shield size={14} /> : <User size={14} />}
</div>
<div>
<p className="text-white font-black text-sm tracking-widest">{user.username}</p>
<p className="text-[10px] text-slate-500 font-bold mt-1 tracking-widest">{user.role}</p>
</div>
</div>
<ChevronRight size={16} className="text-slate-600 group-hover:text-primary transition-colors" />
</button>
))
) : (
<div className="space-y-4 animate-in slide-in-from-right-4 duration-300">
<div className="bg-slate-800/30 p-4 rounded-2xl border border-slate-800 flex items-center gap-3">
<button
onClick={() => setSelectedUserForLogin(null)}
className="p-1 hover:bg-slate-700 rounded-lg transition-colors"
>
<X size={16} className="text-slate-400" />
</button>
<div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-widest">Logging in as</p>
<p className="text-white font-black tracking-tight">{selectedUserForLogin.username}</p>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest px-1">Password</label>
<div className="relative">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
<input
type="password"
autoFocus
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
className="w-full bg-slate-800/50 border border-slate-800 focus:border-primary rounded-2xl py-4 pl-12 pr-4 text-white focus:outline-none transition-all"
placeholder="••••••••"
/>
</div>
</div>
<button
onClick={handleLogin}
className="w-full bg-primary text-white font-black uppercase tracking-widest py-4 rounded-2xl shadow-xl shadow-primary/20 active:scale-95 transition-all"
>
Verify Identity
</button>
</div>
)}
</div>
{users.length === 0 && (
<div className="text-center p-8 text-slate-500 animate-pulse">
Initializing users...
</div>
)}
</div>
</div>
)}
</main>
</div>
</PageShell>
);
}