This commit is contained in:
Daniel Bedeleanu
2026-04-14 22:54:19 +03:00
parent 1ea5e90bc4
commit 6c57b1b0c2
69 changed files with 1020 additions and 554 deletions

3
.gitignore vendored
View File

@@ -38,7 +38,10 @@ backend/config/ldap_config.json
# ── Environment files (secrets) ────────────────────────────── # ── Environment files (secrets) ──────────────────────────────
.env .env
.env.* .env.*
inventory.env
inventory.env.*
!.env.example !.env.example
!inventory.env.example
backend/.env backend/.env
backend/.env.* backend/.env.*
!backend/.env.example !backend/.env.example

View File

@@ -11,10 +11,11 @@ This is the **Single Source of Truth** for ALL AI agents. Refer to [PROJECT_ARCH
- **STRICT HANDOVER**: Update `dev_docs/SESSION_STATE.md` at the end of every task with: **Active AI**, **Current Status** (Stable/Broken/In-Progress), **Context**, and **Next Steps**. - **STRICT HANDOVER**: Update `dev_docs/SESSION_STATE.md` at the end of every task with: **Active AI**, **Current Status** (Stable/Broken/In-Progress), **Context**, and **Next Steps**.
- **SESSION ARCHIVE**: Move previous handover content to `dev_docs/SESSION_HISTORY.md` before writing new state. - **SESSION ARCHIVE**: Move previous handover content to `dev_docs/SESSION_HISTORY.md` before writing new state.
- **NO INTERACTION OVERLAP**: Never modify a file if another AI session is explicitly working on it. - **NO INTERACTION OVERLAP**: Never modify a file if another AI session is explicitly working on it.
- **CONCISE COMMUNICATION**: Be concise! Do not explain a thousand details unless they are absolutely necessary.
## 2. ENGINEERING & OPERATIONAL LAWS ## 2. ENGINEERING & OPERATIONAL LAWS
- **ENGLISH ONLY**: Interfaces, code, variables, and docs MUST be in English. Translate any Romanian text found in code immediately. (User conversation: Romanian/English). - **ENGLISH ONLY**: Interfaces, code, variables, and docs MUST be in English. Translate any Romanian text found in code immediately. (User conversation: Romanian/English).
- **GIT PROTOCOL**: Use the direct binary path in `.git_path` (`/Library/Developer/CommandLineTools/usr/bin/git`) for ALL Git operations. **DO NOT REMOVE OR CHANGE THIS PATH UNDER ANY CIRCUMSTANCES!** Never push or use `--force` unless explicitly asked. Branching: `master` (stable), `dev` (active), `vX` (archive). - **GIT PROTOCOL**: Use the direct binary path `/Library/Developer/CommandLineTools/usr/bin/git`) for ALL Git operations. **DO NOT REMOVE OR CHANGE THIS PATH UNDER ANY CIRCUMSTANCES!** Never push or use `--force` unless explicitly asked. Branching: `master` (stable), `dev` (active), `vX` (archive).
- **VERSIONING**: Update `VERSION.json` on every commit. Use `scripts/save_version.py` for automated releases. - **VERSIONING**: Update `VERSION.json` on every commit. Use `scripts/save_version.py` for automated releases.
- **DEPENDENCIES**: Update `backend/requirements.txt` with version constraints for every new pip package. - **DEPENDENCIES**: Update `backend/requirements.txt` with version constraints for every new pip package.
- **SSOT INTEGRITY**: Every feature change MUST update: `README.md`, `USER_GUIDE.md`, `PROJECT_ARCHITECTURE.md`, and `export_prod.sh`. - **SSOT INTEGRITY**: Every feature change MUST update: `README.md`, `USER_GUIDE.md`, `PROJECT_ARCHITECTURE.md`, and `export_prod.sh`.

View File

@@ -8,4 +8,7 @@ Before taking any action or writing code, you MUST read the following Single Sou
2. `PROJECT_ARCHITECTURE.md` (Contains the tech stack, data models, and system logic). 2. `PROJECT_ARCHITECTURE.md` (Contains the tech stack, data models, and system logic).
3. `dev_docs/SESSION_STATE.md` (Contains the current handover status from the previous AI). 3. `dev_docs/SESSION_STATE.md` (Contains the current handover status from the previous AI).
**STRICT COMMUNICATION RULE:**
Be concise! Do not explain a thousand details unless they are absolutely necessary.
Do not proceed with any task until you have analyzed these three files. Do not proceed with any task until you have analyzed these three files.

View File

@@ -8,4 +8,7 @@ Before taking any action or writing code, you MUST read the following Single Sou
2. `PROJECT_ARCHITECTURE.md` (Contains the tech stack, data models, and system logic). 2. `PROJECT_ARCHITECTURE.md` (Contains the tech stack, data models, and system logic).
3. `dev_docs/SESSION_STATE.md` (Contains the current handover status from the previous AI). 3. `dev_docs/SESSION_STATE.md` (Contains the current handover status from the previous AI).
**STRICT COMMUNICATION RULE:**
Be concise! Do not explain a thousand details unless they are absolutely necessary.
Do not proceed with any task until you have analyzed these three files. Do not proceed with any task until you have analyzed these three files.

BIN
_images.tests/IMG_6082.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

BIN
_images.tests/IMG_6106.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

BIN
_images.tests/IMG_6107.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

BIN
_images.tests/IMG_6108.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

BIN
_images.tests/IMG_6109.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

BIN
_images.tests/IMG_6110.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

BIN
_images.tests/IMG_6111.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

BIN
_images.tests/IMG_6112.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
_images.tests/IMG_6113.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

BIN
_images.tests/IMG_6114.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

BIN
_images.tests/IMG_6115.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

BIN
_images.tests/IMG_6116.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

BIN
_images.tests/IMG_6117.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

BIN
_images.tests/IMG_6118.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

BIN
_images.tests/IMG_6119.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

BIN
_images.tests/IMG_6120.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

BIN
_images.tests/IMG_6121.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

BIN
_images.tests/IMG_6122.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

BIN
_images.tests/IMG_6123.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

BIN
_images.tests/IMG_6124.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

BIN
_images.tests/IMG_6125.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

BIN
_images.tests/IMG_6126.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

BIN
_images.tests/IMG_6127.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

BIN
_images.tests/IMG_6128.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

BIN
_images.tests/IMG_6129.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
_images.tests/IMG_6130.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

BIN
_images.tests/IMG_6131.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

BIN
_images.tests/IMG_6132.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

BIN
_images.tests/IMG_6133.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

BIN
_images.tests/IMG_6134.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

BIN
_images.tests/IMG_6135.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

BIN
_images.tests/IMG_6136.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
_images.tests/IMG_6137.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

BIN
_images.tests/IMG_6139.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

BIN
_images.tests/IMG_6140.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
_images.tests/IMG_6141.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

BIN
_images.tests/IMG_6142.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

BIN
_images.tests/IMG_6143.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

BIN
_images.tests/IMG_6144.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

BIN
_images.tests/IMG_6145.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

BIN
_images.tests/IMG_6146.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

BIN
_images.tests/IMG_6147.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

BIN
_images.tests/IMG_6148.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

BIN
_images.tests/IMG_6149.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

BIN
_images.tests/IMG_6150.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

BIN
_images.tests/IMG_6151.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

BIN
_images.tests/IMG_6152.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

BIN
_images.tests/IMG_6153.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

View File

@@ -1,42 +1,50 @@
import os import os
import json import json
import io import io
import logging
from PIL import Image from PIL import Image
from google import genai from google import genai
from google.genai import types from google.genai import types
log = logging.getLogger("ainventory")
def get_best_models(): def get_best_models():
# Using the exact models discovered via diagnostic # Priority list based on aliases and future-gen models found in user logs
return ["gemini-2.0-flash", "gemini-2.5-flash"] return [
"gemini-flash-latest", # Points to the best stable Flash version
"gemini-3.1-flash-lite-preview", # Cutting edge 3.1 Lite
"gemini-flash-lite-latest", # Best Lite alias
"gemini-pro-latest" # Pro fallback alias
]
def extract(image_bytes: bytes, prompt: str): def extract(image_bytes: bytes, prompt: str):
api_key = os.environ.get("GEMINI_API_KEY") api_key = os.environ.get("GEMINI_API_KEY")
if not api_key: if not api_key:
print("CRITICAL: GEMINI_API_KEY is MISSING in environment!") log.error("CRITICAL: GEMINI_API_KEY is MISSING in environment!")
return None return None
# Log partial key for safety debug # Log partial key for safety debug
key_hint = f"{api_key[:4]}...{api_key[-4:]}" if len(api_key) > 8 else "too short" key_hint = f"{api_key[:4]}...{api_key[-4:]}" if len(api_key) > 8 else "too short"
print(f"🔑 Using API Key: {key_hint}") log.info(f"🔑 Using API Key: {key_hint}")
try: try:
# Initialize the NEW SDK Client forcing stable v1 API # Switching to v1beta which is required for many experimental/new models
client = genai.Client(api_key=api_key, http_options={'api_version': 'v1'}) client = genai.Client(api_key=api_key, http_options={'api_version': 'v1beta'})
# DEBUG: List allowed models for this key # DEBUG: List allowed models for this key
print("🔍 Checking available models for your key...") log.debug("🔍 Checking available models for your key...")
try: try:
for m in client.models.list(): for m in client.models.list():
print(f" - Found: {m.name}") log.debug(f" - Found: {m.name}")
except Exception as list_e: except Exception as list_e:
print(f" ⚠️ Could not list models: {list_e}") log.warning(f" ⚠️ Could not list models: {list_e}")
models_to_try = get_best_models() models_to_try = get_best_models()
# Try models in order # Try models in order
for model_name in models_to_try: for model_name in models_to_try:
try: try:
print(f"🚀 AI Launching (v2 SDK): {model_name}...") log.info(f"🚀 AI Launching (v2 SDK): {model_name}...")
# In the new SDK, we pass a list of parts (text string and image bytes) # In the new SDK, we pass a list of parts (text string and image bytes)
response = client.models.generate_content( response = client.models.generate_content(
@@ -48,10 +56,12 @@ def extract(image_bytes: bytes, prompt: str):
) )
if not response or not response.text: if not response or not response.text:
log.warning(f"⚠️ Gemini {model_name} returned NO text.")
continue continue
text = response.text.strip() text = response.text.strip()
print(f"✅ AI Response Received ({len(text)} bytes)") log.info(f"✅ AI Response Received ({len(text)} bytes)")
log.debug(f"FULL RESPONSE:\n{text}")
# Extract JSON block # Extract JSON block
if "```json" in text: if "```json" in text:
@@ -62,10 +72,10 @@ def extract(image_bytes: bytes, prompt: str):
return json.loads(text) return json.loads(text)
except Exception as e: except Exception as e:
print(f"❌ Gemini {model_name} failed: {e}") log.error(f"❌ Gemini {model_name} failed: {e}")
continue continue
except Exception as outer_e: except Exception as outer_e:
print(f"❌ Gemini Client Init failed: {outer_e}") log.error(f"❌ Gemini Client Init failed: {outer_e}")
return None return None

View File

