Compare commits

...

3 Commits

Author SHA1 Message Date
73ab38d237 Build [v1.9.24] 2026-04-15 15:33:14 +03:00
Daniel Bedeleanu
f751cc20a7 feat: implement strict AI selection (Gemini/Claude), secure API key management, and Admin UI standardization [v1.9.23] 2026-04-15 15:24:06 +03:00
Daniel Bedeleanu
1893c4f38b modificari mari 2026-04-15 15:20:45 +03:00
19 changed files with 1176 additions and 907 deletions

View File

@@ -0,0 +1,6 @@
---
trigger: always_on
glob:
description:
---

View File

@@ -33,7 +33,8 @@
"Bash(bash *)",
"Bash(npm run *)",
"Bash(npm test *)",
"Bash(python3 *)"
"Bash(python3 *)",
"Bash(ls -lh aInventory-PROD-v*.zip)"
]
}
}

View File

@@ -12,7 +12,7 @@ A unified system to maintain an inventory of "items" and their quantities, inclu
- **Database:** SQLite (SQLAlchemy) - Local file-based persistence
- **Validation:** Pydantic v2
- **Auth:** Hybrid LDAP (python-ldap) + PBKDF2 local password hash caching
- **AI Engine:** Google GenAI SDK (Gemini 2.0 Flash) - Location: `backend/ai/`
- **AI Engine:** Google GenAI SDK (Gemini 2.0 Flash) & Anthropic SDK (Claude 3.5 Sonnet) - Location: `backend/ai/`
### 2.2 Frontend (Web & PWA)
- **Architecture:** Next.js 15+ (App Router)
@@ -32,7 +32,7 @@ A unified system to maintain an inventory of "items" and their quantities, inclu
- **PWA Deployment:** `next-pwa` (Service Workers + Manifest.json)
- **HTTPS Proxy:** `caddy` or `local-ssl-proxy` (Port 8909)
- **Servers:** Frontend (Port 8907), Backend (Port 8906)
- **Configuration:** Centrally managed via root `inventory.env` (Network/CORS/API Keys) and `config/` directory (LDAP, Caddyfile).
- **Configuration:** Centrally managed via root `inventory.env` (Network/CORS/API Keys), `config/` directory (LDAP, Caddyfile), and a dynamic `ConfigManager` (`backend/config_manager.py`) for runtime environment and AI provider settings.
## 3. Data Models & Entities
- **Item:** Name, Category Group (Structured), Item Type (Specific), Quantity, Barcode, Part Number, Box Label (Association).
@@ -43,7 +43,8 @@ A unified system to maintain an inventory of "items" and their quantities, inclu
## 4. Scanning & Optimization Strategy (Crucial)
### 4.1 AI Usage Policy
- **Routine Operations (Check-in/Out):** Executes entirely on the local device unconditionally using `html5-qrcode` ($0 cost). No AI is allowed here.
- **New Item Onboarding (AI Label OCR):** Uses cloud AI (`gemini-2.0-flash`). The user takes a photo, AI extracts data based on strict templates.
- **New Item Onboarding (AI Label OCR):** Uses cloud AI (`gemini-2.0-flash` or `claude-3-5-sonnet`). The user takes a photo, AI extracts data based on strict templates.
- **AI Provider Selection (v1.9.23):** Administrators can choose between Gemini and Claude via the Admin Dashboard. API keys are managed securely in the environment.
- **AI Box Discovery Mode (v1.6.0):** Supports specialized `mode="box"` prompt that focuses exclusively on prominent container names/hand-written labels, ignoring technical spec noise.
- **Validation Mask:** AI-extracted data is NEVER saved directly. It is presented in a validation UI for human confirmation.
@@ -103,3 +104,9 @@ To ensure enterprise-grade protection, the following policies are enforced:
To ensure deployment stability on macOS environments with potentially broken developer tool links (`xcode-select` errors):
- **Direct Binary Mapping:** The system bypasses path resolution by using a hardcoded direct link to the Git binary in `.git_path` (`/Library/Developer/CommandLineTools/usr/bin/git`).
- **Persistence Mandate:** This path is protected by mandatory AI rules and must never be removed or modified to ensure `save-version` and automated deployment scripts remain functional.
106:
107: ## 8. Multi-AI Engine & Dynamic Configuration (v1.9.23)
108: To enhance extraction flexibility and system resilience:
109: - **Unified AI Core:** The backend uses an abstraction layer to handle multiple AI providers (Gemini and Claude).
110: - **Dynamic Configuration:** System settings (AI Provider, API Keys, Backup Policies) are managed via `backend/config_manager.py`, allowing real-time updates without restarting the container.
111: - **Admin Standardization:** The Admin Dashboard features a standardized configuration UI with secure field masking for sensitive credentials.

View File

@@ -49,6 +49,11 @@ To generate a clean production package and snapshot the current state:
- Accessible markup with `role="status"` and `aria-hidden` attributes
- **Pages Updated:** Inventory (Categories, Item Types, Total Boxes), Logs (Total Events, Check in/out), Admin (Local Archives)
- **Tested on:** iPhone SE (375px), iPhone 12 (390px), iPhone 14 Pro Max (430px), tablets (768px), desktop (1024px)
- **Multi-AI Selection & Secure Config (v1.9.23):**
- Support for both **Google Gemini** and **Anthropic Claude** for AI label extraction.
- Dynamically configurable AI providers via the Admin Dashboard.
- Centralized `ConfigManager` for secure API key and environment management.
- **Admin UI Standardization:** Refactored Admin page with unified StatCards and secure input masking for system credentials.
## 🏗 Technical Overview
* **Backend:** FastAPI (Python 3.12+)

View File

