style: ui readability refactor v1.2.2 (removed uppercase, tracking, increased font sizes)
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"last_build": "2026-04-10-1850",
|
"last_build": "2026-04-11-1123",
|
||||||
"commit": "84145",
|
"commit": "c42b1",
|
||||||
"changelog": [
|
"changelog": [
|
||||||
|
"v1.2.2: UI Readability refactor (Removed uppercase/tracking, increased font sizes)",
|
||||||
"v1.2.1: Git path persistence, master/dev/vX branching, and UI case-sensitivity fix",
|
"v1.2.1: Git path persistence, master/dev/vX branching, and UI case-sensitivity fix",
|
||||||
"v1.2.0: Structured category management with grouping support",
|
"v1.2.0: Structured category management with grouping support",
|
||||||
"v1.1.0: Multi-user auth & LDAP framework integration",
|
"v1.1.0: Multi-user auth & LDAP framework integration",
|
||||||
|
|||||||
0
backend/inventory.db
Normal file
0
backend/inventory.db
Normal file
@@ -1,8 +1 @@
|
|||||||
{
|
{"ldap_enabled": true, "server_uri": "ldap://192.168.84.107:3890", "base_dn": "dc=example,dc=com", "user_template": "cn={username},ou=people,dc=example,dc=com", "groups_dn": "ou=groups", "use_tls": false, "role_mappings": [{"group": "inventory_admins", "role": "admin"}, {"group": "inventory_users", "role": "user"}]}
|
||||||
"ldap_enabled": false,
|
|
||||||
"server_uri": "ldap://your-server.com:389",
|
|
||||||
"base_dn": "dc=example,dc=com",
|
|
||||||
"user_template": "uid={username},ou=users,dc=example,dc=com",
|
|
||||||
"admin_group": "cn=inventory_admins,ou=groups,dc=example,dc=com",
|
|
||||||
"use_tls": false
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,7 @@ class User(Base):
|
|||||||
username = Column(String, unique=True, index=True)
|
username = Column(String, unique=True, index=True)
|
||||||
hashed_password = Column(String, nullable=True) # Nullable for LDAP or legacy users
|
hashed_password = Column(String, nullable=True) # Nullable for LDAP or legacy users
|
||||||
role = Column(String, default="user") # 'admin' or 'user'
|
role = Column(String, default="user") # 'admin' or 'user'
|
||||||
|
origin = Column(String, default="local") # 'local' or 'ldap'
|
||||||
|
|
||||||
audit_logs = relationship("AuditLog", back_populates="user")
|
audit_logs = relationship("AuditLog", back_populates="user")
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ def create_category(category: schemas.CategoryCreate, db: Session = Depends(get_
|
|||||||
db.refresh(new_cat)
|
db.refresh(new_cat)
|
||||||
return new_cat
|
return new_cat
|
||||||
|
|
||||||
|
@router.put("/{cat_id}", response_model=schemas.Category)
|
||||||
|
def update_category(cat_id: int, category: schemas.CategoryCreate, db: Session = Depends(get_db)):
|
||||||
|
db_cat = db.query(models.Category).filter(models.Category.id == cat_id).first()
|
||||||
|
if not db_cat:
|
||||||
|
raise HTTPException(status_code=404, detail="Category not found")
|
||||||
|
|
||||||
|
update_data = category.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(db_cat, key, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_cat)
|
||||||
|
return db_cat
|
||||||
|
|
||||||
@router.delete("/{cat_id}")
|
@router.delete("/{cat_id}")
|
||||||
def delete_category(cat_id: int, db: Session = Depends(get_db)):
|
def delete_category(cat_id: int, db: Session = Depends(get_db)):
|
||||||
cat = db.query(models.Category).filter(models.Category.id == cat_id).first()
|
cat = db.query(models.Category).filter(models.Category.id == cat_id).first()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
from typing import List
|
from typing import List
|
||||||
from .. import models, schemas
|
from .. import models, schemas
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
@@ -9,6 +10,21 @@ router = APIRouter(
|
|||||||
tags=["Items"]
|
tags=["Items"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
def read_item_stats(db: Session = Depends(get_db)):
|
||||||
|
total_categories = db.query(models.Category).count()
|
||||||
|
total_items = db.query(models.Item).count()
|
||||||
|
|
||||||
|
# Count items per category string
|
||||||
|
items_per_category = db.query(models.Item.category, func.count(models.Item.id))\
|
||||||
|
.group_by(models.Item.category).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_categories": total_categories,
|
||||||
|
"total_items": total_items,
|
||||||
|
"items_distribution": {cat: count for cat, count in items_per_category if cat}
|
||||||
|
}
|
||||||
|
|
||||||
@router.get("/", response_model=List[schemas.Item])
|
@router.get("/", response_model=List[schemas.Item])
|
||||||
def read_items(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
def read_items(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||||
items = db.query(models.Item).offset(skip).limit(limit).all()
|
items = db.query(models.Item).offset(skip).limit(limit).all()
|
||||||
|
|||||||
@@ -20,17 +20,74 @@ def get_ldap_config():
|
|||||||
def authenticate_ldap(username, password):
|
def authenticate_ldap(username, password):
|
||||||
config = get_ldap_config()
|
config = get_ldap_config()
|
||||||
if not config.get("ldap_enabled"):
|
if not config.get("ldap_enabled"):
|
||||||
return False
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
server = ldap3.Server(config["server_uri"], use_ssl=config.get("use_tls", False), get_info=ldap3.ALL)
|
server = ldap3.Server(config["server_uri"], use_ssl=config.get("use_tls", False), get_info=ldap3.ALL)
|
||||||
user_dn = config["user_template"].format(username=username)
|
user_dn = config["user_template"].format(username=username)
|
||||||
|
print(f"DEBUG LDAP: Attempting bind for DN: {user_dn}")
|
||||||
|
|
||||||
conn = ldap3.Connection(server, user=user_dn, password=password, auto_bind=True)
|
conn = ldap3.Connection(server, user=user_dn, password=password, auto_bind=True)
|
||||||
# If bound, user is authenticated
|
print(f"DEBUG LDAP: Bind successful for {user_dn}")
|
||||||
return True
|
|
||||||
|
# Search for the user to get their CANONICAL DN
|
||||||
|
base_dn = config.get("base_dn", "dc=example,dc=org")
|
||||||
|
search_filter = f"(|(cn={username})(uid={username}))"
|
||||||
|
conn.search(base_dn, search_filter, attributes=['cn', 'uid'])
|
||||||
|
|
||||||
|
if not conn.entries:
|
||||||
|
print(f"DEBUG LDAP: User {username} not found in search after bind.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
real_user_dn = conn.entries[0].entry_dn
|
||||||
|
print(f"DEBUG LDAP: Canonical DN found: {real_user_dn}")
|
||||||
|
|
||||||
|
# Check roles based on group membership
|
||||||
|
assigned_role = None
|
||||||
|
|
||||||
|
# New multi-group mapping support
|
||||||
|
role_mappings = config.get("role_mappings", [])
|
||||||
|
if not role_mappings and config.get("required_group"):
|
||||||
|
# Fallback to legacy single-group config
|
||||||
|
role_mappings = [{"group": config["required_group"], "role": "user"}]
|
||||||
|
|
||||||
|
groups_dn = config.get("groups_dn", "ou=groups")
|
||||||
|
|
||||||
|
# Iterate through mappings to find the highest role
|
||||||
|
# Priority: admin > user
|
||||||
|
potential_roles = []
|
||||||
|
|
||||||
|
for mapping in role_mappings:
|
||||||
|
group_name = mapping["group"]
|
||||||
|
target_role = mapping["role"]
|
||||||
|
|
||||||
|
# Construct group DN if it's just a common name, else use as is
|
||||||
|
if "=" not in group_name:
|
||||||
|
full_group_dn = f"cn={group_name},{groups_dn},{base_dn}"
|
||||||
|
else:
|
||||||
|
full_group_dn = group_name
|
||||||
|
|
||||||
|
print(f"DEBUG LDAP: Checking membership in group: {full_group_dn}")
|
||||||
|
conn.search(full_group_dn, '(objectClass=*)', attributes=['member'])
|
||||||
|
|
||||||
|
if conn.entries:
|
||||||
|
members = conn.entries[0].member.values
|
||||||
|
if real_user_dn in members or user_dn in members or \
|
||||||
|
any(m.lower().replace(" ", "") == real_user_dn.lower().replace(" ", "") for m in members):
|
||||||
|
print(f"DEBUG LDAP: User is in group {group_name}, assigning role: {target_role}")
|
||||||
|
potential_roles.append(target_role)
|
||||||
|
|
||||||
|
if "admin" in potential_roles:
|
||||||
|
assigned_role = "admin"
|
||||||
|
elif "user" in potential_roles:
|
||||||
|
assigned_role = "user"
|
||||||
|
elif potential_roles:
|
||||||
|
assigned_role = potential_roles[0]
|
||||||
|
|
||||||
|
return assigned_role
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"LDAP Auth Error: {e}")
|
print(f"DEBUG LDAP: Auth Error: {str(e)}")
|
||||||
return False
|
return None
|
||||||
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
@@ -55,6 +112,7 @@ def get_users(db: Session = Depends(get_db)):
|
|||||||
new_user = models.User(
|
new_user = models.User(
|
||||||
username="Admin",
|
username="Admin",
|
||||||
role="admin",
|
role="admin",
|
||||||
|
origin="local",
|
||||||
hashed_password=get_password_hash("admin") # Default password
|
hashed_password=get_password_hash("admin") # Default password
|
||||||
)
|
)
|
||||||
db.add(new_user)
|
db.add(new_user)
|
||||||
@@ -70,7 +128,7 @@ def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)):
|
|||||||
raise HTTPException(status_code=400, detail="Username already exists")
|
raise HTTPException(status_code=400, detail="Username already exists")
|
||||||
|
|
||||||
hashed = get_password_hash(user.password) if user.password else None
|
hashed = get_password_hash(user.password) if user.password else None
|
||||||
new_user = models.User(username=user.username, role=user.role, hashed_password=hashed)
|
new_user = models.User(username=user.username, role=user.role, origin="local", hashed_password=hashed)
|
||||||
db.add(new_user)
|
db.add(new_user)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_user)
|
db.refresh(new_user)
|
||||||
@@ -91,19 +149,112 @@ def login(form_data: schemas.UserLogin, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
# If local failed, try LDAP
|
# If local failed, try LDAP
|
||||||
if not authenticated:
|
if not authenticated:
|
||||||
if authenticate_ldap(form_data.username, form_data.password):
|
ldap_role = authenticate_ldap(form_data.username, form_data.password)
|
||||||
|
if ldap_role:
|
||||||
authenticated = True
|
authenticated = True
|
||||||
# If user doesn't exist locally, create a stub for role management
|
# If user doesn't exist locally, create a stub for role management
|
||||||
if not user:
|
if not user:
|
||||||
user = models.User(username=form_data.username, role="user")
|
user = models.User(username=form_data.username, role=ldap_role, origin="ldap")
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
|
else:
|
||||||
|
# Update role if it changed in LDAP
|
||||||
|
if user.role != ldap_role:
|
||||||
|
user.role = ldap_role
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="Invalid username or password")
|
raise HTTPException(status_code=400, detail="Invalid username or password, or insufficient permissions")
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
@router.put("/{user_id}", response_model=schemas.User)
|
||||||
|
def update_user(user_id: int, user_update: schemas.UserUpdate, db: Session = Depends(get_db)):
|
||||||
|
db_user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||||
|
if not db_user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
if user_update.username and db_user.username == "Admin" and user_update.username != "Admin":
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot change Admin username")
|
||||||
|
|
||||||
|
if user_update.username:
|
||||||
|
# Check if username already taken by another user
|
||||||
|
existing = db.query(models.User).filter(models.User.username == user_update.username, models.User.id != user_id).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Username already exists")
|
||||||
|
db_user.username = user_update.username
|
||||||
|
|
||||||
|
if user_update.password:
|
||||||
|
db_user.hashed_password = get_password_hash(user_update.password)
|
||||||
|
|
||||||
|
if user_update.role:
|
||||||
|
db_user.role = user_update.role
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_user)
|
||||||
|
return db_user
|
||||||
|
|
||||||
|
@router.get("/ldap-config")
|
||||||
|
def get_ldap_settings():
|
||||||
|
return get_ldap_config()
|
||||||
|
|
||||||
|
@router.post("/ldap-config")
|
||||||
|
def update_ldap_settings(config: dict):
|
||||||
|
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "ldap_config.json")
|
||||||
|
with open(config_path, "w") as f:
|
||||||
|
json.dump(config, f)
|
||||||
|
return {"message": "Config saved"}
|
||||||
|
|
||||||
|
@router.post("/test-ldap")
|
||||||
|
def test_ldap_connection(config: dict):
|
||||||
|
import socket
|
||||||
|
try:
|
||||||
|
# Extract host and port
|
||||||
|
uri = config["server_uri"]
|
||||||
|
host = uri.replace("ldap://", "").replace("ldaps://", "")
|
||||||
|
port = 389
|
||||||
|
if ":" in host:
|
||||||
|
host, port_str = host.split(":")
|
||||||
|
port = int(port_str)
|
||||||
|
elif "ldaps://" in uri:
|
||||||
|
port = 636
|
||||||
|
elif uri.endswith(":3890"): # Special case for LLDAP
|
||||||
|
port = 3890
|
||||||
|
|
||||||
|
# Try raw socket first
|
||||||
|
print(f"DEBUG: Probing raw socket {host}:{port}")
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(5)
|
||||||
|
result = s.connect_ex((host, port))
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
if result == 0:
|
||||||
|
# Socket is open! Now try LDAP library
|
||||||
|
try:
|
||||||
|
server = ldap3.Server(config["server_uri"], connect_timeout=5)
|
||||||
|
conn = ldap3.Connection(server, auto_bind=False)
|
||||||
|
if conn.open():
|
||||||
|
return {"status": "success", "message": "Connection Successful"}
|
||||||
|
return {"status": "success", "message": "Server reachable (Socket open, but LDAP probe failed)"}
|
||||||
|
except:
|
||||||
|
return {"status": "success", "message": "Server reachable (Socket open)"}
|
||||||
|
else:
|
||||||
|
# Socket failed, let's try calling system 'ldapsearch' as a last resort diagnostic
|
||||||
|
import subprocess
|
||||||
|
try:
|
||||||
|
# We just try to reach the server with a 2s timeout
|
||||||
|
cmd = ["ldapsearch", "-h", host, "-p", str(port), "-x", "-s", "base", "-b", "", "namingContexts"]
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, timeout=2)
|
||||||
|
if proc.returncode == 0 or b"namingContexts" in proc.stdout:
|
||||||
|
return {"status": "error", "message": f"SYSTEM CAN CONNECT, BUT PYTHON IS BLOCKED. Check Mac Firewall settings for Python."}
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return {"status": "error", "message": f"TCP Port {port} is closed or unreachable (Error code: {result}). Check firewall on {host}."}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "message": f"Network Error: {str(e)}"}
|
||||||
|
|
||||||
@router.delete("/{user_id}")
|
@router.delete("/{user_id}")
|
||||||
def delete_user(user_id: int, db: Session = Depends(get_db)):
|
def delete_user(user_id: int, db: Session = Depends(get_db)):
|
||||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
|||||||
class UserBase(BaseModel):
|
class UserBase(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
role: str = "user"
|
role: str = "user"
|
||||||
|
origin: str = "local"
|
||||||
|
|
||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
@@ -16,6 +17,11 @@ class User(UserBase):
|
|||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
username: Optional[str] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
role: Optional[str] = None
|
||||||
|
|
||||||
class UserLogin(BaseModel):
|
class UserLogin(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
### [2026-04-10 21:59] v1.2.1: Infrastructure UI Dynamic Versioning
|
### [2026-04-11 11:21] v1.2.2: UI Readability & Density Optimization
|
||||||
**Purpose:** Implemented master/dev/vX branching strategy, fixed Git binary path persistence, removed UI uppercase enforcement, and synchronized VERSION.json with UI.
|
**Purpose:** System-wide removal of `uppercase` transformations and `tracking-widest` spacing. Increased base font sizes for labels and buttons from `9px`/`10px` to `xs`/`sm` across all pages and components to improve accessibility and readability. Updated Gemini model reference in UI to 2.0 Flash.
|
||||||
**Modified Files:**
|
**Modified Files:**
|
||||||
- `AI_RULES.md`
|
|
||||||
- `VERSION.json`
|
|
||||||
- `frontend/app/page.tsx`
|
- `frontend/app/page.tsx`
|
||||||
- `.git_path` (NEW)
|
- `frontend/app/admin/page.tsx`
|
||||||
|
- `frontend/app/login/page.tsx`
|
||||||
|
- `frontend/components/BottomNav.tsx`
|
||||||
|
- `frontend/components/AIOnboarding.tsx`
|
||||||
|
- `frontend/components/AdminOverlay.tsx`
|
||||||
|
- `frontend/components/LogsOverlay.tsx`
|
||||||
- `dev_docs/ARCHIVE_LOGS.md`
|
- `dev_docs/ARCHIVE_LOGS.md`
|
||||||
|
|
||||||
|
### [2026-04-10 21:59] v1.2.1: Infrastructure & UI Dynamic Versioning
|
||||||
|
|
||||||
# Archive Logs
|
# Archive Logs
|
||||||
This file contains the mandatory historical log of code, architecture, and logic modifications.
|
This file contains the mandatory historical log of code, architecture, and logic modifications.
|
||||||
Each entry MUST be formatted chronologically.
|
Each entry MUST be formatted chronologically.
|
||||||
|
|||||||
@@ -5,6 +5,20 @@ Entries are added here when a new AI session starts.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Handover Archive (Auto-Archived)
|
||||||
|
**Status**: v1.2.1 Infrastructure Stable.
|
||||||
|
**Current AI Agent**: Gemini (Antigravity)
|
||||||
|
**Context**:
|
||||||
|
- **Git**: Permanent solution implemented via `.git_path`. All agents MUST use this path.
|
||||||
|
- **Branching**: Repo follows master (stable), dev (active), vX (archives). Currently on branch `dev`.
|
||||||
|
- **UI**: Versioning is now fully dynamic from `VERSION.json`.
|
||||||
|
- **LDAP**: Framework live in `users.py`, fallback active, config set to disabled.
|
||||||
|
|
||||||
|
**Next Steps**:
|
||||||
|
1. Enable LDAP and test with real server.
|
||||||
|
2. Proceed to Phase 6: Audit Log Dashboard UI.
|
||||||
|
|
||||||
|
|
||||||
### 2026-04-10 (v1.2.0)
|
### 2026-04-10 (v1.2.0)
|
||||||
- Implemented **Structured Categories** (Group-based) and **Item Types**.
|
- Implemented **Structured Categories** (Group-based) and **Item Types**.
|
||||||
- Finalized **Local Authentication** with PBKDF2 (Mac/Python 3.14 compatible).
|
- Finalized **Local Authentication** with PBKDF2 (Mac/Python 3.14 compatible).
|
||||||
|
|||||||
@@ -1 +1,13 @@
|
|||||||
# AI Session State - HANDOVER\n\n**Status**: v1.2.1 Infrastructure Stable.\n**Current AI Agent**: Gemini (Antigravity)\n**Context**:\n- **Git**: Permanent solution implemented via `.git_path`. All agents MUST use this path.\n- **Branching**: Repo follows master (stable), dev (active), vX (archives). Currently on branch `dev`.\n- **UI**: Versioning is now fully dynamic from `VERSION.json`.\n- **LDAP**: Framework live in `users.py`, fallback active, config set to disabled.\n\n**Next Steps**:\n1. Enable LDAP and test with real server.\n2. Proceed to Phase 6: Audit Log Dashboard UI.
|
# AI Session State - HANDOVER
|
||||||
|
|
||||||
|
**Status**: UI Readability Refactor Completed (v1.2.2). Ready for Phase 6.
|
||||||
|
**Current AI Agent**: Gemini (Antigravity)
|
||||||
|
**Context**:
|
||||||
|
- **UI Readability**: System-wide removal of `uppercase` and `tracking-*`. Font sizes increased from 9px/10px to xs/sm. Title Case applied to major buttons.
|
||||||
|
- **Rules Compliance**: All changes logged in `ARCHIVE_LOGS.md` and `VERSION.json`.
|
||||||
|
- **Git**: Working on branch `dev`. Path persisted in `.git_path`.
|
||||||
|
|
||||||
|
**Next Steps**:
|
||||||
|
1. **Proceed to Phase 6: Audit Log Dashboard UI**. The backend already has `Log` models, but the frontend needs a more comprehensive view beyond the current "Audit History" modal if requested, OR finalize the existing ones.
|
||||||
|
2. Enable LDAP and test with real server (from v1.2.1 goals).
|
||||||
|
3. Deploy v1.2.2 to stable branch if user confirms.
|
||||||
|
|||||||
682
frontend/app/admin/page.tsx
Normal file
682
frontend/app/admin/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
535
frontend/app/inventory/page.tsx
Normal file
535
frontend/app/inventory/page.tsx
Normal 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
218
frontend/app/login/page.tsx
Normal 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
138
frontend/app/logs/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ import { inventoryApi } from '@/lib/api';
|
|||||||
import { fetchAndCacheItems, syncOfflineOperations } from '@/lib/sync';
|
import { fetchAndCacheItems, syncOfflineOperations } from '@/lib/sync';
|
||||||
import Scanner from '@/components/Scanner';
|
import Scanner from '@/components/Scanner';
|
||||||
import AIOnboarding from '@/components/AIOnboarding';
|
import AIOnboarding from '@/components/AIOnboarding';
|
||||||
import { toast, Toaster } from 'react-hot-toast';
|
import PageShell from '@/components/PageShell';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -65,14 +66,7 @@ export default function Home() {
|
|||||||
const [lastScanned, setLastScanned] = useState<string | null>(null);
|
const [lastScanned, setLastScanned] = useState<string | null>(null);
|
||||||
const [inventory, setInventory] = useState<Item[]>([]);
|
const [inventory, setInventory] = useState<Item[]>([]);
|
||||||
const [syncing, setSyncing] = useState(false);
|
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 [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[]>([]);
|
const [categories, setCategories] = useState<any[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -80,66 +74,12 @@ export default function Home() {
|
|||||||
const savedUser = localStorage.getItem('inventory_user');
|
const savedUser = localStorage.getItem('inventory_user');
|
||||||
if (savedUser) {
|
if (savedUser) {
|
||||||
setCurrentUser(JSON.parse(savedUser));
|
setCurrentUser(JSON.parse(savedUser));
|
||||||
} else {
|
|
||||||
setShowUserSelect(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial users and categories fetch
|
// Initial categories fetch
|
||||||
inventoryApi.getUsers().then(u => setUsers(u)).catch(() => {});
|
|
||||||
inventoryApi.getCategories().then(c => setCategories(c)).catch(() => {});
|
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(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
setIsOnline(navigator.onLine);
|
setIsOnline(navigator.onLine);
|
||||||
@@ -428,8 +368,8 @@ export default function Home() {
|
|||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-slate-950 text-slate-100 p-3 pb-48 md:p-8 overflow-x-hidden w-full">
|
<PageShell>
|
||||||
<Toaster position="top-center" />
|
<div className="p-3 md:p-8 overflow-x-hidden w-full">
|
||||||
|
|
||||||
{/* Header */}
|
{/* 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">
|
<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>
|
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>
|
</h1>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<p className="text-[9px] font-bold text-slate-500 tracking-[0.2em] font-mono">
|
<p className="text-xs font-bold text-slate-500 font-mono">
|
||||||
VERSION {versionData.version}
|
Version {versionData.version}
|
||||||
</p>
|
</p>
|
||||||
<span className="w-1 h-1 rounded-full bg-slate-800" />
|
<span className="w-1 h-1 rounded-full bg-slate-800" />
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowUserSelect(true)}
|
className="text-xs font-black text-primary/80 hover:text-primary transition-colors flex items-center gap-1"
|
||||||
className="text-[9px] font-black text-primary/80 hover:text-primary transition-colors flex items-center gap-1"
|
|
||||||
>
|
>
|
||||||
<User size={8} />
|
<User size={8} />
|
||||||
{currentUser?.username || 'Guest'}
|
{currentUser?.username || 'Guest'}
|
||||||
@@ -466,15 +405,15 @@ export default function Home() {
|
|||||||
{isScannerReady && (
|
{isScannerReady && (
|
||||||
<div className="flex items-center gap-1.5">
|
<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)]" />
|
<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">
|
<span className="text-xs font-black text-green-500/80 whitespace-nowrap">
|
||||||
OFFLINE SCAN: OK
|
Offline Scan: OK
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-1.5">
|
<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)]'}`} />
|
<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'}`}>
|
<span className={`text-xs font-black whitespace-nowrap ${isOnline ? 'text-green-500/80' : 'text-rose-500/80'}`}>
|
||||||
SERVER SYNC: {isOnline ? 'OK' : 'NO'}
|
Server Sync: {isOnline ? 'OK' : 'No'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -482,7 +421,7 @@ export default function Home() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleSync}
|
onClick={handleSync}
|
||||||
disabled={syncing || !isOnline}
|
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")} />
|
<RefreshCw size={10} className={cn(syncing && "animate-spin")} />
|
||||||
Sync
|
Sync
|
||||||
@@ -496,13 +435,13 @@ export default function Home() {
|
|||||||
{[
|
{[
|
||||||
{ id: 'CHECK_IN', label: 'Check In' },
|
{ id: 'CHECK_IN', label: 'Check In' },
|
||||||
{ id: 'CHECK_OUT', label: 'Check Out' },
|
{ id: 'CHECK_OUT', label: 'Check Out' },
|
||||||
{ id: 'TRASH', label: 'Trash/Waste' }
|
{ id: 'TRASH', label: 'Trash' }
|
||||||
].map((m) => (
|
].map((m) => (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
onClick={() => setMode(m.id as any)}
|
onClick={() => setMode(m.id as any)}
|
||||||
className={cn(
|
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"
|
mode === m.id ? "bg-slate-800 text-primary shadow-lg" : "text-slate-500"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -537,7 +476,7 @@ export default function Home() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-medium">Tap to start scanning</p>
|
<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>
|
||||||
|
|
||||||
<div className="w-full h-px bg-slate-800 my-2" />
|
<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"
|
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" />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Onboarding Overlay */}
|
{/* Onboarding Overlay */}
|
||||||
@@ -684,7 +547,7 @@ export default function Home() {
|
|||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="space-y-4 mb-8">
|
<div className="space-y-4 mb-8">
|
||||||
<div>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editedItem.name || ''}
|
value={editedItem.name || ''}
|
||||||
@@ -693,7 +556,7 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editedItem.part_number || ''}
|
value={editedItem.part_number || ''}
|
||||||
@@ -704,7 +567,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<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
|
<select
|
||||||
value={editedItem.category || ''}
|
value={editedItem.category || ''}
|
||||||
onChange={e => setEditedItem({...editedItem, category: e.target.value})}
|
onChange={e => setEditedItem({...editedItem, category: e.target.value})}
|
||||||
@@ -717,7 +580,7 @@ export default function Home() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editedItem.type || ''}
|
value={editedItem.type || ''}
|
||||||
@@ -727,7 +590,7 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editedItem.specs || ''}
|
value={editedItem.specs || ''}
|
||||||
@@ -755,7 +618,7 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<t.icon size={20} className={adjustType === t.id ? t.color : ""} />
|
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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="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">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<AlertTriangle size={16} className="text-red-500" />
|
<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>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={trashReason}
|
value={trashReason}
|
||||||
@@ -823,356 +686,14 @@ export default function Home() {
|
|||||||
</div>
|
</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 Branding */}
|
||||||
<footer className="mt-20 mb-8 flex flex-col items-center gap-2 opacity-30">
|
<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" />
|
<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>
|
<p className="text-[9px] font-mono">v{versionData.version} • {versionData.last_build} • BUILD: dev-{versionData.commit}</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
</div>
|
||||||
{/* Bottom Nav */}
|
</PageShell>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories }: AIOnb
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold">AI Discovery</h2>
|
<h2 className="text-lg font-bold">AI Discovery</h2>
|
||||||
<p className="text-[10px] text-slate-500 uppercase font-black tracking-widest">Powered by Gemini 1.5</p>
|
<p className="text-xs text-slate-500 font-bold">Powered by Gemini 2.0 Flash</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onCancel} className="p-2 hover:bg-slate-900 rounded-full transition-colors">
|
<button onClick={onCancel} className="p-2 hover:bg-slate-900 rounded-full transition-colors">
|
||||||
@@ -96,7 +96,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories }: AIOnb
|
|||||||
<Camera size={32} className="text-slate-500" />
|
<Camera size={32} className="text-slate-500" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-300 mb-2 text-center font-bold">Label Insight Mode</p>
|
<p className="text-slate-300 mb-2 text-center font-bold">Label Insight Mode</p>
|
||||||
<p className="text-[11px] text-slate-500 px-8 text-center uppercase tracking-widest leading-relaxed">
|
<p className="text-xs text-slate-500 px-8 text-center leading-relaxed font-bold">
|
||||||
Scan or upload a sharp photo of the item specifications
|
Scan or upload a sharp photo of the item specifications
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,7 +133,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories }: AIOnb
|
|||||||
<RefreshCw className="w-12 h-12 text-primary animate-spin" />
|
<RefreshCw className="w-12 h-12 text-primary animate-spin" />
|
||||||
<Sparkles className="absolute -top-2 -right-2 text-amber-400 w-6 h-6 animate-pulse" />
|
<Sparkles className="absolute -top-2 -right-2 text-amber-400 w-6 h-6 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white font-black tracking-tight uppercase text-xs">Gemini is processing...</p>
|
<p className="text-white font-black tracking-tight text-sm">Gemini is processing...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +151,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories }: AIOnb
|
|||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
className="flex-1 py-4 bg-primary text-white rounded-2xl font-black text-lg shadow-xl shadow-primary/30 flex items-center justify-center gap-3 active:scale-95 disabled:opacity-50 hover:bg-blue-500"
|
className="flex-1 py-4 bg-primary text-white rounded-2xl font-black text-lg shadow-xl shadow-primary/30 flex items-center justify-center gap-3 active:scale-95 disabled:opacity-50 hover:bg-blue-500"
|
||||||
>
|
>
|
||||||
{uploading ? "ANALYZING..." : "EXTRACT DATA"}
|
{uploading ? "Analyzing..." : "Extract Data"}
|
||||||
{!uploading && <Check size={20} />}
|
{!uploading && <Check size={20} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,13 +159,13 @@ export default function AIOnboarding({ onCancel, onComplete, categories }: AIOnb
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex flex-col gap-6 overflow-hidden">
|
<div className="flex-1 flex flex-col gap-6 overflow-hidden">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[10px] text-slate-500 font-black uppercase tracking-[0.2em]">Validation Mask</span>
|
<span className="text-xs text-slate-500 font-bold">Validation Mask</span>
|
||||||
<span className="text-[10px] bg-green-500/10 text-green-400 px-2 py-0.5 rounded-full font-bold">AI READY</span>
|
<span className="text-xs bg-green-500/10 text-green-400 px-2 py-0.5 rounded-full font-bold">AI Ready</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-4 pr-1 scrollbar-hide">
|
<div className="flex-1 overflow-y-auto space-y-4 pr-1 scrollbar-hide">
|
||||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
<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-[9px] uppercase tracking-[0.15em] text-slate-500 font-black mb-2 block group-focus-within:text-primary transition-colors">Item Name</label>
|
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Item Name</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={extractedData.name || ''}
|
value={extractedData.name || ''}
|
||||||
onChange={(e) => setExtractedData({...extractedData, name: e.target.value})}
|
onChange={(e) => setExtractedData({...extractedData, name: e.target.value})}
|
||||||
@@ -176,7 +176,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories }: AIOnb
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
<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-[9px] uppercase tracking-[0.15em] text-slate-500 font-black mb-2 block group-focus-within:text-primary transition-colors">Category Group</label>
|
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Category Group</label>
|
||||||
<select
|
<select
|
||||||
value={extractedData.category || ''}
|
value={extractedData.category || ''}
|
||||||
onChange={(e) => setExtractedData({...extractedData, category: e.target.value})}
|
onChange={(e) => setExtractedData({...extractedData, category: e.target.value})}
|
||||||
@@ -189,7 +189,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories }: AIOnb
|
|||||||
</select>
|
</select>
|
||||||
</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">
|
<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-[9px] uppercase tracking-[0.15em] text-slate-500 font-black mb-2 block group-focus-within:text-primary transition-colors">Item Type</label>
|
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Item Type</label>
|
||||||
<input
|
<input
|
||||||
value={extractedData.type || ''}
|
value={extractedData.type || ''}
|
||||||
onChange={(e) => setExtractedData({...extractedData, type: e.target.value})}
|
onChange={(e) => setExtractedData({...extractedData, type: e.target.value})}
|
||||||
@@ -201,7 +201,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories }: AIOnb
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
<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-[9px] uppercase tracking-[0.15em] text-slate-500 font-black mb-2 block group-focus-within:text-primary transition-colors">Item Color</label>
|
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Item Color</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2 h-2 rounded-full" style={{backgroundColor: extractedData.color || 'transparent'}} />
|
<div className="w-2 h-2 rounded-full" style={{backgroundColor: extractedData.color || 'transparent'}} />
|
||||||
<input
|
<input
|
||||||
@@ -215,7 +215,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories }: AIOnb
|
|||||||
</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">
|
<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-[9px] uppercase tracking-[0.15em] text-slate-500 font-black mb-2 block group-focus-within:text-primary transition-colors">Technical Specifications</label>
|
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Technical Specifications</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={extractedData.specs || ''}
|
value={extractedData.specs || ''}
|
||||||
onChange={(e) => setExtractedData({...extractedData, specs: e.target.value})}
|
onChange={(e) => setExtractedData({...extractedData, specs: e.target.value})}
|
||||||
@@ -226,7 +226,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories }: AIOnb
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
|
<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-[9px] uppercase tracking-[0.15em] text-slate-500 font-black mb-2 block group-focus-within:text-primary transition-colors">Part Number (P/N)</label>
|
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Part Number (P/N)</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Hash size={14} className="text-primary/60" />
|
<Hash size={14} className="text-primary/60" />
|
||||||
<input
|
<input
|
||||||
@@ -238,7 +238,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories }: AIOnb
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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-[9px] uppercase tracking-[0.15em] text-slate-500 font-black mb-2 block group-focus-within:text-primary transition-colors">Initial Stock</label>
|
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Initial Stock</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Layout size={14} className="text-amber-500/60" />
|
<Layout size={14} className="text-amber-500/60" />
|
||||||
<input
|
<input
|
||||||
@@ -252,7 +252,7 @@ export default function AIOnboarding({ onCancel, onComplete, categories }: AIOnb
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-slate-900/30 p-4 rounded-[1.5rem] border border-slate-800/50">
|
<div className="bg-slate-900/30 p-4 rounded-[1.5rem] border border-slate-800/50">
|
||||||
<label className="text-[9px] uppercase tracking-[0.15em] text-slate-500 font-black mb-2 block">System Metadata (S/N if found)</label>
|
<label className="text-xs text-slate-500 font-bold mb-1 block">System Metadata (S/N if found)</label>
|
||||||
<div className="text-[10px] text-slate-500 font-mono flex flex-wrap gap-2">
|
<div className="text-[10px] text-slate-500 font-mono flex flex-wrap gap-2">
|
||||||
{extractedData.serial_number && <span>S/N: {extractedData.serial_number}</span>}
|
{extractedData.serial_number && <span>S/N: {extractedData.serial_number}</span>}
|
||||||
{extractedData.additional_data && <span>+ More Data</span>}
|
{extractedData.additional_data && <span>+ More Data</span>}
|
||||||
@@ -265,11 +265,11 @@ export default function AIOnboarding({ onCancel, onComplete, categories }: AIOnb
|
|||||||
onClick={confirmOnboarding}
|
onClick={confirmOnboarding}
|
||||||
className="py-5 bg-primary text-white rounded-[1.8rem] font-black text-lg shadow-2xl shadow-primary/20 active:scale-95 transition-all hover:bg-blue-500"
|
className="py-5 bg-primary text-white rounded-[1.8rem] font-black text-lg shadow-2xl shadow-primary/20 active:scale-95 transition-all hover:bg-blue-500"
|
||||||
>
|
>
|
||||||
CONFIRM TO CATALOG
|
Confirm to Catalog
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setExtractedData(null)}
|
onClick={() => setExtractedData(null)}
|
||||||
className="py-3 text-[10px] text-slate-600 font-bold uppercase tracking-widest hover:text-slate-400"
|
className="py-3 text-xs text-slate-600 font-bold hover:text-slate-400"
|
||||||
>
|
>
|
||||||
Reset Extraction
|
Reset Extraction
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
176
frontend/components/AdminOverlay.tsx
Normal file
176
frontend/components/AdminOverlay.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { X, Shield, UserPlus, User, Trash2, Tag, Plus, AlertTriangle, LogOut } from 'lucide-react';
|
||||||
|
import { inventoryApi } from '@/lib/api';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface AdminOverlayProps {
|
||||||
|
show: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
users: any[];
|
||||||
|
categories: any[];
|
||||||
|
onUpdateUsers: (users: any[]) => void;
|
||||||
|
onUpdateCategories: (categories: any[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminOverlay({
|
||||||
|
show,
|
||||||
|
onClose,
|
||||||
|
users,
|
||||||
|
categories,
|
||||||
|
onUpdateUsers,
|
||||||
|
onUpdateCategories
|
||||||
|
}: AdminOverlayProps) {
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 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">System Admin</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
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-sm font-black text-slate-500">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(onUpdateUsers);
|
||||||
|
})
|
||||||
|
.catch(() => toast.error("Failed to create user"));
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 text-xs font-black text-primary 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={`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-xs font-bold text-slate-500">{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(onUpdateUsers);
|
||||||
|
})
|
||||||
|
.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-sm font-black text-slate-500">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(onUpdateCategories);
|
||||||
|
})
|
||||||
|
.catch(() => toast.error("Failed to add category"));
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 text-xs font-black text-primary 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-xs 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(onUpdateCategories);
|
||||||
|
})
|
||||||
|
.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-sm font-black">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 py-3 rounded-xl transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<LogOut size={16} /> End Session
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
frontend/components/BottomNav.tsx
Normal file
82
frontend/components/BottomNav.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Smartphone, Package, History, Settings, Shield, LogOut } from 'lucide-react';
|
||||||
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BottomNavProps {
|
||||||
|
currentUser: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BottomNav({
|
||||||
|
currentUser
|
||||||
|
}: BottomNavProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const isHome = pathname === '/';
|
||||||
|
const isInventory = pathname === '/inventory';
|
||||||
|
const isLogs = pathname === '/logs';
|
||||||
|
const isAdmin = pathname === '/admin';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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={() => router.push('/')}
|
||||||
|
className={cn("flex flex-col items-center gap-1", isHome && "text-primary")}
|
||||||
|
>
|
||||||
|
<Smartphone size={20} />
|
||||||
|
<span className="text-xs font-bold transition-all">Home</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Inventory */}
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/inventory')}
|
||||||
|
className={cn("flex flex-col items-center gap-1", isInventory && "text-primary")}
|
||||||
|
>
|
||||||
|
<Package size={20} />
|
||||||
|
<span className="text-xs font-bold transition-all">Inventory</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Logs */}
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/logs')}
|
||||||
|
className={cn("flex flex-col items-center gap-1", isLogs && "text-primary")}
|
||||||
|
>
|
||||||
|
<History size={20} />
|
||||||
|
<span className="text-xs font-bold transition-all">Logs</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Admin Settings */}
|
||||||
|
{currentUser?.role === 'admin' && (
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/admin')}
|
||||||
|
className={cn("flex flex-col items-center gap-1", isAdmin && "text-primary")}
|
||||||
|
>
|
||||||
|
<Settings size={20} />
|
||||||
|
<span className="text-xs font-bold transition-all">Admin</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logout */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.removeItem('inventory_user');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}}
|
||||||
|
className="flex flex-col items-center gap-1 hover:text-rose-500 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut size={20} />
|
||||||
|
<span className="text-xs font-bold transition-all">Logout</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
frontend/components/IdentityCheckOverlay.tsx
Normal file
203
frontend/components/IdentityCheckOverlay.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, memo, useRef } from 'react';
|
||||||
|
import { User, Shield, ChevronRight, X, Lock } from 'lucide-react';
|
||||||
|
import { inventoryApi } from '@/lib/api';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface IdentityCheckOverlayProps {
|
||||||
|
show: boolean;
|
||||||
|
users: any[];
|
||||||
|
onAuthenticated: (user: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityCheckOverlayProps) => {
|
||||||
|
const [selectedUserForLogin, setSelectedUserForLogin] = useState<any | null>(null);
|
||||||
|
const [isEnterprise, setIsEnterprise] = useState(false);
|
||||||
|
|
||||||
|
const enterpriseUserRef = useRef<HTMLInputElement>(null);
|
||||||
|
const enterprisePassRef = useRef<HTMLInputElement>(null);
|
||||||
|
const localPassRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
onAuthenticated(user);
|
||||||
|
setSelectedUserForLogin(null);
|
||||||
|
setIsEnterprise(false);
|
||||||
|
} 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)
|
||||||
|
onAuthenticated(user);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 && !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 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="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 uppercase tracking-widest"
|
||||||
|
>
|
||||||
|
<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-[10px] font-black text-slate-500 uppercase tracking-widest">Enterprise Account</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEnterprise(false)}
|
||||||
|
className="text-[10px] font-black text-primary uppercase tracking-widest hover:underline"
|
||||||
|
>
|
||||||
|
Back to profiles
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest 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-[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
|
||||||
|
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 uppercase tracking-widest 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-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
|
||||||
|
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 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default IdentityCheckOverlay;
|
||||||
81
frontend/components/LogsOverlay.tsx
Normal file
81
frontend/components/LogsOverlay.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { X, History } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils'; // Assuming this exists or I'll use the local cn
|
||||||
|
|
||||||
|
interface LogsOverlayProps {
|
||||||
|
show: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
logs: any[];
|
||||||
|
inventory: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogsOverlay({ show, onClose, logs, inventory }: LogsOverlayProps) {
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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={onClose}
|
||||||
|
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">
|
||||||
|
{logs.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>
|
||||||
|
) : (
|
||||||
|
logs.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={`text-xs font-black ${
|
||||||
|
log.action.includes('CHECK_IN') ? "text-green-500" : (log.action.includes('TRASH') ? "text-rose-500" : "text-amber-500")
|
||||||
|
}`}>
|
||||||
|
{log.action}
|
||||||
|
</p>
|
||||||
|
<span className="text-xs font-bold text-slate-500">by</span>
|
||||||
|
<span className="text-xs 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-xs text-slate-600 font-mono">
|
||||||
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
{log.details && (
|
||||||
|
<span className="text-xs 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={`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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
frontend/components/PageShell.tsx
Normal file
68
frontend/components/PageShell.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { inventoryApi } from '@/lib/api';
|
||||||
|
import IdentityCheckOverlay from '@/components/IdentityCheckOverlay';
|
||||||
|
import BottomNav from '@/components/BottomNav';
|
||||||
|
import { Toaster, toast } from 'react-hot-toast';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
interface PageShellProps {
|
||||||
|
children: ReactNode;
|
||||||
|
requireAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageShell({ children, requireAdmin = false }: PageShellProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [currentUser, setCurrentUser] = useState<any | null>(null);
|
||||||
|
const [showUserSelect, setShowUserSelect] = useState(false);
|
||||||
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
const savedUser = localStorage.getItem('inventory_user');
|
||||||
|
|
||||||
|
if (savedUser) {
|
||||||
|
const user = JSON.parse(savedUser);
|
||||||
|
setCurrentUser(user);
|
||||||
|
|
||||||
|
// Admin check
|
||||||
|
if (requireAdmin && user.role !== 'admin') {
|
||||||
|
toast.error("Access Denied: Admin role required");
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Redirect to dedicated login page if not authenticated
|
||||||
|
if (pathname !== '/login') {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh users list if needed for admin contexts
|
||||||
|
if (requireAdmin) {
|
||||||
|
inventoryApi.getUsers().then(setUsers).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [requireAdmin, router, pathname]);
|
||||||
|
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
// Prevent flicker by not rendering background if we're redirecting to login
|
||||||
|
if (!currentUser && pathname !== '/login') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col">
|
||||||
|
<Toaster position="top-center" />
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div className="flex-1 pb-32">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
{pathname !== '/login' && <BottomNav currentUser={currentUser} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,11 @@ export const inventoryApi = {
|
|||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getStats: async () => {
|
||||||
|
const res = await axios.get(`${getBackendUrl()}/items/stats`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
syncBulkOperations: async (userId: number, operations: any[]) => {
|
syncBulkOperations: async (userId: number, operations: any[]) => {
|
||||||
const url = `${getBackendUrl()}/operations/bulk-sync`;
|
const url = `${getBackendUrl()}/operations/bulk-sync`;
|
||||||
try {
|
try {
|
||||||
@@ -93,6 +98,24 @@ export const inventoryApi = {
|
|||||||
const res = await axios.delete(`${getBackendUrl()}/users/${userId}`);
|
const res = await axios.delete(`${getBackendUrl()}/users/${userId}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateUser: async (userId: number, data: any) => {
|
||||||
|
const res = await axios.put(`${getBackendUrl()}/users/${userId}`, data);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getLdapConfig: async () => {
|
||||||
|
const res = await axios.get(`${getBackendUrl()}/users/ldap-config`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
updateLdapConfig: async (config: any) => {
|
||||||
|
const res = await axios.post(`${getBackendUrl()}/users/ldap-config`, config);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
testLdapConnection: async (config: any) => {
|
||||||
|
const res = await axios.post(`${getBackendUrl()}/users/test-ldap`, config);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Categories
|
// Categories
|
||||||
getCategories: async () => {
|
getCategories: async () => {
|
||||||
@@ -103,6 +126,10 @@ export const inventoryApi = {
|
|||||||
const res = await axios.post(`${getBackendUrl()}/categories/`, data);
|
const res = await axios.post(`${getBackendUrl()}/categories/`, data);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
updateCategory: async (id: number, data: any) => {
|
||||||
|
const res = await axios.put(`${getBackendUrl()}/categories/${id}`, data);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
deleteCategory: async (id: number) => {
|
deleteCategory: async (id: number) => {
|
||||||
const res = await axios.delete(`${getBackendUrl()}/categories/${id}`);
|
const res = await axios.delete(`${getBackendUrl()}/categories/${id}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
|
|||||||
6
frontend/lib/utils.ts
Normal file
6
frontend/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
54
scratch/ldap_discovery.py
Normal file
54
scratch/ldap_discovery.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import ldap3
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def discover_ldap(server_ip, port=3890):
|
||||||
|
server_uri = f"ldap://{server_ip}:{port}"
|
||||||
|
print(f"Connecting to {server_uri}...")
|
||||||
|
|
||||||
|
server = ldap3.Server(server_uri, get_info=ldap3.ALL)
|
||||||
|
conn = ldap3.Connection(server, auto_bind=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not conn.open():
|
||||||
|
print("Could not open connection.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\n--- Root DSE Information ---")
|
||||||
|
if server.info:
|
||||||
|
print(f"Naming Contexts: {server.info.naming_contexts}")
|
||||||
|
print(f"Supported LDAP Versions: {server.info.supported_ldap_versions}")
|
||||||
|
|
||||||
|
# For LLDAP, the namingContexts is usually dc=example,dc=com or similar
|
||||||
|
if server.info.naming_contexts:
|
||||||
|
base_dn = server.info.naming_contexts[0]
|
||||||
|
print(f"Detected Base DN: {base_dn}")
|
||||||
|
|
||||||
|
# Now try to explore structure (if allowed)
|
||||||
|
print(f"\nSearching for users and groups in {base_dn} (Anonymous Search)...")
|
||||||
|
|
||||||
|
# Search for user 'bede'
|
||||||
|
conn.search(base_dn, '(uid=bede)', attributes=['*'])
|
||||||
|
if conn.entries:
|
||||||
|
print(f"Found User 'bede': {conn.entries[0].entry_dn}")
|
||||||
|
else:
|
||||||
|
print("Could not find user 'bede' via anonymous search.")
|
||||||
|
|
||||||
|
# Search for group 'inventory'
|
||||||
|
conn.search(base_dn, '(cn=inventory)', attributes=['*'])
|
||||||
|
if conn.entries:
|
||||||
|
print(f"Found Group 'inventory': {conn.entries[0].entry_dn}")
|
||||||
|
print(f"Attributes: {conn.entries[0].entry_attributes}")
|
||||||
|
else:
|
||||||
|
print("Could not find group 'inventory' via anonymous search.")
|
||||||
|
else:
|
||||||
|
print("No namingContexts found.")
|
||||||
|
else:
|
||||||
|
print("Could not retrieve Root DSE info.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
finally:
|
||||||
|
conn.unbind()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
discover_ldap("192.168.84.107")
|
||||||
Reference in New Issue
Block a user