@@ -1,10 +1,42 @@
import os
import time
from dotenv import load_dotenv
from . import models from . import models
from .database import SessionLocal from .database import SessionLocal
from .ai import gemini, claude
# Load environment variables from the directory where this file resides # Note: Environment variables are managed centrally by config_loader.py
base_dir = os.path.dirname(os.path.abspath(__file__)) base_dir = os.path.dirname(os.path.abspath(__file__))
dotenv_path = os.path.join(base_dir, ".env")
load_dotenv(dotenv_path) class PromptManager:
"""Manages AI prompt with auto-reloading from file."""
def __init__(self, file_path: str):
self.file_path = file_path
self.last_mtime = 0
self.cached_prompt = None
def get_prompt(self) -> str:
if not os.path.exists(self.file_path):
return None
try:
current_mtime = os.path.getmtime(self.file_path)
if current_mtime > self.last_mtime or self.cached_prompt is None:
with open(self.file_path, 'r', encoding='utf-8') as f:
self.cached_prompt = f.read().strip()
self.last_mtime = current_mtime
# Use print or log for visibility in dev
print(f"🔄 AI Vision prompt reloaded from {self.file_path} (mtime: {current_mtime})")
return self.cached_prompt
except Exception as e:
print(f"⚠️ Failed to reload AI prompt: {e}")
return self.cached_prompt
# The prompt file is located in the global /config directory
# We go up one level from backend/ to reach project root, then into config/
PROJECT_ROOT = os.path.dirname(os.path.abspath(base_dir))
PROMPT_FILE_PATH = os.path.join(PROJECT_ROOT, "config", "ai_prompt.md")
prompt_mgr = PromptManager(PROMPT_FILE_PATH)
def extract_label_info(image_bytes: bytes, mode: str = "item"): def extract_label_info(image_bytes: bytes, mode: str = "item"):
""" """
@@ -29,20 +61,27 @@ def extract_label_info(image_bytes: bytes, mode: str = "item"):
} }
""" """
else: else:
# Fetch custom prompt from DB # 1. Try fetching from the configuration file first (SSOT)
setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first() prompt = prompt_mgr.get_prompt()
if setting:
prompt = setting.value if not prompt:
else: # 2. Fallback to Database if file is missing
# Fallback to a sensible default if DB is not ready setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
prompt = "Extract technical specs. Return JSON with name, category, description, connector, size, color, part_number, ocr_text, quantity." if setting:
prompt = setting.value
else:
# 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 # 1. Try Gemini
result = gemini.extract(image_bytes, prompt) result = gemini.extract(image_bytes, prompt)
if result: if result:
# Map user-defined prompt keys to model fields if needed # Check if AI returned a list of items or a singular object
# User keys: Item, Type, Description, Category, Connector, Size, Color, PartNr, OCR raw_items = result.get("items") or result.get("Items")
was_list = isinstance(raw_items, list)
items_to_map = raw_items if was_list else [result]
mapping = { mapping = {
"Item": "name", "Item": "name",
"Type": "type", "Type": "type",
@@ -55,23 +94,32 @@ def extract_label_info(image_bytes: bytes, mode: str = "item"):
"OCR": "ocr_text" "OCR": "ocr_text"
} }
final_result = {} mapped_items = []
for ai_key, model_key in mapping.items(): for item_data in items_to_map:
if ai_key in result: final_item = {}
final_result[model_key] = result[ai_key] for ai_key, model_key in mapping.items():
elif model_key in result: # Already mapped or using model keys val = item_data.get(ai_key) or item_data.get(model_key)
final_result[model_key] = result[model_key] if val and isinstance(val, str):
final_item[model_key] = val.strip()
else:
final_item[model_key] = val
# Default fields
final_item["quantity"] = item_data.get("quantity", 1)
raw_barcode = item_data.get("barcode") or item_data.get("PartNr") or item_data.get("part_number") or item_data.get("Part Number")
final_item["barcode"] = str(raw_barcode).strip() if raw_barcode else f"AI-{int(time.time()*100)}"
# Handle Box mode specifically inside mapping
if mode == "box":
final_item["box_label"] = final_item.get("box_label") or item_data.get("Box") or final_item.get("name") or "Unknown Box"
final_item["name"] = final_item["box_label"]
mapped_items.append(final_item)
# Ensure quantity and barcode are handled if returned or default # Return either the whole list wrapper or the first item (legacy compatibility)
final_result["quantity"] = result.get("quantity", 1) if was_list:
final_result["barcode"] = result.get("barcode", result.get("PartNr", result.get("part_number", ""))) return {"items": mapped_items}
return mapped_items[0] if mapped_items else {"error": "No items after mapping"}
# Handle Box mode specifically
if mode == "box":
final_result["box_label"] = result.get("box_label", result.get("name", "Unknown Box"))
final_result["name"] = final_result["box_label"]
return final_result
finally: finally:
db.close() db.close()

34
backend/config_loader.py Normal file
View File

@@ -0,0 +1,34 @@
import os
import logging
from dotenv import load_dotenv
log = logging.getLogger("ainventory")
def load_config():
"""
Centralized environment loader for TFM aInventory.
Prioritizes existing environment variables (Docker),
then inventory.env at project root, then backend/.env.
"""
# Base directory is backend/
base_dir = os.path.dirname(os.path.abspath(__file__))
# Project root is one level up
project_root = os.path.dirname(base_dir)
inventory_env_path = os.path.join(project_root, "inventory.env")
backend_env_path = os.path.join(base_dir, ".env")
# Check for inventory.env in root (Master Config)
if os.path.exists(inventory_env_path):
load_dotenv(inventory_env_path)
log.info(f"✅ Loaded master configuration from {inventory_env_path}")
# Check for local backend/.env (Legacy/Fragmented)
elif os.path.exists(backend_env_path):
load_dotenv(backend_env_path)
log.info(f" Loaded local configuration from {backend_env_path}")
else:
log.warning("⚠️ No .env config files found. Relying on system environment variables.")
# Auto-run if imported
load_config()

View File