@@ -138,6 +138,7 @@ Ensure you have internet connectivity and wait a moment. Synchronization happens
- The image size must not exceed 10 MB.
- The application supports JPEG, PNG, WebP, and GIF formats.
- If the AI service is unavailable, try again later or contact your administrator.
- **AI Provider Toggle:** In the Admin Dashboard, you can choose between **Gemini** and **Claude** for label extraction. If one provider is slow or failing, your administrator can switch to the other seamlessly.
---
@@ -149,5 +150,5 @@ For detailed technical documentation, see the [Project Architecture](../PROJECT_
---
**Version:** v1.9.19
**Last Updated:** 2026-04-14
**Version:** v1.9.24
**Last Updated:** 2026-04-15

View File

@@ -73,8 +73,18 @@ def extract_label_info(image_bytes: bytes, mode: str = "item"):
# 3. Final fallback to hardcoded default
prompt = "Extract technical specs. Return JSON with name, category, description, connector, size, color, part_number, ocr_text, quantity."
# 1. Try Gemini
result = gemini.extract(image_bytes, prompt)
# 0. Get Active Provider from DB
provider_setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first()
active_provider = provider_setting.value if provider_setting else "gemini"
# 1. Execute extraction based on selection
result = None
if active_provider == "claude":
print(f"📡 Using Anthropic Claude for extraction...")
result = claude.extract(image_bytes, prompt)
else:
print(f"📡 Using Google Gemini for extraction...")
result = gemini.extract(image_bytes, prompt)
if result:
# Check if AI returned a list of items or a singular object

90
backend/config_manager.py Normal file
View File

@@ -0,0 +1,90 @@
import os
from dotenv import load_dotenv
class ConfigManager:
"""Safely manages multi-line .env files without corrupting other content."""
@staticmethod
def get_root_env_path():
# backend/config_manager.py -> backend/ -> /
base_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(base_dir)
return os.path.join(project_root, "inventory.env")
@staticmethod
def update_keys(updates: dict):
"""
Updates specific keys in inventory.env.
Preserves comments and order where possible.
Appends new keys at the end if not found.
"""
env_path = ConfigManager.get_root_env_path()
if not os.path.exists(env_path):
# Create a basic file if it doesn't exist (unlikely in this project)
with open(env_path, 'w', encoding='utf-8') as f:
f.write("# TFM aInventory — Generated Configuration\n")
with open(env_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
new_lines = []
keys_to_process = set(updates.keys())
processed_keys = set()
for line in lines:
trimmed = line.strip()
# Skip empty lines or comments when matching
if not trimmed or trimmed.startswith('#'):
new_lines.append(line)
continue
# Check if this line is a key assignment we want to update
found_match = False
for key in keys_to_process:
if trimmed.startswith(f"{key}="):
new_lines.append(f"{key}={updates[key]}\n")
processed_keys.add(key)
found_match = True
break
if not found_match:
new_lines.append(line)
# Append keys that weren't found in the file
missing_keys = keys_to_process - processed_keys
if missing_keys:
if new_lines and not new_lines[-1].endswith('\n'):
new_lines.append('\n')
if new_lines and not new_lines[-1].strip() == '':
new_lines.append('\n')
new_lines.append("# --- Automatically Added Keys ---\n")
for key in missing_keys:
new_lines.append(f"{key}={updates[key]}\n")
with open(env_path, 'w', encoding='utf-8') as f:
f.writelines(new_lines)
# Force reload environment variables for the current process
load_dotenv(env_path, override=True)
return True
@staticmethod
def get_masked_key(key_name: str):
"""Returns a masked version of the environment variable."""
val = os.environ.get(key_name)
if not val:
return None
# Determine prefix based on key type
prefix = ""
if key_name == "GEMINI_API_KEY":
prefix = "G-"
elif key_name == "CLAUDE_API_KEY":
prefix = "sk-"
if len(val) <= 8:
return f"{prefix}****"
return f"{prefix}****{val[-4:]}"

View File

@@ -145,7 +145,8 @@ def startup_event():
defaults = {
"backup_retention_count": "10",
"backup_schedule_hour": "3",
"backup_schedule_freq_days": "1"
"backup_schedule_freq_days": "1",
"ai_provider": "gemini"
}
for key, val in defaults.items():
if not db.query(models.SystemSetting).filter(models.SystemSetting.key == key).first():

View File

@@ -189,3 +189,126 @@ def update_ai_prompt(
db.commit()
return {"status": "success", "file_updated": os.path.exists(PROMPT_FILE_PATH)}
from ..config_manager import ConfigManager
@router.get("/settings/ai")
def get_ai_config(
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Check AI provider status and active provider."""
gemini_key = os.environ.get("GEMINI_API_KEY")
claude_key = os.environ.get("CLAUDE_API_KEY")
# Get active provider from DB
provider_setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first()
active_provider = provider_setting.value if provider_setting else "gemini"
return {
"active_provider": active_provider,
"providers": [
{
"id": "gemini",
"name": "Google Gemini 2.0",
"configured": bool(gemini_key),
"active": active_provider == "gemini",
"masked_key": ConfigManager.get_masked_key("GEMINI_API_KEY")
},
{
"id": "claude",
"name": "Anthropic Claude 3.5",
"configured": bool(claude_key),
"active": active_provider == "claude",
"masked_key": ConfigManager.get_masked_key("CLAUDE_API_KEY")
}
]
}
@router.post("/settings/ai-keys")
def update_ai_keys(
payload: dict,
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Update AI API keys in inventory.env."""
gemini_key = payload.get("gemini_api_key")
claude_key = payload.get("claude_api_key")
updates = {}
if gemini_key:
updates["GEMINI_API_KEY"] = gemini_key
if claude_key:
updates["CLAUDE_API_KEY"] = claude_key
if not updates:
return {"status": "no_change"}
ConfigManager.update_keys(updates)
return {
"status": "success",
"gemini_configured": bool(os.environ.get("GEMINI_API_KEY")),
"claude_configured": bool(os.environ.get("CLAUDE_API_KEY"))
}
@router.post("/settings/test-ai-key")
def test_ai_key(
payload: dict,
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Test AI API key connectivity."""
provider = payload.get("provider")
key = payload.get("key")
if not provider or provider not in ["gemini", "claude"]:
raise HTTPException(status_code=400, detail="Invalid provider")
# If key is masked or empty, use the one from environment
if not key or "****" in key:
key = os.environ.get("GEMINI_API_KEY" if provider == "gemini" else "CLAUDE_API_KEY")
if not key:
raise HTTPException(status_code=400, detail="No API key provided or configured")
try:
if provider == "gemini":
from google import genai
client = genai.Client(api_key=key, http_options={'api_version': 'v1beta'})
# Listing models is the standard way to check key validity
client.models.list()
return {"status": "success", "message": "Google Gemini API connection verified!"}
elif provider == "claude":
import anthropic
client = anthropic.Anthropic(api_key=key)
# Claude also supports model listing for key verification
client.models.list(limit=1)
return {"status": "success", "message": "Anthropic Claude API connection verified!"}
except Exception as e:
error_msg = str(e)
if "API_KEY_INVALID" in error_msg or "invalid_api_key" in error_msg:
error_msg = "The API key provided is invalid."
elif "PERMISSION_DENIED" in error_msg:
error_msg = "Permission denied. Check if the key has correct permissions."
raise HTTPException(status_code=400, detail=f"{provider.capitalize()} Test Failed: {error_msg}")
@router.post("/settings/ai")
def update_ai_provider(
payload: dict,
db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin)
):
"""Update the active AI provider."""
provider = payload.get("provider")
if provider not in ["gemini", "claude"]:
raise HTTPException(status_code=400, detail="Invalid provider. Must be 'gemini' or 'claude'.")
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first()
if existing:
existing.value = provider
else:
db.add(models.SystemSetting(key="ai_provider", value=provider))
db.commit()
return {"status": "success", "active_provider": provider}

View File

@@ -1,6 +1,6 @@
{
"version": "1.9.22",
"last_build": "2026-04-15-1141",
"codename": "TSFix",
"commit": "10d75619"
"version": "1.9.24",
"last_build": "2026-04-15-1533",
"codename": "MultiAI",
"commit": "f751cc20"
}

File diff suppressed because it is too large Load Diff

View File

@@ -248,13 +248,13 @@ export default function InventoryPage() {
{existingBoxes.map(b => <option key={b} value={b} />)}
</datalist>
<header className="flex items-center gap-5 mb-10">
<div className="p-4 bg-primary/10 rounded-[2rem] text-primary border border-primary/20 shadow-xl shadow-primary/5">
<Package size={32} />
<header className="flex items-center gap-4 mb-6 md:mb-10">
<div className="p-3 md:p-4 bg-primary/10 rounded-3xl text-primary border border-primary/20 shadow-xl shadow-primary/5">
<Package size={28} className="md:w-8 md:h-8" />
</div>
<div>
<h1 className="text-3xl font-black tracking-tight text-white">Inventory Catalog</h1>
<p className="text-xs text-slate-500 font-bold mt-1">Enterprise Stock Overview</p>
<h1 className="text-2xl md:text-3xl font-black tracking-tight text-white leading-tight">Inventory Catalog</h1>
<p className="text-[10px] md:text-xs text-slate-500 font-bold mt-0.5">Enterprise Stock Overview</p>
</div>
<button
onClick={() => setShowBoxManager(true)}
@@ -265,9 +265,9 @@ export default function InventoryPage() {
</button>
</header>
<div className="w-full space-y-8">
<div className="w-full space-y-6 md:space-y-8">
{/* Stats Dashboard */}
<section className="grid grid-cols-2 md:grid-cols-4 gap-3">
<section className="grid grid-cols-2 md:grid-cols-4 gap-2.5 md:gap-4">
<StatCard
label="Categories"
value={stats?.total_categories || categories.length}
@@ -289,22 +289,22 @@ export default function InventoryPage() {
<div className="relative">
<input
type="text"
placeholder="Search categories or items..."
placeholder="Search catalog..."
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"
className="w-full bg-slate-900 border border-slate-800 rounded-2xl py-3.5 pr-4 pl-11 text-sm focus:border-primary outline-none transition-all placeholder:text-slate-600"
/>
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-600">
🔍
<Search size={18} />
</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 key={cat} className="bg-slate-900/40 border border-slate-800/50 rounded-3xl overflow-hidden transition-all duration-300">
<div
className="w-full p-5 flex items-center justify-between hover:bg-slate-900/60 transition-colors cursor-pointer"
className="w-full p-4 md: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">
@@ -385,8 +385,8 @@ export default function InventoryPage() {
{/* 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="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm: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-x border-t sm:border border-slate-800 rounded-t-[2.5rem] sm:rounded-[2.5rem] shadow-2xl p-5 sm:p-8 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 flex items-center gap-2">
{isEditing ? "Edit Item" : selectedItem.name}
@@ -611,7 +611,7 @@ export default function InventoryPage() {
<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",
"w-full py-4 sm: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" :

View File

@@ -74,9 +74,10 @@ export default function LogsPage() {
// Calculate stats
const totalCount = auditLogs.length;
const inCount = auditLogs.filter(l => l.action.includes('IN')).length;
const outCount = auditLogs.filter(l => l.action.includes('OUT') || l.action.includes('TRASH')).length;
const inCount = auditLogs.filter(l => l.action.includes('IN') || l.action.includes('ADD')).length;
const outCount = auditLogs.filter(l => l.action.includes('OUT') || l.action.includes('TRASH') || l.action.includes('DELETE')).length;
const criticalLogsCount = auditLogs.filter(l => l.action.includes('DELETE') || l.action.includes('TRASH')).length;
const mostActiveUser = auditLogs.length > 0 ?
Object.entries(auditLogs.reduce((acc: any, curr) => {
const user = curr.username || 'System';
@@ -86,145 +87,143 @@ export default function LogsPage() {
return (
<PageShell>
<main className="p-4 md:p-8 max-w-7xl mx-auto space-y-12">
<header className="space-y-8">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6">
<div className="flex items-center gap-5">
<div className="p-4 bg-primary/10 rounded-[2rem] text-primary border border-primary/20 shadow-xl shadow-primary/5">
<History size={32} />
</div>
<div>
<h1 className="text-3xl font-black tracking-tight text-white">Audit Dashboard</h1>
<p className="text-xs text-slate-500 font-bold mt-1">Real-time Intervention Tracking</p>
</div>
<main className="p-3 md:p-8 max-w-7xl mx-auto space-y-6 md:space-y-10 mb-20">
<header className="flex flex-col sm:flex-row sm:items-end justify-between gap-6">
<div className="flex items-center gap-4">
<div className="p-3 md:p-4 bg-primary/10 rounded-2xl text-primary border border-primary/20 shadow-xl shadow-primary/5">
<History size={28} className="md:w-8 md:h-8" />
</div>
<div className="flex gap-2">
<button
onClick={loadData}
disabled={loading}
className="flex items-center gap-2 px-6 py-3 bg-slate-900 border border-slate-800 text-slate-400 hover:text-white rounded-2xl text-xs font-black transition-all active:scale-95 disabled:opacity-50"
>
<RefreshCw size={14} className={cn(loading && "animate-spin")} />
Refresh Stream
</button>
<div>
<h1 className="text-2xl md:text-3xl font-black tracking-tight text-white leading-tight">Operations Audit</h1>
<p className="text-[10px] md:text-xs text-slate-500 font-bold uppercase tracking-widest mt-0.5">Real-time Intervention Tracking</p>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
label="Total Events"
value={totalCount}
icon={Activity}
/>
<StatCard
label="Check in"
value={inCount}
icon={ArrowDownCircle}
/>
<StatCard
label="Check out"
value={outCount}
icon={ArrowUpCircle}
/>
<div className="flex justify-between items-center gap-2 p-4 bg-slate-900 rounded-lg" role="status">
<div className="flex items-center gap-2 min-w-0">
<User className="w-5 h-5 text-primary flex-shrink-0" aria-hidden="true" />
<span className="text-sm md:text-base text-slate-400 truncate">
Top Operator
</span>
</div>
<span className="text-lg md:text-xl font-black text-white whitespace-nowrap" title={mostActiveUser}>
{mostActiveUser}
</span>
</div>
</div>
<div className="flex flex-col md:flex-row gap-4">
<div className="relative group flex-1">
<Search className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-primary transition-colors" size={20} />
<input
type="text"
placeholder="Search logs by item name, user, or action details..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-slate-950/50 border border-slate-900 focus:border-primary/50 rounded-3xl py-5 pl-14 pr-6 text-sm text-white placeholder:text-slate-700 outline-none transition-all shadow-2xl"
/>
</div>
<div className="flex items-center gap-1.5 p-1.5 bg-slate-900/50 border border-slate-900 rounded-[1.5rem] overflow-x-auto no-scrollbar">
{[
{ label: 'All', value: 'ALL' },
{ label: 'Check in', value: 'CHECK_IN' },
{ label: 'Check out', value: 'CHECK_OUT' },
{ label: 'Trash', value: 'TRASH' },
{ label: 'Create', value: 'CREATE' },
{ label: 'System', value: 'DB' }
].map(action => (
<button
key={action.value}
onClick={() => setFilterAction(action.value)}
className={cn(
"px-4 py-2.5 rounded-xl text-xs font-bold transition-all whitespace-nowrap",
filterAction === action.value
? "bg-primary text-white shadow-lg shadow-primary/20"
: "text-slate-500 hover:text-slate-300 hover:bg-slate-800"
)}
>
{action.label}
</button>
))}
</div>
<div className="flex gap-2 ml-auto sm:ml-0">
<button
onClick={loadData}
disabled={loading}
className="flex items-center gap-2 px-5 py-2.5 bg-slate-900 border border-slate-800 text-slate-400 hover:text-white rounded-2xl text-[10px] font-black transition-all active:scale-95 disabled:opacity-50 shadow-xl"
>
<RefreshCw size={14} className={cn(loading && "animate-spin")} />
Sync Logs
</button>
</div>
</header>
<section className="space-y-4">
{/* Stats Grid */}
<section className="grid grid-cols-2 lg:grid-cols-4 gap-2.5 md:gap-4">
<StatCard
label="Total Events"
value={totalCount}
icon={Activity}
/>
<StatCard
label="Inbound"
value={inCount}
icon={ArrowDownCircle}
/>
<StatCard
label="Outbound"
value={outCount}
icon={ArrowUpCircle}
/>
<StatCard
label="Integrity"
value={totalCount - criticalLogsCount}
icon={History}
/>
</section>
<section className="space-y-6">
<div className="relative group">
<input
type="text"
placeholder="Filter by user, action or asset..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-slate-900/40 backdrop-blur-xl border border-slate-800 rounded-2xl py-3.5 pr-4 pl-12 text-sm focus:border-primary outline-none transition-all placeholder:text-slate-600 shadow-2xl"
/>
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-600 transition-colors group-focus-within:text-primary">
<Search size={18} />
</div>
</div>
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar pb-1 px-1 -mx-1">
<button
onClick={() => setFilterAction('ALL')}
className={cn(
"px-5 py-2 rounded-full text-[10px] font-black transition-all whitespace-nowrap border uppercase tracking-widest",
filterAction === 'ALL'
? "bg-white text-slate-950 border-white shadow-xl shadow-white/10"
: "bg-slate-900/50 text-slate-500 border-slate-800 hover:border-slate-700"
)}
>
All Streams
</button>
{['ADD', 'REMOVE', 'ADJUST', 'DELETE', 'LOGIN'].map((f) => (
<button
key={f}
onClick={() => setFilterAction(f)}
className={cn(
"px-5 py-2 rounded-full text-[10px] font-black transition-all whitespace-nowrap border uppercase tracking-widest",
filterAction === f
? "bg-primary text-white border-primary shadow-xl shadow-primary/10"
: "bg-slate-900/50 text-slate-500 border-slate-800 hover:border-slate-700"
)}
>
{f}
</button>
))}
</div>
</section>
<section className="space-y-3">
{loading ? (
<div className="flex flex-col items-center justify-center py-32 text-slate-600 gap-4 animate-pulse">
<div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-[10px] font-black tracking-widest">Securing Audit Stream...</p>
<div className="w-10 h-10 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
<p className="text-[10px] font-black tracking-widest uppercase italic">Securing Audit Stream...</p>
</div>
) : filteredLogs.length === 0 ? (
<div className="bg-slate-900/20 border border-slate-800/50 border-dashed rounded-[3rem] py-24 flex flex-col items-center justify-center text-center gap-6">
<div className="w-20 h-20 bg-slate-900 rounded-[2rem] flex items-center justify-center text-slate-700 border border-slate-800 shadow-inner">
<Search size={40} />
<div className="bg-slate-900/20 border border-slate-800/50 border-dashed rounded-[2.5rem] py-20 flex flex-col items-center justify-center text-center gap-6">
<div className="w-16 h-16 bg-slate-900 rounded-2xl flex items-center justify-center text-slate-700 border border-slate-800">
<Search size={32} />
</div>
<div>
<p className="text-xl font-black text-slate-300">No interventions found</p>
<p className="text-xs text-slate-600 font-bold mt-2">Try adjusting your filters or search query</p>
<p className="text-xl font-black text-slate-300 tracking-tight">No events found</p>
<p className="text-xs text-slate-600 font-bold mt-1">Refine your strategic filters</p>
</div>
</div>
) : (
<div className="grid gap-4">
<div className="grid gap-2.5">
{filteredLogs.map((log) => (
<button
key={log.id}
onClick={() => setSelectedLog(log)}
className="w-full text-left bg-slate-900/30 border border-slate-800/20 p-2 px-4 rounded-xl flex items-center justify-between gap-4 hover:bg-slate-900/60 hover:border-slate-700/50 transition-all group active:scale-[0.99] relative overflow-hidden shadow-sm"
className="w-full text-left bg-slate-900/40 backdrop-blur-md border border-slate-800/30 p-3 px-4 rounded-2xl flex items-center justify-between gap-4 hover:bg-slate-800/40 hover:border-primary/30 transition-all group active:scale-[0.99] relative overflow-hidden shadow-sm"
>
<div className="flex-1 min-w-0 z-10 flex items-center gap-4">
{/* Compact Action Badge */}
<div className={cn(
"text-[9px] font-black px-2 py-0.5 rounded-md border min-w-[70px] text-center",
log.action.includes('CHECK_IN') ? "bg-green-500/5 text-green-500 border-green-500/20" :
(log.action.includes('TRASH') ? "bg-rose-500/5 text-rose-500 border-rose-500/20" :
(log.action.includes('DB') ? "bg-sky-500/5 text-sky-400 border-sky-500/20" :
"text-[8px] font-black px-2 py-1 rounded-lg border min-w-[75px] text-center uppercase tracking-tighter",
log.action.includes('CHECK_IN') ? "bg-green-500/10 text-green-500 border-green-500/20" :
(log.action.includes('TRASH') ? "bg-rose-500/10 text-rose-500 border-rose-500/20" :
(log.action.includes('DB') ? "bg-sky-500/10 text-sky-400 border-sky-500/20" :
(log.action.includes('DELETE') ? "bg-red-500/10 text-red-500 border-red-500/30" :
(log.action.includes('CREATE') ? "bg-indigo-500/5 text-indigo-400 border-indigo-500/20" : "bg-amber-500/5 text-amber-500 border-amber-500/20"))))
(log.action.includes('CREATE') ? "bg-indigo-500/10 text-indigo-400 border-indigo-500/20" : "bg-primary/10 text-primary border-primary/20"))))
)}>
{log.action.replace('_', ' ')}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-bold text-white group-hover:text-primary transition-colors truncate">
<h3 className="text-sm font-bold text-slate-100 group-hover:text-primary transition-colors truncate">
{log.resolved_name}
</h3>
<div className="flex items-center gap-2 opacity-80">
<span className="text-[9px] font-black text-amber-500 tracking-tight">{log.username || 'System'}</span>
<span className="w-1 h-1 rounded-full bg-slate-700" />
<span className="text-[9px] text-slate-500 font-mono">
{new Date(log.timestamp).toLocaleDateString()} · {new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[9px] font-black text-slate-500 uppercase tracking-tighter shrink-0">{log.username || 'System'}</span>
<span className="w-0.5 h-0.5 rounded-full bg-slate-800 shrink-0" />
<span className="text-[9px] text-slate-600 font-bold tabular-nums truncate">
{new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · {new Date(log.timestamp).toLocaleDateString()}
</span>
</div>
</div>
@@ -232,14 +231,12 @@ export default function LogsPage() {
<div className="shrink-0 flex items-center gap-3 z-10">
<div className={cn(
"text-xl font-black tabular-nums min-w-[40px] text-right",
(log.quantity_change || 0) > 0 ? "text-green-500" : ((log.quantity_change || 0) < 0 ? "text-rose-500" : "text-indigo-400")
"text-lg font-black tabular-nums min-w-[35px] text-right",
(log.quantity_change || 0) > 0 ? "text-green-500" : ((log.quantity_change || 0) < 0 ? "text-rose-500" : "text-primary/50")
)}>
{log.quantity_change ? (log.quantity_change > 0 ? `+${log.quantity_change}` : log.quantity_change) : (log.action.includes('DB') ? 'SYS' : '±')}
</div>
</div>
<div className="absolute inset-x-0 bottom-0 h-1 bg-gradient-to-r from-transparent via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
))}
</div>
@@ -248,48 +245,48 @@ export default function LogsPage() {
{/* Selected Log Modal */}
{selectedLog && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-xl animate-in fade-in duration-300">
<div className="bg-slate-900 border border-slate-800 rounded-[3rem] p-10 max-w-lg w-full shadow-2xl space-y-8 animate-in zoom-in-95 duration-300">
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4 bg-slate-950/90 backdrop-blur-xl animate-in fade-in duration-300">
<div className="bg-slate-900 border-t sm:border border-slate-800 rounded-t-[2.5rem] sm:rounded-[3rem] p-6 sm:p-10 max-w-lg w-full shadow-2xl space-y-8 animate-in slide-in-from-bottom-10 duration-300 overflow-hidden">
<div className="flex justify-between items-start">
<div className="space-y-1">
<div className="space-y-1 pr-4">
<div className={cn(
"text-[10px] font-black px-4 py-1.5 rounded-full border inline-block",
"text-[9px] font-black px-3 py-1 rounded-full border inline-block uppercase tracking-widest",
selectedLog.action.includes('CHECK_IN') ? "bg-green-500/10 text-green-500 border-green-500/30" :
(selectedLog.action.includes('TRASH') ? "bg-rose-500/10 text-rose-500 border-rose-500/30" : "bg-amber-500/10 text-amber-500 border-amber-500/30")
(selectedLog.action.includes('TRASH') ? "bg-rose-500/10 text-rose-500 border-rose-500/30" : "bg-primary/10 text-primary border-primary/30")
)}>
{selectedLog.action}
</div>
<h2 className="text-3xl font-black text-white tracking-tight leading-tight pt-2">
<h2 className="text-2xl font-black text-white tracking-tight pt-2 leading-tight">
{selectedLog.resolved_name}
</h2>
</div>
<button onClick={() => setSelectedLog(null)} className="p-3 hover:bg-slate-800 rounded-2xl text-slate-500 transition-colors border border-slate-800">
<X size={24} />
<button onClick={() => setSelectedLog(null)} className="p-3 bg-slate-800/50 hover:bg-slate-800 rounded-2xl text-slate-500 transition-colors border border-slate-800 shrink-0 shadow-lg">
<X size={20} />
</button>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-1 bg-slate-950/50 p-4 rounded-2xl border border-slate-800">
<p className="text-[10px] font-black text-slate-600">Operator</p>
<p className="text-sm font-black text-white">{selectedLog.username || 'System Profile'}</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1 bg-slate-950/50 p-4 rounded-2xl border border-slate-800/50">
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest">Protocol Operator</p>
<p className="text-sm font-black text-slate-200">{selectedLog.username || 'Automated Process'}</p>
</div>
<div className="space-y-1 bg-slate-950/50 p-4 rounded-2xl border border-slate-800">
<p className="text-[10px] font-black text-slate-600">Delta</p>
<div className="space-y-1 bg-slate-950/50 p-4 rounded-2xl border border-slate-800/50">
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest">Quantity Delta</p>
<p className={cn(
"text-xl font-black",
"text-xl font-black tabular-nums",
(selectedLog.quantity_change || 0) > 0 ? "text-green-500" : "text-rose-500"
)}>
{selectedLog.quantity_change
? `${selectedLog.quantity_change > 0 ? '+' : ''}${selectedLog.quantity_change} Units`
: (selectedLog.action.includes('DB') ? 'System' : 'No Delta')}
? `${selectedLog.quantity_change > 0 ? '+' : ''}${selectedLog.quantity_change}`
: (selectedLog.action.includes('DB') ? 'SYS' : '±')}
</p>
</div>
</div>
<div className="space-y-4">
<div className="space-y-5">
<div className="space-y-1">
<p className="text-[10px] font-black text-slate-600">Timestamp</p>
<p className="text-sm font-bold text-slate-300 bg-slate-800/30 p-4 rounded-2xl border border-slate-800/50">
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest ml-1">Universal Timestamp</p>
<p className="text-xs font-bold text-slate-400 bg-slate-950/30 p-4 rounded-2xl border border-slate-800/30 tabular-nums">
{new Date(selectedLog.timestamp).toLocaleString(undefined, { dateStyle: 'full', timeStyle: 'medium' })}
</p>
</div>
@@ -298,18 +295,18 @@ export default function LogsPage() {
try {
const snap = JSON.parse(selectedLog.target_snapshot) as Record<string, any>;
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-slate-800" />
<p className="text-[10px] font-black text-slate-700 uppercase tracking-widest">Full Historical Context</p>
<div className="h-px flex-1 bg-slate-800" />
<div className="space-y-4 pt-2">
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-slate-800/50" />
<p className="text-[8px] font-black text-slate-700 uppercase tracking-[0.2em]">Snapshot Record</p>
<div className="h-px flex-1 bg-slate-800/50" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-2 gap-2.5">
{Object.entries(snap).map(([key, val]) => (
(val && key !== 'image_url') ? (
<div key={key} className="bg-slate-950/30 p-3 rounded-xl border border-slate-800/40">
<p className="text-[8px] font-black text-slate-600 uppercase mb-1">{key.replace('_', ' ')}</p>
<p className="text-xs font-bold text-slate-300 truncate" title={String(val)}>{String(val)}</p>
(val && key !== 'image_url' && key !== 'id') ? (
<div key={key} className="bg-slate-950/20 p-3 rounded-xl border border-slate-800/20">
<p className="text-[7px] font-black text-slate-600 uppercase mb-1 tracking-tighter opacity-70">{key.replace('_', ' ')}</p>
<p className="text-[10px] font-bold text-slate-400 truncate" title={String(val)}>{String(val)}</p>
</div>
) : null
))}
@@ -320,29 +317,20 @@ export default function LogsPage() {
})()}
{selectedLog.details && (
<div className="space-y-1">
<p className="text-[10px] font-black text-slate-600">Intervention Details</p>
<div className="bg-primary/5 text-primary/80 p-6 rounded-[2rem] border border-primary/10 text-sm font-bold leading-relaxed italic">
<div className="space-y-1.5">
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest ml-1">Intervention Details</p>
<div className="bg-primary/5 text-primary/80 p-5 rounded-3xl border border-primary/10 text-xs font-bold leading-relaxed italic shadow-inner">
"{selectedLog.details}"
</div>
</div>
)}
{selectedLog.target_item_pn && (
<div className="space-y-1">
<p className="text-[10px] font-black text-slate-600">Historical Part Number</p>
<div className="bg-slate-800/20 text-slate-400 p-4 rounded-2xl border border-slate-800/50 text-xs font-mono">
{selectedLog.target_item_pn}
</div>
</div>
)}
</div>
<button
onClick={() => setSelectedLog(null)}
className="w-full bg-slate-800 hover:bg-slate-700 text-white font-black py-5 rounded-[2rem] transition-all active:scale-95 border border-slate-700"
className="w-full bg-slate-800 hover:bg-slate-700 text-white font-black py-4.5 rounded-2xl transition-all active:scale-95 border border-slate-700 shadow-xl"
>
Close Insights
Close Audit Insight
</button>
</div>
</div>

View File

@@ -25,8 +25,8 @@ export default function BottomNav({
const isAdmin = pathname === '/admin';
return (
<footer className="fixed bottom-0 left-0 right-0 p-4 pb-safe 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">
<footer className="fixed bottom-0 left-0 right-0 py-3 px-2 pb-safe bg-slate-950/80 backdrop-blur-lg border-t border-slate-900/50 z-40 transition-all duration-300">
<div className="max-w-xl mx-auto flex justify-around items-center text-slate-400">
<button
onClick={() => router.push('/')}

View File

@@ -56,143 +56,149 @@ const IdentityCheckOverlay = memo(({ show, users, onAuthenticated }: IdentityChe
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 className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-2xl animate-in fade-in duration-500">
<div className="bg-slate-900/80 border border-slate-800 rounded-[2.5rem] p-8 sm:p-10 max-w-sm w-full shadow-2xl space-y-8 animate-in zoom-in-95 duration-300 relative overflow-hidden">
{/* Subtle accent light */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-40 h-1 bg-gradient-to-r from-transparent via-primary/50 to-transparent blur-sm" />
<div className="text-center space-y-3">
<div className="w-20 h-20 bg-primary/10 text-primary rounded-[2rem] flex items-center justify-center mx-auto mb-6 border border-primary/20 shadow-xl shadow-primary/5">
<Shield size={40} className="italic" />
</div>
<h2 className="text-2xl font-black text-white">Identity Check</h2>
<p className="text-slate-500 text-sm">Select operator profile to continue</p>
<h2 className="text-3xl font-black text-white tracking-tight">Protocol Access</h2>
<p className="text-slate-500 text-[10px] font-black uppercase tracking-widest">Select operator profile to initialize</p>
</div>
<div className="grid gap-3">
<div className="grid gap-3.5">
{!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"
className="bg-slate-800/40 hover:bg-slate-800 border border-slate-800/50 hover:border-primary/40 p-5 rounded-[1.5rem] text-left transition-all group flex items-center justify-between shadow-sm active:scale-[0.98]"
>
<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 className="flex items-center gap-4">
<div className="w-10 h-10 rounded-2xl bg-slate-900 border border-slate-800 flex items-center justify-center text-slate-500 group-hover:text-primary transition-all group-hover:scale-110">
{user.role === 'admin' ? <Shield size={18} /> : <User size={18} />}
</div>
<div>
<p className="text-white font-black text-sm">{user.username}</p>
<p className="text-xs text-slate-500 font-medium mt-1">{user.role}</p>
<p className="text-slate-100 font-black text-sm tracking-tight">{user.username}</p>
<p className="text-[9px] text-slate-600 font-black uppercase tracking-widest mt-0.5">{user.role}</p>
</div>
</div>
<ChevronRight size={16} className="text-slate-600 group-hover:text-primary transition-colors" />
<div className="p-1.5 rounded-full bg-slate-900/50 text-slate-700 group-hover:text-primary group-hover:bg-primary/10 transition-all">
<ChevronRight size={14} />
</div>
</button>
))}
<div className="pt-2">
<div className="pt-4">
<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"
className="w-full flex items-center justify-center gap-3 py-5 rounded-[1.5rem] border border-dashed border-slate-800 text-slate-600 hover:text-primary hover:border-primary/40 hover:bg-primary/5 transition-all font-black text-[10px] uppercase tracking-widest"
>
<Shield size={14} />
Enterprise Login
<Lock size={14} />
Enterprise Directory
</button>
</div>
</>
) : isEnterprise ? (
<div className="space-y-4 animate-in slide-in-from-right-4 duration-300">
<div className="space-y-5 animate-in slide-in-from-bottom-5 duration-300">
<div className="flex justify-between items-center px-1">
<p className="text-xs font-black text-slate-500">Enterprise Account</p>
<p className="text-[10px] font-black text-slate-600 uppercase tracking-widest italic">LDAP Authentication</p>
<button
onClick={() => setIsEnterprise(false)}
className="text-xs font-black text-primary hover:underline"
className="text-[10px] font-black text-primary hover:underline uppercase tracking-tighter"
>
Back to profiles
Profile Swap
</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} />
<div className="relative group">
<User className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-600 group-focus-within:text-primary transition-colors" size={18} />
<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"
className="w-full bg-slate-950/50 border border-slate-800/80 focus:border-primary/50 focus:bg-slate-950 rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm text-slate-100 focus:outline-none transition-all placeholder:text-slate-700 font-mono"
placeholder="DIRECTORY ID"
/>
</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} />
<div className="relative group">
<Lock className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-600 group-focus-within:text-primary transition-colors" size={18} />
<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/50 focus:text-white focus:outline-none transition-all placeholder:text-slate-700 font-mono"
placeholder="Enter password"
className="w-full bg-slate-950/50 border border-slate-800/80 focus:border-primary/50 focus:bg-slate-950 rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm text-slate-100 focus:outline-none transition-all placeholder:text-slate-700 font-mono"
placeholder="••••••••"
/>
</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"
className="w-full bg-primary text-white font-black py-5 rounded-[1.5rem] shadow-2xl shadow-primary/20 hover:shadow-primary/30 active:scale-95 transition-all uppercase text-[10px] tracking-widest border border-primary/20"
>
Sign In
Initialize Session
</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">
<div className="space-y-5 animate-in slide-in-from-bottom-5 duration-300">
<div className="bg-slate-950/50 p-5 rounded-[1.5rem] border border-slate-800 flex items-center justify-between group">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-2xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary shadow-lg">
<Shield size={20} />
</div>
<div>
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest">Active Profile</p>
<p className="text-white font-black tracking-tight">{selectedUserForLogin.username}</p>
</div>
</div>
<button
onClick={() => setSelectedUserForLogin(null)}
className="p-1 hover:bg-slate-700 rounded-lg transition-colors"
className="p-2 hover:bg-slate-800 rounded-xl transition-all text-slate-600 hover:text-rose-500"
title="Change User"
>
<X size={16} className="text-slate-400" />
<X size={18} />
</button>
<div>
<p className="text-xs 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} />
<div className="relative group">
<Lock className="absolute left-5 top-1/2 -translate-y-1/2 text-slate-600 group-focus-within:text-primary transition-colors" size={18} />
<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/50 focus:text-white focus:outline-none transition-all placeholder:text-slate-700 font-mono"
placeholder="Enter password"
className="w-full bg-slate-950/50 border border-slate-800/80 focus:border-primary/50 focus:bg-slate-950 rounded-[1.25rem] py-4.5 pl-14 pr-5 text-sm text-slate-100 focus:outline-none transition-all placeholder:text-slate-700 font-mono tracking-widest"
placeholder="KEY-PHRASE"
/>
</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"
className="w-full bg-primary text-white font-black py-5 rounded-[1.5rem] shadow-2xl shadow-primary/20 hover:shadow-primary/30 active:scale-95 transition-all uppercase text-[10px] tracking-widest border border-primary/20"
>
Verify Identity
Unlock Account
</button>
</div>
)}
</div>
{users.length === 0 && (
<div className="text-center p-8 text-slate-500 animate-pulse">
Initializing users...
<div className="text-center p-8 text-slate-700 animate-pulse font-black text-[10px] uppercase tracking-[0.2em]">
Synchronizing User Tokens...
</div>
)}
</div>

View File

@@ -215,20 +215,21 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
return (
<div className="w-full max-w-md mx-auto flex flex-col gap-6">
{/* Video Viewport Area */}
<div className="relative w-full aspect-square overflow-hidden rounded-[2.5rem] shadow-2xl bg-black border-[3px] border-slate-800 shadow-blue-500/10">
<div className="relative w-full aspect-square overflow-hidden rounded-[2.5rem] shadow-[0_20px_50px_rgba(0,0,0,0.5)] bg-slate-950 border-2 border-slate-800/50">
<div className="absolute inset-0 z-10 pointer-events-none flex items-center justify-center">
<div className="w-[320px] h-[320px] border-2 border-primary/50 rounded-3xl relative">
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-primary rounded-tl-xl" />
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-primary rounded-tr-xl" />
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-primary rounded-bl-xl" />
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-primary rounded-br-xl" />
<div className="w-[85%] h-[85%] border border-primary/30 rounded-[2rem] relative">
<div className="absolute top-0 left-0 w-10 h-10 border-t-4 border-l-4 border-primary rounded-tl-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
<div className="absolute top-0 right-0 w-10 h-10 border-t-4 border-r-4 border-primary rounded-tr-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
<div className="absolute bottom-0 left-0 w-10 h-10 border-b-4 border-l-4 border-primary rounded-bl-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
<div className="absolute bottom-0 right-0 w-10 h-10 border-b-4 border-r-4 border-primary rounded-br-2xl shadow-[0_0_15px_rgba(59,130,246,0.5)]" />
{isStarted && !paused && !isSelecting && (
<div className="absolute top-0 left-0 right-0 h-0.5 bg-primary/50 shadow-[0_0_15px_rgba(59,130,246,0.8)] animate-scan-fast" />
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-primary to-transparent shadow-[0_0_20px_rgba(59,130,246,0.8)] animate-scan-fast" />
)}
</div>
</div>
<div id={scannerId} className="w-full aspect-square bg-slate-900" />
<div id={scannerId} className="w-full h-full bg-slate-900 object-cover" />
{/* Selection UI */}
{isSelecting && capturedImage && (
@@ -253,13 +254,16 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
</div>
</div>
</div>
<div className="p-6 bg-slate-900 border-t border-slate-800 flex flex-col gap-4">
<p className="text-sm font-bold text-center text-primary">Tap the correct text on the label</p>
<div className="p-6 bg-slate-900/90 backdrop-blur-xl border-t border-slate-800 flex flex-col gap-4">
<div className="flex flex-col items-center gap-1">
<p className="text-sm font-black text-white italic text-center">Protocol Detected</p>
<p className="text-[10px] text-slate-500 font-black uppercase tracking-widest text-center">Select identity from label matrix</p>
</div>
<button
onClick={() => { setIsSelecting(false); setCapturedImage(null); setCountdown(4); }}
className="w-full py-4 bg-slate-800 text-white rounded-2xl font-bold text-xs"
className="w-full py-4 bg-slate-800 hover:bg-slate-700 text-white rounded-2xl font-black text-[10px] uppercase tracking-widest transition-all active:scale-95 border border-slate-700"
>
Cancel & rescans
Abort Cycle
</button>
</div>
</div>
@@ -290,8 +294,8 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
</div>
{/* External Controls Area */}
<div className="flex flex-col gap-4 bg-slate-900/40 p-6 rounded-[2rem] border border-slate-800/50">
<div className="flex items-center gap-4 w-full">
<div className="flex flex-col gap-4 bg-slate-900/40 backdrop-blur-md p-5 rounded-[2.5rem] border border-slate-800/50 shadow-2xl">
<div className="flex items-center gap-3 w-full">
{hasZoom && (
<button
onClick={async () => {
@@ -308,26 +312,26 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
setZoom(nextZoom);
}
}}
className="h-16 px-6 bg-slate-800 hover:bg-slate-700 border border-slate-700 text-white rounded-2xl flex flex-col items-center justify-center shadow-lg transition-all active:scale-95"
className="h-14 px-5 bg-slate-800/80 hover:bg-slate-700 border border-slate-700 text-white rounded-2xl flex flex-col items-center justify-center shadow-lg transition-all active:scale-95 shrink-0"
>
<span className="text-xs font-black">{zoom.toFixed(1)}x</span>
<span className="text-[10px] text-primary font-bold">Zoom</span>
<span className="text-xs font-black tabular-nums">{zoom.toFixed(1)}x</span>
<span className="text-[8px] text-primary font-black uppercase tracking-tighter">Zoom</span>
</button>
)}
<div className="flex-1 h-16 bg-slate-800/50 border border-slate-700 rounded-2xl flex items-center justify-center gap-3 px-4 relative overflow-hidden">
<div className="flex-1 h-14 bg-slate-950/40 border border-slate-800/50 rounded-2xl flex items-center justify-center gap-3 px-4 relative overflow-hidden group">
{ocrProcessing ? (
<>
<RefreshCw className="animate-spin text-primary" size={20} />
<span className="text-sm font-bold text-slate-200">Analyzing labels...</span>
<RefreshCw className="animate-spin text-primary" size={18} />
<span className="text-[10px] font-black text-slate-200 uppercase tracking-widest leading-none">Analyzing</span>
</>
) : (
<>
<Search className={cn("text-slate-500", !isStarted && "opacity-20")} size={20} />
<Search className={cn("text-slate-600 transition-colors group-hover:text-primary", !isStarted && "opacity-20")} size={18} />
<div className="flex flex-col">
<span className="text-[10px] text-slate-500 font-bold leading-none">Label Scanning</span>
<span className="text-sm font-black text-primary leading-tight">
{countdown === 0 ? "Scanning..." : `Next scan in ${countdown}s`}
<span className="text-[8px] text-slate-500 font-black leading-none uppercase tracking-widest">Optical Assist</span>
<span className="text-xs font-black text-primary leading-tight uppercase tabular-nums">
{countdown === 0 ? "Scanning" : `${countdown}s Cycle`}
</span>
</div>
</>
@@ -335,16 +339,16 @@ export default function Scanner({ onScanSuccess, onOCRMatch, paused }: ScannerPr
{/* Visual Progress Bar */}
<div
className="absolute bottom-0 left-0 h-1 bg-primary/30 transition-all duration-1000 ease-linear"
className="absolute bottom-0 left-0 h-0.5 bg-primary/40 transition-all duration-1000 ease-linear shadow-[0_0_10px_rgba(59,130,246,0.5)]"
style={{ width: `${((4 - countdown) / 4) * 100}%` }}
/>
</div>
</div>
<div className="w-full flex justify-center items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
<p className="text-[10px] text-slate-500 font-bold">
Barcode auto-scan active
<div className="w-1 h-1 rounded-full bg-green-500 animate-pulse" />
<p className="text-[9px] text-slate-600 font-black uppercase tracking-[0.15em]">
Optical Data Stream Active
</p>
</div>
</div>

View File

@@ -9,15 +9,15 @@ interface StatCardProps {
export default function StatCard({ label, value, icon: Icon }: StatCardProps) {
return (
<div className="flex justify-between items-center gap-2 p-4 bg-slate-900 rounded-lg" role="status">
<div className="flex items-center gap-2 min-w-0">
<div className="flex justify-between items-center gap-2 p-3.5 bg-slate-900/50 backdrop-blur-md border border-slate-800/50 rounded-2xl shadow-sm transition-all hover:bg-slate-900/80" role="status">
<div className="flex items-center gap-2.5 min-w-0">
{Icon && <Icon className="w-5 h-5 text-primary flex-shrink-0" aria-hidden="true" />}
<span className="text-sm md:text-base text-slate-400 truncate">
<span className="text-sm md:text-base text-slate-400 font-medium truncate">
{label}
</span>
</div>
<span className="text-lg md:text-xl font-black text-white whitespace-nowrap">
<span className="text-lg md:text-xl font-black text-white whitespace-nowrap tabular-nums">
{value}
</span>
</div>

View File

@@ -247,5 +247,21 @@ export const inventoryApi = {
updateAiPrompt: async (prompt: string) => {
const res = await axiosInstance.post('/admin/db/settings/prompt', { value: prompt });
return res.data;
},
getAiConfig: async () => {
const res = await axiosInstance.get('/admin/db/settings/ai');
return res.data;
},
updateAiProvider: async (provider: string) => {
const res = await axiosInstance.post('/admin/db/settings/ai', { provider });
return res.data;
},
updateAiKeys: async (keys: { gemini_api_key?: string, claude_api_key?: string }) => {
const res = await axiosInstance.post('/admin/db/settings/ai-keys', keys);
return res.data;
},
testAiKey: async (provider: string, key: string) => {
const res = await axiosInstance.post('/admin/db/settings/test-ai-key', { provider, key });
return res.data;
}
};

View File

@@ -22,3 +22,6 @@ GEMINI_API_KEY=AIzaSyAajthWG2agpDLyJHY11U5qFLP4WnV5z0w
# External Access (CORS)
# Comma-separated list of extra IPs or FQDNs allowed to connect (e.g. Tailscale, VPN)
EXTRA_ALLOWED_ORIGINS=100.78.182.27
# --- Automatically Added Keys ---
CLAUDE_API_KEY=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