@@ -1,4 +1,5 @@
import os import os
from . import config_loader # This triggers the automatic environment loading
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from slowapi import Limiter from slowapi import Limiter
@@ -26,9 +27,9 @@ ALLOWED_ORIGINS = [o.strip() for o in _raw_origins.split(",") if o.strip()]
# Automatically add origins based on network_config.env variables if present # Automatically add origins based on network_config.env variables if present
server_ip = os.environ.get("SERVER_IP") server_ip = os.environ.get("SERVER_IP")
front_port = os.environ.get("FRONTEND_PORT", "8907") front_port = os.environ.get("FRONTEND_PORT", "8917")
front_ssl_port = os.environ.get("FRONTEND_SSL_PORT", "8909") front_ssl_port = os.environ.get("FRONTEND_SSL_PORT", "8919")
back_ssl_port = os.environ.get("BACKEND_SSL_PORT", "8908") back_ssl_port = os.environ.get("BACKEND_SSL_PORT", "8918")
# Always allow localhost # Always allow localhost
defaults = [ defaults = [
@@ -121,7 +122,25 @@ def startup_event():
if not existing: if not existing:
db.add(models.SystemSetting(key="ai_extraction_prompt", value=final_prompt)) db.add(models.SystemSetting(key="ai_extraction_prompt", value=final_prompt))
db.commit() db.commit()
log.info("Initialized default AI prompt.") log.info("Initialized default AI prompt in database.")
# Refresh existing after commit
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
# [NEW] Sync/Initialize AI prompt file in /config
from .database import BASE_DIR
PROJECT_ROOT = os.path.dirname(BASE_DIR)
PROMPT_FILE_PATH = os.path.join(PROJECT_ROOT, "config", "ai_prompt.md")
if not os.path.exists(PROMPT_FILE_PATH):
try:
os.makedirs(os.path.dirname(PROMPT_FILE_PATH), exist_ok=True)
# Use DB value (which we just ensured exists)
current_val = existing.value if existing else final_prompt
with open(PROMPT_FILE_PATH, 'w', encoding='utf-8') as f:
f.write(current_val)
log.info(f"✅ Initialized AI prompt configuration file: {PROMPT_FILE_PATH}")
except Exception as fe:
log.error(f"❌ Failed to initialize AI prompt file: {fe}")
defaults = { defaults = {
"backup_retention_count": "10", "backup_retention_count": "10",

View File

@@ -135,16 +135,30 @@ async def import_database(
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
import os
from ..database import BASE_DIR
PROJECT_ROOT = os.path.dirname(BASE_DIR)
PROMPT_FILE_PATH = os.path.join(PROJECT_ROOT, "config", "ai_prompt.md")
@router.get("/settings/prompt") @router.get("/settings/prompt")
def get_ai_prompt( def get_ai_prompt(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin) current_admin: auth.TokenData = Depends(auth.get_current_admin)
): ):
"""Get the current AI extraction prompt.""" """Get the current AI extraction prompt (prioritizes config file)."""
# 1. Try file first
if os.path.exists(PROMPT_FILE_PATH):
try:
with open(PROMPT_FILE_PATH, 'r', encoding='utf-8') as f:
return {"value": f.read().strip(), "source": "file"}
except Exception as e:
print(f"Error reading prompt file: {e}")
# 2. Fallback to DB
setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first() setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
if not setting: if not setting:
return {"value": ""} return {"value": "", "source": "none"}
return {"value": setting.value} return {"value": setting.value, "source": "database"}
@router.post("/settings/prompt") @router.post("/settings/prompt")
def update_ai_prompt( def update_ai_prompt(
@@ -152,11 +166,21 @@ def update_ai_prompt(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_admin: auth.TokenData = Depends(auth.get_current_admin) current_admin: auth.TokenData = Depends(auth.get_current_admin)
): ):
"""Update the AI extraction prompt.""" """Update the AI extraction prompt (writes to both file and DB)."""
value = payload.get("value") value = payload.get("value")
if value is None: if value is None:
raise HTTPException(status_code=400, detail="Value required") raise HTTPException(status_code=400, detail="Value required")
# 1. Update File (Primary)
try:
os.makedirs(os.path.dirname(PROMPT_FILE_PATH), exist_ok=True)
with open(PROMPT_FILE_PATH, 'w', encoding='utf-8') as f:
f.write(value)
except Exception as e:
print(f"Failed to write prompt file: {e}")
# We continue to DB update anyway
# 2. Update DB (Backup/Sync)
existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first() existing = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_extraction_prompt").first()
if existing: if existing:
existing.value = value existing.value = value
@@ -164,4 +188,4 @@ def update_ai_prompt(
db.add(models.SystemSetting(key="ai_extraction_prompt", value=value)) db.add(models.SystemSetting(key="ai_extraction_prompt", value=value))
db.commit() db.commit()
return {"status": "success"} return {"status": "success", "file_updated": os.path.exists(PROMPT_FILE_PATH)}

View File

@@ -18,21 +18,7 @@ def get_categories(
current_user: auth.TokenData = Depends(auth.get_current_user) current_user: auth.TokenData = Depends(auth.get_current_user)
): ):
"""[C-01] List of categories — only for authenticated users.""" """[C-01] List of categories — only for authenticated users."""
categories = db.query(models.Category).all() return db.query(models.Category).all()
# Auto-seed if empty with defaults mentioned by user
if not categories:
defaults = [
{"name": "Connectors", "description": "Conectica: cables, adapters, plugs"},
{"name": "Spare Parts", "description": "Piese de schimb: specific components"},
{"name": "Tools", "description": "Hand and power tools"},
{"name": "Consumables", "description": "One-time use items"}
]
for d in defaults:
cat = models.Category(**d)
db.add(cat)
db.commit()
return db.query(models.Category).all()
return categories
@router.post("/", response_model=schemas.Category) @router.post("/", response_model=schemas.Category)
def create_category( def create_category(

37
config/ai_prompt.md Normal file
View File

@@ -0,0 +1,37 @@
# Technical Inventory Multi-Label Extraction Protocol
Your goal is to extract precise hardware specifications for ALL relevant inventory items found in the image.
## Intelligence Rules:
1. **Filtering Strategy**:
- **INCLUDE**: Standalone physical hardware, modules, cables, servers, and storage units.
- **EXCLUDE**: Generic mounting hardware (screws, nails, rails, brackets), paper licenses/documentation, or empty packaging, UNLESS they are the primary and only subject of the image.
- In cases like image labels listing multiple SKUs (e.g., 5m cable and 7m cable), TREAT EACH LINE AS A SEPARATE ITEM.
2. **Field Definitions**:
- **Item**: Primary identity (Manufacturer + Core Class). Max 3 words (e.g., "Cisco SFP Module").
- **Type**: Asset classification (e.g., "SFP", "patch cords", "NVMe").
- **Description**: Technical summary emphasizing speed/capacity. Max 5 words.
- **Category**: Broad ecosystem (e.g., "Network", "Storage").
- **Connector**: Physical interface (e.g., "LC/UPC", "RJ45").
- **Size**: Capacity or Length (e.g., "1.6TB", "5m").
- **Color**: Physical distinguish color.
- **PartNr**: Absolute technical identifier (P/N, SKU). Omit Serial Numbers.
- **OCR**: Concise technical string for local heuristic matching. **EXCLUDE ALL SERIAL NUMBERS.**
## Output Format:
Return ONLY a valid JSON object with the key "items" containing an array of objects.
Example:
{
"items": [
{
"Item": "SFP Module",
"Type": "SFP",
"Description": "64G Gen7 Fibre Channel",
"Category": "Network",
"Connector": "LC",
"PartNr": "SFP-64G-G7",
"OCR": "SFP 64G GEN7 FC"
}
]
}

View File

@@ -1,25 +0,0 @@
# ============================================================
# TFM aInventory — Backend Environment Variables
# Copy this file to .env and fill in real values.
# NEVER commit the real .env file to Git!
# ============================================================
# --- AI API Keys ---
# Google Gemini API Key (Required for AI label OCR onboarding)
# You can also set this in the root 'inventory.env' for Docker convenience.
GEMINI_API_KEY=your_gemini_api_key_here
# --- Security ---
# JWT secret key — generate a strong random value for production:
# python3 -c "import secrets; print(secrets.token_urlsafe(64))"
JWT_SECRET_KEY=change-me-generate-a-secure-random-value
# --- CORS & External Access ---
# The system automatically allows localhost and the local LAN IP.
# Use EXTRA_ALLOWED_ORIGINS for Tailscale, VPNs, or FQDNs.
# Example: EXTRA_ALLOWED_ORIGINS=100.78.182.27,inventory.my-domain.com
EXTRA_ALLOWED_ORIGINS=
# --- Data Paths (usually managed by startup scripts) ---
# DATA_DIR=/app/data
# LOGS_DIR=/app/logs

View File

@@ -2,53 +2,50 @@
**Active AI:** Gemini (Antigravity) **Active AI:** Gemini (Antigravity)
**Last Updated:** 2026-04-14 **Last Updated:** 2026-04-14
**Current Version:** v1.9.19 (MobilePolish) **Current Version:** v1.9.20 (AIFix)
**Branch:** dev **Branch:** manual-fix (Git Disrupted)
--- ---
## STATUS: 🟢 STABLE — MOBILE UX & BOX MANAGER REFINEMENT COMPLETE ## STATUS: 🟢 STABLE — AI EXTRACTION FIXED & v1.9.20 RELEASED
**CRITICAL FOR NEXT AI:** The UI has been optimized for mobile ("Premium" density). **CRITICAL FOR NEXT AI:** The backend-to-frontend AI data bridge is now robust. Versioning was performed manually due to local `xcode-select` permissions issues on the host.
1. **Quick Actions**: "Add Item" and "Manage Boxes" are now in a 2-column grid on the Home dashboard. 1. **AI Discovery**: Fixed field mapping in `backend/ai_vision.py` (Item/PartNr -> name/part_number) and added support for Gemini's list-based responses.
2. **Box Manager Accessibility**: A shortcut icon has been added to the global header (Package icon) for instant access. 2. **Frontend Resilience**: `AIOnboarding.tsx` now uses a hyper-robust extraction logic to handle various JSON structures.
3. **Internal Search**: The Box Manager now supports real-time filtering via a search bar. 3. **Emergency Release**: Created `aInventory-PROD-v1.9.20.zip` manually.
--- ---
## WHAT WAS DONE THIS SESSION ## WHAT WAS DONE THIS SESSION
### 1. Dashboard UX Refinement ### 1. AI Discovery Stabilization
- **Grid Layout**: Transformed the vertical stack button list into a 2-column "Quick Actions" grid with modern card styling. - **Backend Fix**: Corrected `extract_label_info` to handle `items` lists and map capitalized AI keys to model fields.
- **Typography Alignment**: Purged `uppercase` and `tracking-widest` from status badges and action cards to align with `AI_RULES.md`. - **Frontend Hardening**: Updated `AIOnboarding.tsx` with deep-scan JSON parsing and string safety.
- **Mode Switcher**: Verified Title Case compliance. - **Verification**: Confirmed successful data flow via browser console logs.
### 2. Box Manager Enhancements ### 2. Versioning & Deployment
- **Global Shortcut**: Integrated a small `Package` icon in the header next to the Sync button. - **Manual Bump**: Increment to `v1.9.20` (AIFix) in `VERSION.json`.
- **Internal Search**: Added a persistent search bar to the Box Manager modal to handle large container lists. - **Git Workaround**: Documented local `xcode-select: Operation not permitted` blocker.
- **Card Refinement**: Streamlined the layout of box cards for high-density mobile viewing. - **Production Bundle**: Generated `aInventory-PROD-v1.9.20.zip` using `export_prod.sh`.
### 3. Documentation & RELEASE ### 3. UI/UX Stabilization
- **User Guide**: Updated `USER_GUIDE.md` with instructions for the new Search and Header shortcut. - **Box Manager**: Verified migration and shortcut accessibility in the header.
- **Version Bump**: Increment to `v1.9.19`.
- **Deployment**: Generated production bundle `aInventory-PROD-v1.9.19.zip`.
--- ---
## WHAT THE NEXT AI MUST DO ## WHAT THE NEXT AI MUST DO
1. **Phase 8: Database Encryption**: As per `PLAN.md`, the next logical step is implementing SQLCipher for data-at-rest protection. 1. **Fix Git Path**: Address the `xcode-select` issue if possible, or continue manual versioning.
2. **Multi-Location Support**: If requested, begin architecting Phase 9 (warehouse-specific stock tracking). 2. **Phase 8: Database Encryption**: Implement SQLCipher for local DB security.
3. **Docker Validation**: Verify that the latest `inventory.env` changes (CORS/API Key) are correctly reflected in the local Docker environment if the user reports issues. 3. **Live Camera Performance**: Monitor video stream performance on older mobile devices.
--- ---
## SYSTEM STATE ## SYSTEM STATE
**Active database:** `<project_root>/data/inventory.db` **Active database:** `<project_root>/data/inventory.db`
**LDAP config:** `config/ldap_config.json` **Current Version:** `v1.9.20`
**Network config:** `inventory.env` (SSOT) **Production Bundle:** `aInventory-PROD-v1.9.20.zip`
**Production Bundle:** `aInventory-PROD-v1.9.19.zip`
**How to start:** **How to start:**
```bash ```bash

View File

@@ -1,6 +1,6 @@
{ {
"version": "1.9.19", "version": "1.9.20",
"last_build": "2026-04-13-2343", "last_build": "2026-04-14-2250",
"codename": "MobilePolish", "codename": "AIFix",
"commit": "1fff658d" "commit": "manual"
} }

View File

@@ -18,7 +18,18 @@ import {
RotateCcw, RotateCcw,
FileText, FileText,
Upload, Upload,
Brain Brain,
Globe,
Edit2,
Server,
Wifi,
WifiOff,
Clock,
History,
Database,
Layers,
X,
ChevronDown
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';

View File

@@ -19,8 +19,13 @@ import {
AlertTriangle, AlertTriangle,
Tag, Tag,
Edit2, Edit2,
Camera Camera,
Layout,
Printer,
Download,
Search
} from 'lucide-react'; } from 'lucide-react';
import { generateBarcode128, getQRCodeURL } from '@/lib/labels';
import { clsx, type ClassValue } from 'clsx'; import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
@@ -56,6 +61,11 @@ export default function InventoryPage() {
const [showScanner, setShowScanner] = useState(false); const [showScanner, setShowScanner] = useState(false);
const [fieldScanning, setFieldScanning] = useState<{ active: boolean, field: string } | null>(null); const [fieldScanning, setFieldScanning] = useState<{ active: boolean, field: string } | null>(null);
// Box Manager State
const [showBoxManager, setShowBoxManager] = useState(false);
const [selectedBoxLabel, setSelectedBoxLabel] = useState<string | null>(null);
const [boxSearchQuery, setBoxSearchQuery] = useState('');
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
const savedUser = localStorage.getItem('inventory_user'); const savedUser = localStorage.getItem('inventory_user');
@@ -244,6 +254,13 @@ export default function InventoryPage() {
<h1 className="text-3xl font-black tracking-tight text-white">Inventory Catalog</h1> <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> <p className="text-xs text-slate-500 font-bold mt-1">Enterprise Stock Overview</p>
</div> </div>
<button
onClick={() => setShowBoxManager(true)}
className="ml-auto p-3 bg-slate-900 border border-slate-800 text-slate-400 rounded-2xl hover:text-primary transition-all active:scale-95 shadow-xl"
title="Manage Boxes"
>
<Layout size={20} />
</button>
</header> </header>
<div className="w-full space-y-8"> <div className="w-full space-y-8">
@@ -259,6 +276,11 @@ export default function InventoryPage() {
<p className="text-sm font-bold text-slate-300 whitespace-nowrap">Item Types</p> <p className="text-sm font-bold text-slate-300 whitespace-nowrap">Item Types</p>
<p className="text-xl font-black text-white tabular-nums ml-auto">{stats?.total_items || inventory.length}</p> <p className="text-xl font-black text-white tabular-nums ml-auto">{stats?.total_items || inventory.length}</p>
</div> </div>
<div className="bg-slate-900/80 border border-slate-700/40 p-2 px-4 rounded-xl flex items-center gap-4 shadow-lg backdrop-blur-sm">
<Layout size={18} className="text-amber-500 shrink-0 opacity-80" />
<p className="text-sm font-bold text-slate-300 whitespace-nowrap">Total Boxes</p>
<p className="text-xl font-black text-white tabular-nums ml-auto">{existingBoxes.length}</p>
</div>
</section> </section>
{/* Search */} {/* Search */}
@@ -364,8 +386,13 @@ export default function InventoryPage() {
<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="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="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"> <div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black tracking-tight"> <h3 className="text-xl font-black tracking-tight flex items-center gap-2">
{isEditing ? "Edit Item" : selectedItem.name} {isEditing ? "Edit Item" : selectedItem.name}
{!isEditing && (
<span className="text-[10px] bg-slate-800 text-slate-400 px-2 py-0.5 rounded-md font-black uppercase">
In Stock: {selectedItem.quantity}
</span>
)}
</h3> </h3>
<div className="flex gap-2"> <div className="flex gap-2">
{!isEditing && ( {!isEditing && (
@@ -401,62 +428,92 @@ export default function InventoryPage() {
</div> </div>
{isEditing ? ( {isEditing ? (
<div className="space-y-4 mb-8"> <div className="max-h-[60vh] overflow-y-auto pr-2 space-y-4 mb-6 scrollbar-hide">
<div> <div>
<label className="text-xs font-black text-slate-500 ml-1">Name</label> <label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Item Name</label>
<input <input
type="text" type="text"
value={editedItem.name || ''} value={editedItem.name || ''}
onChange={e => setEditedItem({...editedItem, name: e.target.value})} 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" className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-slate-100 placeholder:text-slate-700"
/> />
</div> </div>
<div> <div>
<label className="text-xs font-black text-slate-500 ml-1">Part Number</label> <label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Part Number</label>
<input <input
type="text" type="text"
value={editedItem.part_number || ''} value={editedItem.part_number || ''}
onChange={e => setEditedItem({...editedItem, part_number: e.target.value})} 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" className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-slate-100 placeholder:text-slate-700 uppercase"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="text-xs font-black text-slate-500 ml-1">Category</label> <label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Category</label>
<div className="relative flex items-center"> <input
<select type="text"
value={editedItem.category || ''} list="existing-categories"
onChange={e => setEditedItem({...editedItem, category: e.target.value})} value={editedItem.category || ''}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100 appearance-none" 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 font-bold outline-none text-slate-100 placeholder:text-slate-700"
<option value="">Select Category</option> placeholder="e.g. storage"
{categoriesList.map(c => ( />
<option key={c.id} value={c.name}>{c.name}</option> <datalist id="existing-categories">
))} {categoriesList.map(c => (
</select> <option key={c.id} value={c.name} />
<ChevronDown size={16} className="absolute right-4 text-slate-500 pointer-events-none" /> ))}
</div> </datalist>
</div> </div>
<div> <div>
<label className="text-xs font-black text-slate-500 ml-1">Item Type</label> <label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Item Type</label>
<input <input
type="text" type="text"
list="existing-types" list="existing-types"
value={editedItem.type || ''} value={editedItem.type || ''}
onChange={e => setEditedItem({...editedItem, type: e.target.value})} 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" className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-slate-100"
/>
</div>
<div>
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Connector</label>
<input
type="text"
value={editedItem.connector || ''}
onChange={e => setEditedItem({...editedItem, connector: e.target.value})}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-slate-100"
placeholder="e.g. LC/UPC"
/>
</div>
<div>
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Size / Length</label>
<input
type="text"
value={editedItem.size || ''}
onChange={e => setEditedItem({...editedItem, size: e.target.value})}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-slate-100"
placeholder="e.g. 1.6TB"
/> />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="text-xs font-black text-slate-500 ml-1">Box / Container Label</label> <label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Item Color</label>
<input
type="text"
value={editedItem.color || ''}
onChange={e => setEditedItem({...editedItem, color: e.target.value})}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-slate-100"
placeholder="e.g. Blue"
/>
</div>
<div className="col-span-2">
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Box / Container Label</label>
<div className="relative flex items-center"> <div className="relative flex items-center">
<input <input
type="text" type="text"
list="existing-boxes" list="existing-boxes"
value={editedItem.box_label || ''} value={editedItem.box_label || ''}
onChange={e => setEditedItem({...editedItem, box_label: e.target.value})} onChange={e => setEditedItem({...editedItem, box_label: e.target.value})}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 pl-4 pr-12 text-sm outline-none text-slate-100 placeholder:text-slate-700 focus:border-primary transition-colors" className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 pl-4 pr-12 text-sm font-bold outline-none text-slate-100 placeholder:text-slate-700 focus:border-primary transition-colors"
placeholder="e.g. SFPs 40G Cisco" placeholder="e.g. Box 5"
/> />
<button <button
type="button" type="button"
@@ -476,12 +533,19 @@ export default function InventoryPage() {
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs font-black text-slate-500 ml-1">Specs / Comments</label> <label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">Specs / Comments</label>
<input <textarea
type="text"
value={editedItem.specs || ''} value={editedItem.specs || ''}
onChange={e => setEditedItem({...editedItem, specs: e.target.value})} 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" className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-slate-100 h-20 resize-none"
/>
</div>
<div>
<label className="text-[11px] font-black text-slate-500 ml-1 uppercase tracking-wider">OCR Matching Key</label>
<textarea
value={editedItem.ocr_text || ''}
onChange={e => setEditedItem({...editedItem, ocr_text: e.target.value})}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-primary/80 h-16 resize-none"
/> />
</div> </div>
</div> </div>
@@ -610,8 +674,183 @@ export default function InventoryPage() {
onOCRMatch={onOCRMatch} onOCRMatch={onOCRMatch}
/> />
)} )}
</div> </div>
{/* Box Manager Modal */}
{showBoxManager && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-md animate-in fade-in duration-300">
<div className="w-full max-w-2xl bg-slate-900 border border-slate-800 rounded-[2.5rem] shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="p-8 pb-4 flex flex-col gap-6 shrink-0 bg-slate-900/50">
<div className="flex justify-between items-center">
<div>
<h3 className="text-2xl font-black tracking-tight flex items-center gap-3">
<Package className="text-primary" size={28} />
Box Inventory
</h3>
<p className="text-sm text-slate-500 font-bold mt-1">Manage physical box labels & printing</p>
</div>
<button onClick={() => { setShowBoxManager(false); setBoxSearchQuery(''); }} className="p-3 hover:bg-slate-800 rounded-full transition-colors text-slate-400">
<X size={24} />
</button>
</div>
<div className="relative">
<input
type="text"
placeholder="Search boxes..."
value={boxSearchQuery}
onChange={(e) => setBoxSearchQuery(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-2xl py-3.5 pl-11 pr-4 text-sm focus:border-primary outline-none transition-all placeholder:text-slate-600"
/>
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-600" size={18} />
{boxSearchQuery && (
<button
onClick={() => setBoxSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-slate-800 rounded-lg text-slate-500"
>
<X size={14} />
</button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-4 pt-4">
{existingBoxes.filter(b => b.toLowerCase().includes(boxSearchQuery.toLowerCase())).length === 0 ? (
<div className="py-20 text-center space-y-4 opacity-40">
<Package size={48} className="mx-auto" />
<p className="font-bold">{existingBoxes.length === 0 ? 'No box labels defined yet.' : 'No matching boxes found.'}</p>
<p className="text-xs max-w-xs mx-auto">Associate items with a "Box Label" in their metadata to see them here.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 pb-4">
{existingBoxes
.filter(box => box.toLowerCase().includes(boxSearchQuery.toLowerCase()))
.map(box => {
const itemCount = inventory.filter(i => i.box_label === box).length;
return (
<div key={box} className="bg-slate-950/50 border border-slate-800/60 p-5 rounded-3xl flex flex-col gap-4 group hover:border-primary/40 transition-all">
<div className="flex-1 min-w-0">
<h4 className="text-lg font-black text-white truncate">{box}</h4>
<p className="text-xs text-slate-500 font-bold mt-0.5">{itemCount} items linked</p>
</div>
<div className="flex gap-2 shrink-0">
<button
onClick={() => setSelectedBoxLabel(box)}
className="flex-1 py-3 bg-primary text-white text-[11px] font-black rounded-xl flex items-center justify-center gap-2 hover:bg-blue-500 transition-colors shadow-lg shadow-primary/10 active:scale-95"
>
<Printer size={14} /> Print Label
</button>
<button
onClick={() => { setSearchQuery(box); setShowBoxManager(false); setBoxSearchQuery(''); }}
className="px-4 py-3 bg-slate-900 border border-slate-800 text-slate-400 text-[11px] font-bold rounded-xl hover:bg-slate-800 active:scale-95"
>
View
</button>
</div>
</div>
);
})}
</div>
)}
</div>
<div className="p-6 py-4 bg-slate-950/50 border-t border-slate-800 text-center flex items-center justify-center gap-2">
<div className="w-1 h-1 rounded-full bg-primary animate-pulse" />
<p className="text-[10px] text-slate-500 font-bold font-mono">TFM aInventory Box management mode</p>
</div>
</div>
</div>
)}
{/* Label Print Preview Modal */}
{selectedBoxLabel && (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/95 animate-in zoom-in-95 duration-200">
<div className="w-full max-w-md flex flex-col gap-6">
<div id="print-label-area" className="w-full bg-white p-8 rounded-lg shadow-2xl flex flex-col items-center gap-6">
<h2 className="text-2xl font-black text-black tracking-tighter text-center uppercase">
{selectedBoxLabel}
</h2>
<div className="w-full aspect-[2/1] bg-white flex flex-col items-center justify-center overflow-hidden" dangerouslySetInnerHTML={{ __html: generateBarcode128(selectedBoxLabel) }} />
<div className="flex flex-col items-center gap-1">
<p className="text-[10px] font-black tracking-[0.2em] text-black/50">TFM INVENTORY BOX LABEL</p>
<img src={getQRCodeURL(selectedBoxLabel)} className="w-24 h-24" alt="QR Code" />
</div>
</div>
<div className="flex flex-col gap-3 no-print">
<button
onClick={() => window.print()}
className="w-full py-5 bg-primary text-white rounded-[2rem] font-black text-lg flex items-center justify-center gap-3 shadow-2xl shadow-primary/40 active:scale-95 transition-all"
>
<Printer size={20} /> Print to Dymo/Brother
</button>
<button
onClick={() => {
const svg = document.querySelector("#print-label-area svg");
if (svg) {
const svgData = new XMLSerializer().serializeToString(svg);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.onload = () => {
canvas.width = img.width * 4;
canvas.height = img.height * 4;
if (ctx) {
ctx.fillStyle = "white";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const link = document.createElement("a");
link.download = `Label-${selectedBoxLabel}.png`;
link.href = canvas.toDataURL("image/png");
link.click();
}
};
img.src = "data:image/svg+xml;base64," + btoa(svgData);
}
}}
className="w-full py-4 bg-slate-900 border border-slate-800 text-white rounded-[2rem] font-black text-sm flex items-center justify-center gap-2 active:scale-95 transition-all"
>
<Download size={16} /> Save for Mobile App
</button>
<button
onClick={() => setSelectedBoxLabel(null)}
className="w-full py-4 text-slate-500 font-bold text-xs"
>
Cancel
</button>
</div>
{/* Print CSS Injection */}
<style dangerouslySetInnerHTML={{ __html: `
@media print {
body * { display: none !important; }
#print-label-area {
display: flex !important;
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
margin: 0 !important;
padding: 40px !important;
border: none !important;
box-shadow: none !important;
}
.no-print { display: none !important; }
}
@page {
size: auto;
margin: 0;
}
`}} />
</div>
</div>
)}
</PageShell> </PageShell>
); );
} }

View File

@@ -66,7 +66,6 @@ export default function Home() {
const [mode, setMode] = useState<'CHECK_IN' | 'CHECK_OUT' | 'TRASH'>('CHECK_OUT'); const [mode, setMode] = useState<'CHECK_IN' | 'CHECK_OUT' | 'TRASH'>('CHECK_OUT');
const [showScanner, setShowScanner] = useState(false); const [showScanner, setShowScanner] = useState(false);
const [showOnboarding, setShowOnboarding] = useState(false); const [showOnboarding, setShowOnboarding] = useState(false);
const [showBoxManager, setShowBoxManager] = useState(false);
const [selectedBoxLabel, setSelectedBoxLabel] = useState<string | null>(null); const [selectedBoxLabel, setSelectedBoxLabel] = useState<string | null>(null);
const [selectedItem, setSelectedItem] = useState<Item | null>(null); const [selectedItem, setSelectedItem] = useState<Item | null>(null);
const [boxMatches, setBoxMatches] = useState<Item[]>([]); const [boxMatches, setBoxMatches] = useState<Item[]>([]);
@@ -82,7 +81,6 @@ export default function Home() {
const [currentUser, setCurrentUser] = useState<any | null>(null); const [currentUser, setCurrentUser] = useState<any | null>(null);
const [categories, setCategories] = useState<any[]>([]); const [categories, setCategories] = useState<any[]>([]);
const [fieldScanning, setFieldScanning] = useState<{ active: boolean, field: string } | null>(null); const [fieldScanning, setFieldScanning] = useState<{ active: boolean, field: string } | null>(null);
const [boxSearchQuery, setBoxSearchQuery] = useState('');
useEffect(() => { useEffect(() => {
if (!localStorage.getItem('inventory_token')) { if (!localStorage.getItem('inventory_token')) {
@@ -497,13 +495,6 @@ export default function Home() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button
onClick={() => setShowBoxManager(true)}
className="p-2.5 bg-slate-900/80 border border-slate-800 text-slate-400 rounded-xl hover:text-primary transition-all active:scale-95"
title="Box Manager"
>
<Package size={18} />
</button>
<button <button
onClick={handleSync} onClick={handleSync}
disabled={syncing} disabled={syncing}
@@ -542,7 +533,7 @@ export default function Home() {
{showScanner ? ( {showScanner ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h2 className="text-lg font-semibold">Scanning...</h2> <h2 className="text-lg font-semibold">scanning...</h2>
<button <button
onClick={() => setShowScanner(false)} onClick={() => setShowScanner(false)}
className="text-sm text-slate-400 hover:text-white" className="text-sm text-slate-400 hover:text-white"
@@ -562,36 +553,23 @@ export default function Home() {
</button> </button>
<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 labels to {mode.replace('_', ' ')} items</p> <p className="text-sm text-slate-400">Scan labels to {mode.replace('_', ' ')} items</p>
</div> </div>
<div className="w-full h-px bg-slate-800/50 my-2" /> <div className="w-full h-px bg-slate-800/50 my-2" />
<div className="w-full grid grid-cols-2 gap-3"> <div className="w-full flex justify-center">
<button <button
onClick={() => setShowOnboarding(true)} onClick={() => setShowOnboarding(true)}
className="flex flex-col items-center justify-center p-5 rounded-3xl bg-indigo-500/10 border border-indigo-500/20 group hover:border-indigo-500/50 transition-all font-black text-indigo-400 gap-3" className="w-full flex flex-col items-center justify-center p-8 rounded-[2rem] bg-indigo-500/5 border border-indigo-500/20 group hover:border-indigo-500/50 transition-all font-black text-indigo-400 gap-4"
> >
<div className="p-3 bg-indigo-500/10 rounded-2xl group-hover:scale-110 transition-transform"> <div className="p-4 bg-indigo-500/10 rounded-2xl group-hover:scale-110 transition-transform shadow-lg shadow-indigo-500/10">
<Sparkles size={24} /> <Sparkles size={32} />
</div> </div>
<div className="text-center"> <div className="text-center">
<p className="text-sm leading-tight">Add Item</p> <p className="text-lg leading-tight">Add New Item</p>
<p className="text-[10px] opacity-60 font-mono mt-1">AI Onboarding</p> <p className="text-xs opacity-60 font-mono mt-1">AI Smart Discovery</p>
</div>
</button>
<button
onClick={() => setShowBoxManager(true)}
className="flex flex-col items-center justify-center p-5 rounded-3xl bg-slate-900/50 border border-slate-800 group hover:border-primary/40 transition-all font-black text-slate-300 gap-3"
>
<div className="p-3 bg-primary/10 rounded-2xl group-hover:scale-110 transition-transform">
<Package size={24} className="text-primary" />
</div>
<div className="text-center">
<p className="text-sm leading-tight">Manage Boxes</p>
<p className="text-[10px] opacity-60 font-mono mt-1">Box Inventory</p>
</div> </div>
</button> </button>
</div> </div>
@@ -615,11 +593,14 @@ export default function Home() {
<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="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="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"> <div className="flex justify-between items-center mb-6">
{isEditing ? ( <h3 className="text-xl font-black tracking-tight flex items-center gap-2">
<h3 className="text-xl font-black tracking-tight">Edit Metadata</h3> {isEditing ? "Edit Metadata" : selectedItem.name}
) : ( {!isEditing && (
<h3 className="text-xl font-black tracking-tight">{selectedItem.name}</h3> <span className="text-[10px] bg-slate-800 text-slate-400 px-2 py-0.5 rounded-md font-black uppercase tracking-tight shadow-sm border border-slate-700/50">
)} In Stock: {selectedItem.quantity}
</span>
)}
</h3>
<div className="flex gap-2"> <div className="flex gap-2">
{!isEditing && ( {!isEditing && (
<button <button
@@ -655,50 +636,51 @@ 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-xs font-black text-slate-500 ml-1">Name</label> <label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Item Name</label>
<input <input
type="text" type="text"
value={editedItem.name || ''} value={editedItem.name || ''}
onChange={e => setEditedItem({ ...editedItem, name: e.target.value })} 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" className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100 placeholder-slate-700"
placeholder="e.g. Solid State Drive"
/> />
</div> </div>
<div> <div>
<label className="text-xs font-black text-slate-500 ml-1">Part Number (for OCR match)</label> <label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Part Number</label>
<input <input
type="text" type="text"
value={editedItem.part_number || ''} value={editedItem.part_number || ''}
onChange={e => setEditedItem({ ...editedItem, part_number: e.target.value })} 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" 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="e.g. OM4-TURQ-2M" placeholder="e.g. PN-12345"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="text-xs font-black text-slate-500 ml-1">Category</label> <label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Category Group</label>
<div className="relative flex items-center"> <input
<select type="text"
value={editedItem.category || ''} list="existing-categories"
onChange={e => setEditedItem({ ...editedItem, category: e.target.value })} value={editedItem.category || ''}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100 appearance-none" 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 placeholder:text-slate-700"
<option value="">Select Category</option> placeholder="e.g. storage"
{categories.map(c => ( />
<option key={c.id} value={c.name}>{c.name}</option> <datalist id="existing-categories">
))} {categories.map(c => (
</select> <option key={c.id} value={c.name} />
<ChevronDown size={16} className="absolute right-4 text-slate-500 pointer-events-none" /> ))}
</div> </datalist>
</div> </div>
<div> <div>
<label className="text-xs font-black text-slate-500 ml-1">Item Type (e.g. SFP, Patch Cord)</label> <label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Item Type</label>
<input <input
type="text" type="text"
list="existing-types" list="existing-types"
value={editedItem.type || ''} value={editedItem.type || ''}
onChange={e => setEditedItem({...editedItem, type: e.target.value})} 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" className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100"
placeholder="e.g. SFP+" placeholder="e.g. spare parts"
/> />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
@@ -729,7 +711,7 @@ export default function Home() {
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs font-black text-slate-500 ml-1">Connectors</label> <label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Connector</label>
<input <input
type="text" type="text"
value={editedItem.connector || ''} value={editedItem.connector || ''}
@@ -739,23 +721,23 @@ export default function Home() {
/> />
</div> </div>
<div> <div>
<label className="text-xs font-black text-slate-500 ml-1">Size / Length</label> <label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Size / Length</label>
<input <input
type="text" type="text"
value={editedItem.size || ''} value={editedItem.size || ''}
onChange={e => setEditedItem({...editedItem, size: e.target.value})} onChange={e => setEditedItem({...editedItem, size: 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" className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100"
placeholder="e.g. 5m / 10G" placeholder="e.g. 5m / 1600GB"
/> />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="text-xs font-black text-slate-500 ml-1">Color</label> <label className="text-xs font-black text-slate-500 ml-1 tracking-tight">Item Color</label>
<input <input
type="text" type="text"
value={editedItem.color || ''} value={editedItem.color || ''}
onChange={e => setEditedItem({...editedItem, color: e.target.value})} onChange={e => setEditedItem({...editedItem, color: 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" className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm outline-none text-slate-100"
placeholder="e.g. Aqua" placeholder="e.g. Black"
/> />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
@@ -768,11 +750,11 @@ export default function Home() {
/> />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="text-xs font-black text-slate-500 ml-1">OCR Matching Key (Local Identification)</label> <label className="text-xs font-black text-slate-500 ml-1 tracking-tight">OCR Matching Key</label>
<textarea <textarea
value={editedItem.ocr_text || ''} value={editedItem.ocr_text || ''}
onChange={e => setEditedItem({ ...editedItem, ocr_text: e.target.value })} onChange={e => setEditedItem({ ...editedItem, ocr_text: e.target.value })}
className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-[10px] font-mono outline-none text-slate-400 resize-none h-12" className="w-full bg-slate-950 border border-slate-800 rounded-xl py-3 px-4 text-sm font-bold outline-none text-slate-400 resize-none h-12"
placeholder="Paste identification string here..." placeholder="Paste identification string here..."
/> />
</div> </div>
@@ -915,181 +897,6 @@ export default function Home() {
</div> </div>
)} )}
{/* Box Manager Modal */}
{showBoxManager && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-md animate-in fade-in duration-300">
<div className="w-full max-w-2xl bg-slate-900 border border-slate-800 rounded-[2.5rem] shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="p-8 pb-4 flex flex-col gap-6 shrink-0 bg-slate-900/50">
<div className="flex justify-between items-center">
<div>
<h3 className="text-2xl font-black tracking-tight flex items-center gap-3">
<Package className="text-primary" size={28} />
Box Inventory
</h3>
<p className="text-sm text-slate-500 font-bold mt-1">Manage physical box labels & printing</p>
</div>
<button onClick={() => { setShowBoxManager(false); setBoxSearchQuery(''); }} className="p-3 hover:bg-slate-800 rounded-full transition-colors text-slate-400">
<X size={24} />
</button>
</div>
<div className="relative">
<input
type="text"
placeholder="Search boxes..."
value={boxSearchQuery}
onChange={(e) => setBoxSearchQuery(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-2xl py-3.5 pl-11 pr-4 text-sm focus:border-primary outline-none transition-all placeholder:text-slate-600"
/>
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-600" size={18} />
{boxSearchQuery && (
<button
onClick={() => setBoxSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 hover:bg-slate-800 rounded-lg text-slate-500"
>
<X size={14} />
</button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-4 pt-4">
{existingBoxes.filter(b => b.toLowerCase().includes(boxSearchQuery.toLowerCase())).length === 0 ? (
<div className="py-20 text-center space-y-4 opacity-40">
<Package size={48} className="mx-auto" />
<p className="font-bold">{existingBoxes.length === 0 ? 'No box labels defined yet.' : 'No matching boxes found.'}</p>
<p className="text-xs max-w-xs mx-auto">Associate items with a "Box Label" in their metadata to see them here.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 pb-4">
{existingBoxes
.filter(box => box.toLowerCase().includes(boxSearchQuery.toLowerCase()))
.map(box => {
const itemCount = inventory.filter(i => i.box_label === box).length;
return (
<div key={box} className="bg-slate-950/50 border border-slate-800/60 p-5 rounded-3xl flex flex-col gap-4 group hover:border-primary/40 transition-all">
<div className="flex-1 min-w-0">
<h4 className="text-lg font-black text-white truncate">{box}</h4>
<p className="text-xs text-slate-500 font-bold mt-0.5">{itemCount} items linked</p>
</div>
<div className="flex gap-2 shrink-0">
<button
onClick={() => setSelectedBoxLabel(box)}
className="flex-1 py-3 bg-primary text-white text-[11px] font-black rounded-xl flex items-center justify-center gap-2 hover:bg-blue-500 transition-colors shadow-lg shadow-primary/10 active:scale-95"
>
<Printer size={14} /> Print Label
</button>
<button
onClick={() => { setSearchQuery(box.toLowerCase()); setShowBoxManager(false); setBoxSearchQuery(''); }}
className="px-4 py-3 bg-slate-900 border border-slate-800 text-slate-400 text-[11px] font-bold rounded-xl hover:bg-slate-800 active:scale-95"
>
View
</button>
</div>
</div>
);
})}
</div>
)}
</div>
<div className="p-6 py-4 bg-slate-950/50 border-t border-slate-800 text-center flex items-center justify-center gap-2">
<div className="w-1 h-1 rounded-full bg-primary animate-pulse" />
<p className="text-[10px] text-slate-500 font-bold font-mono">TFM aInventory Box management mode</p>
</div>
</div>
</div>
)}
{/* Label Print Preview Modal */}
{selectedBoxLabel && (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/95 animate-in zoom-in-95 duration-200">
<div className="w-full max-w-md flex flex-col gap-6">
<div id="print-label-area" className="w-full bg-white p-8 rounded-lg shadow-2xl flex flex-col items-center gap-6">
<h2 className="text-2xl font-black text-black tracking-tighter text-center uppercase">
{selectedBoxLabel}
</h2>
<div className="w-full aspect-[2/1] bg-white flex flex-col items-center justify-center overflow-hidden" dangerouslySetInnerHTML={{ __html: generateBarcode128(selectedBoxLabel) }} />
<div className="flex flex-col items-center gap-1">
<p className="text-[10px] font-black tracking-[0.2em] text-black/50">TFM INVENTORY BOX LABEL</p>
<img src={getQRCodeURL(selectedBoxLabel)} className="w-24 h-24" alt="QR Code" />
</div>
</div>
<div className="flex flex-col gap-3 no-print">
<button
onClick={() => window.print()}
className="w-full py-5 bg-primary text-white rounded-[2rem] font-black text-lg flex items-center justify-center gap-3 shadow-2xl shadow-primary/40 active:scale-95 transition-all"
>
<Printer size={20} /> Print to Dymo/Brother
</button>
<button
onClick={() => {
const svg = document.querySelector("#print-label-area svg");
if (svg) {
const svgData = new XMLSerializer().serializeToString(svg);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.onload = () => {
canvas.width = img.width * 4;
canvas.height = img.height * 4;
if (ctx) {
ctx.fillStyle = "white";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const link = document.createElement("a");
link.download = `Label-${selectedBoxLabel}.png`;
link.href = canvas.toDataURL("image/png");
link.click();
}
};
img.src = "data:image/svg+xml;base64," + btoa(svgData);
}
}}
className="w-full py-4 bg-slate-900 border border-slate-800 text-white rounded-[2rem] font-black text-sm flex items-center justify-center gap-2 active:scale-95 transition-all"
>
<Download size={16} /> Save for Mobile App
</button>
<button
onClick={() => setSelectedBoxLabel(null)}
className="w-full py-4 text-slate-500 font-bold text-xs"
>
Cancel
</button>
</div>
{/* Print CSS Injection */}
<style dangerouslySetInnerHTML={{ __html: `
@media print {
body * { display: none !important; }
#print-label-area {
display: flex !important;
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
margin: 0 !important;
padding: 40px !important;
border: none !important;
box-shadow: none !important;
}
.no-print { display: none !important; }
}
@page {
size: auto;
margin: 0;
}
`}} />
</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">

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { Camera, Check, RefreshCw, X, Image as ImageIcon, Sparkles, Hash, Layout, Layers, Package, ChevronDown } from 'lucide-react'; import { Camera, Check, RefreshCw, X, Image as ImageIcon, Sparkles, Hash, Layout, Layers, Package, ChevronDown } from 'lucide-react';
import { inventoryApi } from '@/lib/api'; import { inventoryApi } from '@/lib/api';
@@ -15,22 +15,54 @@ interface AIOnboardingProps {
export default function AIOnboarding({ onCancel, onComplete, categories, inventory }: AIOnboardingProps) { export default function AIOnboarding({ onCancel, onComplete, categories, inventory }: AIOnboardingProps) {
const [image, setImage] = useState<string | null>(null); const [image, setImage] = useState<string | null>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [extractedData, setExtractedData] = useState<any>(null); const [extractedItems, setExtractedItems] = useState<any[]>([]);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [mode, setMode] = useState<'item' | 'box'>('item'); const [mode, setMode] = useState<'item' | 'box'>('item');
const [isLive, setIsLive] = useState(false);
// Extract unique item types for suggestions const videoRef = useRef<HTMLVideoElement>(null);
const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[]; const canvasRef = useRef<HTMLCanvasElement>(null);
const existingBoxes = Array.from(new Set(inventory.map(i => i.box_label).filter(Boolean))).sort() as string[]; const streamRef = useRef<MediaStream | null>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const startLiveCamera = async () => {
const file = e.target.files?.[0]; try {
if (file) { setIsLive(true);
const reader = new FileReader(); const stream = await navigator.mediaDevices.getUserMedia({
reader.onload = () => setImage(reader.result as string); video: { facingMode: 'environment', width: { ideal: 1920 }, height: { ideal: 1080 } },
reader.readAsDataURL(file); audio: false
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
streamRef.current = stream;
}
} catch (err) {
console.error("Camera access error:", err);
toast.error("Could not access camera for live scan.");
setIsLive(false);
}
};
const stopLiveCamera = () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
setIsLive(false);
};
const captureSnapshot = () => {
if (videoRef.current && canvasRef.current) {
const video = videoRef.current;
const canvas = canvasRef.current;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
setImage(dataUrl);
stopLiveCamera();
}
} }
}; };
@@ -44,18 +76,47 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
formData.append('file', blob, 'label.jpg'); formData.append('file', blob, 'label.jpg');
const data = await inventoryApi.analyzeLabel(formData, mode); const data = await inventoryApi.analyzeLabel(formData, mode);
if (data.error) { if (data.error) {
toast.error(`AI Error: ${data.error}`); toast.error(`AI Error: ${data.error}`);
setUploading(false); // Force stop loading state setUploading(false);
return; return;
} }
setExtractedData(data); let parsedData = data;
if (mode === 'box') { if (typeof data === 'string') {
toast.success(`Box identified: ${data.box_label || data.name}`); try { parsedData = JSON.parse(data); } catch (e) {}
}
const d = parsedData;
// HYPER-ROBUST: Find ANY array in the response if it's not a direct array
let items: any[] = [];
if (Array.isArray(d)) {
items = d;
} else { } else {
toast.success("AI extraction complete!"); const potentialArrayKey = Object.keys(d).find(k => Array.isArray(d[k]));
if (potentialArrayKey) {
items = d[potentialArrayKey];
} else {
// Check for singular object (must have at least name or Item or PN)
const target = d.data || d;
if (target.name || target.Item || target.PartNr || target.part_number) {
items = [target];
}
}
}
if (!items || items.length === 0) {
toast.error("No relevant items detected. Try a closer photo.");
} else {
setExtractedItems(items);
if (items.length === 1) {
setEditingIndex(0);
toast.success("Item identified!");
} else {
toast.success(`Found ${items.length} items!`);
}
} }
} catch (error) { } catch (error) {
toast.error("Failed to process image with AI"); toast.error("Failed to process image with AI");
@@ -65,28 +126,108 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
} }
}; };
const confirmOnboarding = async () => { const confirmSingleItem = (index: number) => {
// Sanitize data for the backend (Pydantic validation) const data = extractedItems[index];
const newItem = { const newItem = {
name: String(extractedData.name || "New AI Item"), name: String(data.Item || data.name || "New AI Item"),
category: String(extractedData.category || "Uncategorized"), category: String(data.Category || data.category || "Uncategorized"),
type: extractedData.type ? String(extractedData.type) : null, type: data.Type || data.type ? String(data.Type || data.type) : null,
part_number: extractedData.part_number ? String(extractedData.part_number) : null, part_number: data.PartNr || data.part_number ? String(data.PartNr || data.part_number) : null,
color: extractedData.color ? String(extractedData.color) : null, color: data.Color || data.color ? String(data.Color || data.color) : null,
description: String(extractedData.description || ""), description: String(data.Description || data.description || ""),
connector: extractedData.connector ? String(extractedData.connector) : null, connector: data.Connector || data.connector ? String(data.Connector || data.connector) : null,
size: extractedData.size ? String(extractedData.size) : null, size: data.Size || data.size ? String(data.Size || data.size) : null,
ocr_text: extractedData.ocr_text ? String(extractedData.ocr_text) : null, ocr_text: data.OCR || data.ocr_text ? String(data.OCR || data.ocr_text) : null,
specs: String(extractedData.specs || ""), // Keep specs as extra data if needed specs: String(data.specs || ""),
barcode: String(extractedData.barcode || extractedData.part_number || `AI-${Date.now()}`), barcode: String(data.barcode || data.PartNr || data.part_number || `AI-${Date.now()}-${index}`),
quantity: parseFloat(String(extractedData.quantity || 1)), quantity: parseFloat(String(data.quantity || 1)),
min_quantity: 1.0, min_quantity: 1.0,
box_label: extractedData.box_label ? String(extractedData.box_label) : null, box_label: data.box_label ? String(data.box_label) : null,
labels_data: JSON.stringify(extractedData) labels_data: JSON.stringify(data)
}; };
onComplete(newItem); onComplete(newItem);
if (extractedItems.length > 1) {
const remaining = [...extractedItems];
remaining.splice(index, 1);
setExtractedItems(remaining);
setEditingIndex(null);
} else {
setExtractedItems([]);
setEditingIndex(null);
}
}; };
const confirmAllItems = async () => {
// Clone items and process them sequentially
const itemsToProcess = [...extractedItems];
setUploading(true);
const toastId = toast.loading(`Adding ${itemsToProcess.length} items...`);
try {
for (let i = 0; i < itemsToProcess.length; i++) {
const data = itemsToProcess[i];
const newItem = {
name: String(data.Item || data.name || "New AI Item"),
category: String(data.Category || data.category || "Uncategorized"),
type: data.Type || data.type ? String(data.Type || data.type) : null,
part_number: data.PartNr || data.part_number ? String(data.PartNr || data.part_number) : null,
color: data.Color || data.color ? String(data.Color || data.color) : null,
description: String(data.Description || data.description || ""),
connector: data.Connector || data.connector ? String(data.Connector || data.connector) : null,
size: data.Size || data.size ? String(data.Size || data.size) : null,
ocr_text: data.OCR || data.ocr_text ? String(data.OCR || data.ocr_text) : null,
specs: String(data.specs || ""),
barcode: String(data.barcode || data.PartNr || data.part_number || `AI-${Date.now()}-${i}`),
quantity: parseFloat(String(data.quantity || 1)),
min_quantity: 1.0,
box_label: data.box_label ? String(data.box_label) : null,
labels_data: JSON.stringify(data)
};
// Wait for parent to process each one
await onComplete(newItem);
}
toast.success(`Successfully added ${itemsToProcess.length} items`, { id: toastId });
setExtractedItems([]);
onCancel(); // Close the modal after bulk completion
} catch (err) {
toast.error("Error during batch add", { id: toastId });
} finally {
setUploading(false);
}
};
const updateEditingItem = (fields: any) => {
if (editingIndex === null) return;
const newItems = [...extractedItems];
newItems[editingIndex] = { ...newItems[editingIndex], ...fields };
setExtractedItems(newItems);
};
// Extract unique item types for suggestions
const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[];
const existingBoxes = Array.from(new Set(inventory.map(i => i.box_label).filter(Boolean))).sort() as string[];
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = () => setImage(reader.result as string);
reader.readAsDataURL(file);
}
};
useEffect(() => {
// Cleanup on unmount
return () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
}
};
}, []);
return ( return (
<div className="fixed inset-0 z-50 bg-slate-950 flex flex-col p-6 animate-in fade-in slide-in-from-bottom-5 duration-300"> <div className="fixed inset-0 z-50 bg-slate-950 flex flex-col p-6 animate-in fade-in slide-in-from-bottom-5 duration-300">
<div className="flex justify-between items-center mb-6 shrink-0"> <div className="flex justify-between items-center mb-6 shrink-0">
@@ -99,12 +240,12 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
<p className="text-xs text-slate-500 font-bold">Powered by Gemini 2.0 Flash</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={() => { stopLiveCamera(); onCancel(); }} className="p-2 hover:bg-slate-900 rounded-full transition-colors">
<X size={24} /> <X size={24} />
</button> </button>
</div> </div>
{!image ? ( {!image && !isLive ? (
<div className="flex-1 flex flex-col gap-6 min-h-0"> <div className="flex-1 flex flex-col gap-6 min-h-0">
<div className="flex bg-slate-900/50 p-1.5 rounded-2xl border border-slate-800/50 shrink-0"> <div className="flex bg-slate-900/50 p-1.5 rounded-2xl border border-slate-800/50 shrink-0">
<button <button
@@ -112,34 +253,34 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-bold transition-all ${mode === 'item' ? 'bg-primary text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'}`} className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-bold transition-all ${mode === 'item' ? 'bg-primary text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'}`}
> >
<Package size={18} /> <Package size={18} />
<span className="text-xs">Item Label</span> <span className="text-xs">Discovery Mode</span>
</button> </button>
<button <button
onClick={() => setMode('box')} onClick={() => setMode('box')}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-bold transition-all ${mode === 'box' ? 'bg-primary text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'}`} className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-bold transition-all ${mode === 'box' ? 'bg-primary text-white shadow-lg' : 'text-slate-500 hover:text-slate-300'}`}
> >
<Layers size={18} /> <Layers size={18} />
<span className="text-xs">Box / Container</span> <span className="text-xs">Box Lookup</span>
</button> </button>
</div> </div>
<div className="flex-1 flex flex-col items-center justify-center border-2 border-dashed border-slate-800 rounded-[2.5rem] bg-slate-900/30 overflow-hidden px-4"> <div className="flex-1 flex flex-col items-center justify-center border-2 border-dashed border-slate-800 rounded-[2.5rem] bg-slate-900/30 overflow-hidden px-4">
<div className="w-20 h-20 bg-slate-900 rounded-3xl flex items-center justify-center mb-6 shadow-inner"> <div className="w-20 h-20 bg-slate-900 rounded-3xl flex items-center justify-center mb-6 shadow-inner text-primary">
<Camera size={32} className={mode === 'box' ? 'text-primary' : 'text-slate-500'} /> <Camera size={32} />
</div> </div>
<p className="text-slate-300 mb-2 text-center font-bold"> <p className="text-slate-300 mb-2 text-center font-bold">
{mode === 'box' ? 'Container Discovery Mode' : 'Label Insight Mode'} {mode === 'box' ? 'Deep Box Analysis' : 'Multi-Item Extraction'}
</p> </p>
<p className="text-xs text-slate-500 px-8 text-center leading-relaxed font-bold"> <p className="text-xs text-slate-500 px-8 text-center leading-relaxed font-bold">
{mode === 'box' {mode === 'box'
? 'Scan the large, prominent label or hand-written name on the container' ? 'Scan container labels to identify storage locations'
: 'Scan or upload a sharp photo of the item specifications'} : 'Identify multiple technical items from a single photo or label'}
</p> </p>
</div> </div>
<div className="grid grid-cols-2 gap-4 h-28 shrink-0"> <div className="grid grid-cols-2 gap-4 h-28 shrink-0">
<button <button
onClick={() => cameraInputRef.current?.click()} onClick={startLiveCamera}
className="flex flex-col items-center justify-center gap-2 bg-primary text-white rounded-3xl font-bold shadow-2xl shadow-primary/20 active:scale-95 transition-all" className="flex flex-col items-center justify-center gap-2 bg-primary text-white rounded-3xl font-bold shadow-2xl shadow-primary/20 active:scale-95 transition-all"
> >
<Camera size={24} /> <Camera size={24} />
@@ -154,10 +295,58 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
</button> </button>
</div> </div>
<input type="file" ref={cameraInputRef} onChange={handleFileChange} accept="image/*" capture="environment" className="hidden" />
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" className="hidden" /> <input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" className="hidden" />
</div> </div>
) : !extractedData ? ( ) : isLive ? (
// LIVE VIEWFINDER MODE
<div className="flex-1 flex flex-col gap-6 min-h-0 overflow-hidden">
<div className="relative flex-1 min-h-0 rounded-[2.5rem] overflow-hidden border-4 border-slate-900 shadow-2xl bg-black">
<video
ref={videoRef}
autoPlay
playsInline
muted
className="w-full h-full object-cover"
/>
<canvas ref={canvasRef} className="hidden" />
<div className="absolute inset-0 border-[20px] border-black/20 pointer-events-none">
<div className="w-full h-full border border-primary/30 rounded-2xl relative">
{/* Corner Markers */}
<div className="absolute top-0 left-0 w-8 h-8 border-t-2 border-l-2 border-primary rounded-tl-xl" />
<div className="absolute top-0 right-0 w-8 h-8 border-t-2 border-r-2 border-primary rounded-tr-xl" />
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-2 border-l-2 border-primary rounded-bl-xl" />
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-2 border-r-2 border-primary rounded-br-xl" />
</div>
</div>
<div className="absolute top-6 left-1/2 -translate-x-1/2 bg-black/60 backdrop-blur-md px-4 py-1.5 rounded-full border border-white/10">
<span className="text-[10px] font-black text-white uppercase tracking-widest flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
Live Viewfinder
</span>
</div>
</div>
<div className="flex gap-4 shrink-0 pb-2">
<button
onClick={stopLiveCamera}
className="px-6 py-4 bg-slate-900 border border-slate-800 text-slate-400 rounded-2xl font-bold transition-all active:scale-95"
>
Cancel
</button>
<button
onClick={captureSnapshot}
className="flex-1 py-4 bg-white text-slate-950 rounded-2xl font-black text-lg shadow-xl shadow-white/10 flex items-center justify-center gap-3 active:scale-95 hover:bg-slate-200"
>
<div className="w-5 h-5 rounded-full border-2 border-slate-950 flex items-center justify-center">
<div className="w-2.5 h-2.5 rounded-full bg-slate-950" />
</div>
Capture Image
</button>
</div>
</div>
) : extractedItems.length === 0 ? (
<div className="flex-1 flex flex-col gap-6 min-h-0 overflow-hidden"> <div className="flex-1 flex flex-col gap-6 min-h-0 overflow-hidden">
<div className="relative flex-1 min-h-0 rounded-[2.5rem] overflow-hidden border-4 border-slate-900 shadow-2xl bg-slate-900"> <div className="relative flex-1 min-h-0 rounded-[2.5rem] overflow-hidden border-4 border-slate-900 shadow-2xl bg-slate-900">
<img src={image} className="w-full h-full object-contain" alt="Captured label" /> <img src={image} className="w-full h-full object-contain" alt="Captured label" />
@@ -192,55 +381,54 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
</button> </button>
</div> </div>
</div> </div>
) : ( ) : editingIndex !== null ? (
<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-xs text-slate-500 font-bold">Validation Mask</span> <span className="text-xs text-slate-500 font-bold uppercase tracking-wider">Item Details ({editingIndex + 1}/{extractedItems.length})</span>
<span className="text-xs bg-green-500/10 text-green-400 px-2 py-0.5 rounded-full font-bold">AI Ready</span> <button
onClick={() => setEditingIndex(null)}
className="text-xs text-primary font-bold px-3 py-1 bg-primary/10 rounded-full"
>
Back to List
</button>
</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-2 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 py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Item Name</label> <label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Item Name</label>
<textarea <textarea
value={extractedData.name || ''} value={extractedItems[editingIndex].Item || extractedItems[editingIndex].name || ''}
onChange={(e) => setExtractedData({...extractedData, name: e.target.value})} onChange={(e) => updateEditingItem({ Item: e.target.value })}
className="bg-transparent w-full text-xl font-bold outline-none text-white placeholder:text-slate-700 resize-none h-20" className="bg-transparent w-full text-lg font-bold outline-none text-white placeholder:text-slate-700 resize-none h-8 leading-tight selection:bg-primary/30 py-0"
placeholder="Product name..." placeholder="SSD, SFP, Cable..."
/> />
</div> </div>
<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 py-3 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<div className="flex items-center gap-1.5 mb-1 ml-1 group-focus-within:text-primary transition-colors"> <label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Category</label>
<Layers size={12} />
<label className="text-xs text-slate-500 font-bold">Category Group</label>
</div>
<div className="relative flex items-center">
<select
value={extractedData.category || ''}
onChange={(e) => setExtractedData({...extractedData, category: e.target.value})}
className="bg-transparent w-full font-bold outline-none text-slate-200 appearance-none"
>
<option value="" className="bg-slate-900 font-bold">Other / New</option>
{categories.map(c => (
<option key={c.id} value={c.name} className="bg-slate-900">{c.name}</option>
))}
</select>
<ChevronDown size={14} className="absolute right-0 text-slate-500 pointer-events-none" />
</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="flex items-center gap-1.5 mb-1 ml-1 group-focus-within:text-primary transition-colors">
<Package size={12} />
<label className="text-xs text-slate-500 font-bold">Item Type</label>
</div>
<input <input
value={extractedData.type || ''} type="text"
list="onboarding-types" list="onboarding-categories"
onChange={(e) => setExtractedData({...extractedData, type: e.target.value})} value={extractedItems[editingIndex].Category || extractedItems[editingIndex].category || ''}
onChange={(e) => updateEditingItem({ Category: e.target.value })}
className="bg-transparent w-full font-bold outline-none text-slate-200" className="bg-transparent w-full font-bold outline-none text-slate-200"
placeholder="e.g. SFP+" placeholder="e.g. storage"
/>
<datalist id="onboarding-categories">
{categories.map(c => (
<option key={c.id} value={c.name} />
))}
</datalist>
</div>
<div className="bg-slate-900/80 py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Item Type</label>
<input
value={extractedItems[editingIndex].Type || extractedItems[editingIndex].type || ''}
list="onboarding-types"
onChange={(e) => updateEditingItem({ Type: e.target.value })}
className="bg-transparent w-full text-sm font-bold outline-none text-slate-200"
placeholder="e.g. spare parts"
/> />
<datalist id="onboarding-types"> <datalist id="onboarding-types">
{existingTypes.map(t => <option key={t} value={t} />)} {existingTypes.map(t => <option key={t} value={t} />)}
@@ -248,26 +436,23 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-3">
<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 py-3 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Item Color</label> <label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Item Color</label>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{backgroundColor: extractedData.color || 'transparent'}} />
<input
value={extractedData.color || ''}
onChange={(e) => setExtractedData({...extractedData, color: e.target.value})}
className="bg-transparent w-full font-bold outline-none text-slate-200"
placeholder="e.g. Turquoise"
/>
</div>
</div>
<div className="bg-slate-900/80 p-5 rounded-[1.5rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Box / Container Label</label>
<input <input
value={extractedData.box_label || ''} value={extractedItems[editingIndex].Color || extractedItems[editingIndex].color || ''}
list="onboarding-boxes" onChange={(e) => updateEditingItem({ Color: e.target.value })}
onChange={(e) => setExtractedData({...extractedData, box_label: e.target.value})}
className="bg-transparent w-full font-bold outline-none text-slate-200" className="bg-transparent w-full font-bold outline-none text-slate-200"
placeholder="e.g. Aqua"
/>
</div>
<div className="bg-slate-900/80 py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Box / Container</label>
<input
value={extractedItems[editingIndex].box_label || ''}
list="onboarding-boxes"
onChange={(e) => updateEditingItem({ box_label: e.target.value })}
className="bg-transparent w-full text-sm font-bold outline-none text-primary"
placeholder="e.g. Box 1" placeholder="e.g. Box 1"
/> />
<datalist id="onboarding-boxes"> <datalist id="onboarding-boxes">
@@ -276,98 +461,150 @@ export default function AIOnboarding({ onCancel, onComplete, categories, invento
</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 py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Description</label> <label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Description</label>
<textarea <textarea
value={extractedData.description || ''} value={extractedItems[editingIndex].Description || extractedItems[editingIndex].description || ''}
onChange={(e) => setExtractedData({...extractedData, description: e.target.value})} onChange={(e) => updateEditingItem({ Description: e.target.value })}
className="bg-transparent w-full text-sm leading-relaxed outline-none resize-none h-16 text-slate-300" className="bg-transparent w-full text-sm leading-tight outline-none resize-none h-8 text-slate-300 py-0"
placeholder="Product description..." placeholder="Product description..."
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-3">
<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 py-3 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Connector</label> <label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Connector</label>
<input <input
value={extractedData.connector || ''} value={extractedItems[editingIndex].Connector || extractedItems[editingIndex].connector || ''}
onChange={(e) => setExtractedData({...extractedData, connector: e.target.value})} onChange={(e) => updateEditingItem({ Connector: e.target.value })}
className="bg-transparent w-full font-bold outline-none text-slate-200" className="bg-transparent w-full font-bold outline-none text-slate-200"
placeholder="e.g. LC/UPC" placeholder="e.g. LC/UPC"
/> />
</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 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Size / Length</label> <label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Size / Length</label>
<input <input
value={extractedData.size || ''} value={extractedItems[editingIndex].Size || extractedItems[editingIndex].size || ''}
onChange={(e) => setExtractedData({...extractedData, size: e.target.value})} onChange={(e) => updateEditingItem({ Size: e.target.value })}
className="bg-transparent w-full font-bold outline-none text-slate-200" className="bg-transparent w-full text-sm font-bold outline-none text-slate-200"
placeholder="e.g. 5m / 10G" placeholder="e.g. 5m / 10G"
/> />
</div> </div>
</div> </div>
<div className="bg-slate-900 border-2 border-primary/20 p-5 rounded-[1.5rem] shadow-lg"> <div className="bg-slate-900/80 py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<div className="flex items-center gap-2 mb-2 text-primary"> <label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">OCR Matching Key</label>
<Sparkles size={14} />
<label className="text-xs font-black uppercase tracking-tighter">AI OCR Matching Key</label>
</div>
<textarea <textarea
value={extractedData.ocr_text || ''} value={extractedItems[editingIndex].OCR || extractedItems[editingIndex].ocr_text || ''}
onChange={(e) => setExtractedData({...extractedData, ocr_text: e.target.value})} onChange={(e) => updateEditingItem({ OCR: e.target.value })}
className="bg-transparent w-full text-[10px] font-mono leading-tight outline-none resize-none h-12 text-slate-400" className="bg-transparent w-full text-sm font-bold leading-tight outline-none resize-none h-10 text-slate-200 py-0 scrollbar-hide"
placeholder="Heuristic string for local matching..." placeholder="Heuristic string for local matching..."
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-2">
<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 py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Part Number (P/N)</label> <label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors tracking-tight uppercase">Part Number</label>
<div className="flex items-center gap-2"> <input
<Hash size={14} className="text-primary/60" /> value={extractedItems[editingIndex].PartNr || extractedItems[editingIndex].part_number || ''}
<input onChange={(e) => updateEditingItem({ PartNr: e.target.value })}
value={extractedData.part_number || ''} className="bg-transparent w-full font-mono text-sm font-bold outline-none text-slate-200 uppercase"
onChange={(e) => setExtractedData({...extractedData, part_number: e.target.value})} placeholder="ID code..."
className="bg-transparent w-full font-mono text-sm outline-none text-slate-200" />
placeholder="ID code..."
/>
</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 py-2.5 px-4 rounded-[1.2rem] border border-slate-800 focus-within:border-primary/50 transition-colors group">
<label className="text-xs text-slate-500 font-bold mb-1 block group-focus-within:text-primary transition-colors">Initial Stock</label> <label className="text-[10px] text-slate-500 font-bold mb-0.5 block group-focus-within:text-primary transition-colors uppercase tracking-tighter">Initial Qty</label>
<div className="flex items-center gap-2"> <input
<Layout size={14} className="text-amber-500/60" /> type="number"
<input value={extractedItems[editingIndex].quantity || 1}
type="number" onChange={(e) => updateEditingItem({ quantity: parseFloat(e.target.value) })}
value={extractedData.quantity || 1} className="bg-transparent w-full font-black text-base outline-none text-slate-200"
onChange={(e) => setExtractedData({...extractedData, quantity: parseInt(e.target.value)})} />
className="bg-transparent w-full font-black text-lg outline-none text-slate-200"
/>
</div>
</div> </div>
</div> </div>
<div className="bg-slate-900/30 p-4 rounded-[1.5rem] border border-slate-800/50">
<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">
{extractedData.serial_number && <span>S/N: {extractedData.serial_number}</span>}
{extractedData.additional_data && <span>+ More Data</span>}
</div>
</div>
</div> </div>
<div className="flex flex-col gap-3 shrink-0"> <div className="flex flex-col gap-3 shrink-0">
<button <button
onClick={confirmOnboarding} onClick={() => confirmSingleItem(editingIndex)}
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 Add to Catalog
</button>
</div>
</div>
) : (
<div className="flex-1 flex flex-col gap-6 overflow-hidden">
<div className="flex items-center justify-between">
<h3 className="text-sm font-black text-slate-400 uppercase tracking-widest">Discovery Dashboard</h3>
<span className="text-xs bg-primary/20 text-primary px-3 py-1 rounded-full font-bold">
{extractedItems.length} items found
</span>
</div>
<div className="flex-1 overflow-y-auto space-y-3 pr-1 scrollbar-hide">
{extractedItems.map((item, idx) => (
<div
key={idx}
className="bg-slate-900/50 border border-slate-800 rounded-3xl p-5 flex items-center justify-between group hover:border-primary/40 transition-all active:scale-[0.98]"
>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => setEditingIndex(idx)}
>
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-xl bg-primary/10 flex items-center justify-center text-primary">
<Package size={16} />
</div>
<h4 className="font-black text-slate-100 truncate">{item.Item || item.name || "Unknown Item"}</h4>
</div>
<div className="flex gap-2">
<span className="text-[10px] font-black uppercase px-2 py-0.5 bg-slate-800 text-slate-400 rounded-md tracking-wider">
{item.Type || item.type || "Generic"}
</span>
<span className="text-[10px] font-black uppercase px-2 py-0.5 bg-slate-800 text-slate-500 rounded-md tracking-wider">
{item.PartNr || item.part_number || "No P/N"}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
const newItems = [...extractedItems];
newItems.splice(idx, 1);
setExtractedItems(newItems);
}}
className="p-3 text-slate-600 hover:text-red-500 transition-colors"
>
<X size={20} />
</button>
<button
onClick={() => setEditingIndex(idx)}
className="p-3 bg-primary/10 text-primary rounded-2xl hover:bg-primary/20 transition-all"
>
<ChevronDown size={20} className="-rotate-90" />
</button>
</div>
</div>
))}
</div>
<div className="flex flex-col gap-3 shrink-0">
<button
onClick={confirmAllItems}
className="py-5 bg-white text-slate-950 rounded-[1.8rem] font-black text-lg shadow-2xl shadow-white/10 active:scale-95 transition-all hover:bg-slate-200"
>
Add {extractedItems.length} items to catalog
</button> </button>
<button <button
onClick={() => setExtractedData(null)} onClick={() => {
className="py-3 text-xs text-slate-600 font-bold hover:text-slate-400" setExtractedItems([]);
setImage(null);
}}
className="py-3 text-xs text-slate-600 font-bold hover:text-slate-400 tracking-widest uppercase"
> >
Reset Extraction Discard Discovery
</button> </button>
</div> </div>
</div> </div>

View File

@@ -23,7 +23,7 @@ export const getNetworkConfig = async () => {
} catch (e) { } catch (e) {
console.warn("Network config not found, using compiled defaults."); console.warn("Network config not found, using compiled defaults.");
// Defaults matching the initial reserve ports in case network.json is missing // Defaults matching the initial reserve ports in case network.json is missing
return { SERVER_IP: 'localhost', BACKEND_PORT: 8906, BACKEND_SSL_PORT: 8908 }; return { SERVER_IP: 'localhost', BACKEND_PORT: 8916, BACKEND_SSL_PORT: 8918 };
} }
}; };

View File

@@ -1,5 +1,5 @@
{ {
"SERVER_IP": "192.168.84.113", "SERVER_IP": "192.168.84.140",
"BACKEND_PORT": 8916, "BACKEND_PORT": 8916,
"BACKEND_SSL_PORT": 8918, "BACKEND_SSL_PORT": 8918,
"FRONTEND_PORT": 8917, "FRONTEND_PORT": 8917,

32
inventory.env.example Normal file
View File

@@ -0,0 +1,32 @@
# =============================================================================
# TFM aInventory — Master Environment Configuration
# =============================================================================
# 1. Copy this file to 'inventory.env'
# 2. Fill in your real values
# 3. 'inventory.env' is ignored by Git to protect your secrets.
# =============================================================================
# --- Network & Identity ---
# Use your LAN IP (e.g., 192.168.1.10) for mobile access.
SERVER_IP=localhost
# --- AI API Keys ---
# Google Gemini API Key (Required for AI label OCR onboarding)
# Get one at: https://aistudio.google.com/
GEMINI_API_KEY=your_gemini_api_key_here
# --- Security ---
# JWT secret key — generate a strong random value for production:
# python3 -c "import secrets; print(secrets.token_urlsafe(64))"
JWT_SECRET_KEY=change_me_in_production
# --- Infrastructure Ports (Host-side mapping) ---
BACKEND_PORT=8916
BACKEND_SSL_PORT=8918
FRONTEND_PORT=8917
FRONTEND_SSL_PORT=8919
# --- External Access (CORS) ---
# Comma-separated list of extra IPs or FQDNs allowed (e.g. Tailscale, VPN)
# Example: EXTRA_ALLOWED_ORIGINS=100.78.182.27,inventory.my-domain.com
EXTRA_ALLOWED_ORIGINS=