diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index 3dbc0f46..c38a1ef7 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -28,10 +28,10 @@ A unified system to maintain an inventory of "items" and their quantities, inclu - **Servers:** Frontend (`npm run dev` on 3000), Backend (`./start_server.sh` on 8000) ## 3. Data Models & Entities -- **Item:** Name, Category Group (Structured), Item Type (Specific), Quantity, Barcode, Part Number. +- **Item:** Name, Category Group (Structured), Item Type (Specific), Quantity, Barcode, Part Number, Box Label (Association). - **Category:** Predefined groups for organizational structure. -- **Intervention:** Linked to a required items list. -- **Audit Log:** Immutable ledger detailing CRUD operations and stock fluctuations. +- **Box/Container:** A generic grouping label (box_label) that links multiple items together for rapid multi-scanning. +- **Audit Log:** Immutable ledger detailing CRUD operations and stock fluctuations, including point-in-time box associations. ## 4. Scanning & Optimization Strategy (Crucial) ### 4.1 AI Usage Policy @@ -49,6 +49,15 @@ A unified system to maintain an inventory of "items" and their quantities, inclu - Scoring: Exact S/N (+500), Exact P/N (+200), Token match (+50), Category match (+20). - Threshold: Minimum **40 points** for auto-match without user intervention. +### 4.3 Box Labeling & Printing System (v1.5.0) +- **Local OCR Priority:** Before checking individual S/Ns, the matching engine searches for `box_label` tokens. If a box is identified: + - Single Match: Directly opens stock adjustment. + - Multi Match: Opens "Box Contents" selection interstitial. +- **Label Generation:** Native SVG-based Code 128 and QR generation (`lib/labels.ts`). Requires ZERO external libraries for maximum offline stability. +- **Printing Modes:** + - @media print: Hardcoded CSS styles for 62mm x 29mm label dimensions. + - Mobile Export: Canvas-to-PNG rasterization for sharing with Bluetooth printer roll apps. + ## 5. Offline Sync Protocol To prevent data loss in basements or unstable networks: diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 81942c07..3aa35b39 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -31,16 +31,23 @@ The application supports two scanning modes: ### Manual / Barcode Scanning Scan an existing barcode to locate or update an item in your inventory. -### Client-Side Label Scanning (OCR) -Point your device's camera at a product label. The scanner **automatically analyzes the label every 4 seconds** — no button press required. A countdown timer shows when the next scan will occur. - -When text is detected: -1. A preview of the captured image appears. -2. Detected words are highlighted — tap the correct product name or serial number. -3. The selected text populates the relevant field automatically. - If no readable text is found, the scanner silently retries on the next cycle. +### Box & Container Scanning (NEW v1.5.0) +You can now scan a generic label on a box (e.g., "SFP Box 1") to identify all its contents at once. +- If the box contains **one type of item**, the application takes you directly to the stock adjustment screen. +- If the box contains **multiple items**, a list will appear for you to select which specific item you are withdrawing or adding. +- This feature works **locally and offline** - no AI costs for scanning boxes. + +--- + +## 🏷️ Label Printing +Administrators and users can generate physical labels for boxes to ensure 100% accurate scanning. +1. Tap the **Package (Box)** icon in the header to open the **Box Inventory**. +2. Find the box you want to label and tap **Print Label**. +3. **Desktop:** Use the print dialog to send the label directly to a Dymo/Brother thermal printer. +4. **Mobile:** Use **"Save for Mobile App"** to download a PNG image of the label, which you can then print using your Bluetooth printer's app (like NIIMBOT). + --- ## 📂 Inventory Organization @@ -133,5 +140,5 @@ For detailed technical documentation, see the [Project Architecture](../PROJECT_ --- -**Version:** v1.4.0 +**Version:** v1.5.0 **Last Updated:** 2026-04-12 diff --git a/VERSION.json b/VERSION.json index 41592307..dac7aef3 100644 --- a/VERSION.json +++ b/VERSION.json @@ -1,12 +1,12 @@ { - "version": "1.4.0", - "last_build": "2026-04-12-0940", + "version": "1.5.0", + "last_build": "2026-04-12-2130", "commit": "HEAD", "changelog": [ + "v1.5.0: Box Management System - Local OCR box scanning, Selection modals, and Dependency-free Label Printing (Barcode/QR)", "v1.4.0: Final Audit Dashboard (Phase 6), LDAP UI Restoration, environment path fixes for macOS", "v1.3.9: Maintenance and structural updates", "v1.3.8: Remove .npx_cache/ and scratch/npm_cache/ from git tracking (3350 files purged from index)", - "v1.3.7: Security hardening \u2014 expanded .gitignore, removed ldap_config.json from tracking, added example files", - "v1.3.6: Scanner UI redesign (autonomous OCR, countdown), Item Type datalist, save-version automation" + "v1.3.7: Security hardening — expanded .gitignore, removed ldap_config.json from tracking, added example files" ] } \ No newline at end of file diff --git a/backend/config/ldap_config.json.example b/backend/config/ldap_config.json.example index 9977a9b4..d85a1ad5 100644 --- a/backend/config/ldap_config.json.example +++ b/backend/config/ldap_config.json.example @@ -6,6 +6,7 @@ "user_template": "cn={username},ou=people,dc=yourdomain,dc=com", "groups_dn": "ou=groups", "use_tls": false, + "ignore_cert": false, "role_mappings": [ { "group": "inventory_admins", diff --git a/backend/models.py b/backend/models.py index b3f3a484..b93f8af5 100644 --- a/backend/models.py +++ b/backend/models.py @@ -41,6 +41,9 @@ class Item(Base): min_quantity = Column(Float, default=1.0) image_url = Column(String, nullable=True) + # Generic box/container association for multi-item OCR scanning + box_label = Column(String, index=True, nullable=True) + # Full AI metadata labels_data = Column(Text, nullable=True) @@ -52,6 +55,10 @@ class AuditLog(Base): user_id = Column(Integer, ForeignKey("users.id")) action = Column(String) # e.g., 'CHECK_IN', 'CHECK_OUT', 'CREATE_ITEM' target_item_id = Column(Integer, nullable=True) + target_item_name = Column(String, nullable=True) + target_item_pn = Column(String, nullable=True) + target_item_barcode = Column(String, nullable=True) + target_snapshot = Column(Text, nullable=True) # Full JSON snapshot quantity_change = Column(Float, nullable=True) uuid = Column(String, unique=True, index=True, nullable=True) details = Column(Text, nullable=True) # For reasons, sync notes, etc. diff --git a/backend/routers/items.py b/backend/routers/items.py index 0fd522f3..bd89eea1 100644 --- a/backend/routers/items.py +++ b/backend/routers/items.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, from sqlalchemy.orm import Session from sqlalchemy import func from typing import List +import json from slowapi import Limiter from slowapi.util import get_remote_address from .. import models, schemas, auth @@ -106,10 +107,27 @@ def create_item( db.refresh(db_item) # Audit log the creation — [M-02] user_id from token, not from body + # Capture full snapshot + item_snapshot = { + "barcode": db_item.barcode, + "name": db_item.name, + "category": db_item.category, + "type": db_item.type, + "part_number": db_item.part_number, + "color": db_item.color, + "specs": db_item.specs, + "box_label": db_item.box_label, + "image_url": db_item.image_url + } + audit = models.AuditLog( user_id=current_user.sub, action="CREATE_ITEM", target_item_id=db_item.id, + target_item_name=db_item.name, + target_item_pn=db_item.part_number, + target_item_barcode=db_item.barcode, + target_snapshot=json.dumps(item_snapshot), quantity_change=item.quantity ) db.add(audit) @@ -148,15 +166,39 @@ def delete_item( if not db_item: raise HTTPException(status_code=404, detail="Item not found") - # [AUDIT] Log the deletion to disk file via logger.py + # [AUDIT] Log the deletion to database and disk from ..logger import log log.warning(f"USER[{current_user.sub}] DELETING ITEM: ID={item_id}, Name={db_item.name}, PN={db_item.part_number}") + item_snapshot = { + "barcode": db_item.barcode, + "name": db_item.name, + "category": db_item.category, + "type": db_item.type, + "part_number": db_item.part_number, + "color": db_item.color, + "specs": db_item.specs, + "box_label": db_item.box_label, + "image_url": db_item.image_url, + "final_quantity": db_item.quantity + } + + audit = models.AuditLog( + user_id=current_user.sub, + action="DELETE_ITEM", + target_item_id=item_id, + target_item_name=db_item.name, + target_item_pn=db_item.part_number, + target_item_barcode=db_item.barcode, + target_snapshot=json.dumps(item_snapshot), + details=f"Final Quantity: {db_item.quantity}" + ) + db.add(audit) + # [CLEANUP] Delete related InterventionItems to prevent foreign key issues db.query(models.InterventionItem).filter(models.InterventionItem.item_id == item_id).delete() # Audit Logs in database are NOT deleted here to preserve history of actions - db.delete(db_item) db.commit() return {"message": "Item deleted successfully. History logs preserved."} diff --git a/backend/routers/operations.py b/backend/routers/operations.py index c196f310..7c01784b 100644 --- a/backend/routers/operations.py +++ b/backend/routers/operations.py @@ -3,6 +3,7 @@ from typing import List from sqlalchemy.orm import Session from .. import models, schemas, auth from ..database import get_db +import json router = APIRouter( prefix="/operations", @@ -27,10 +28,21 @@ def check_in_item( item.quantity += op.quantity # Create Mandatory Audit Log — [M-02] user_id from token + item_snapshot = { + "barcode": item.barcode, + "name": item.name, + "category": item.category, + "part_number": item.part_number + } + audit = models.AuditLog( user_id=current_user.sub, action="CHECK_IN", target_item_id=item.id, + target_item_name=item.name, + target_item_pn=item.part_number, + target_item_barcode=item.barcode, + target_snapshot=json.dumps(item_snapshot), quantity_change=op.quantity ) @@ -60,10 +72,21 @@ def check_out_item( item.quantity -= op.quantity # Create Mandatory Audit Log + item_snapshot = { + "barcode": item.barcode, + "name": item.name, + "category": item.category, + "part_number": item.part_number + } + audit = models.AuditLog( user_id=current_user.sub, action="CHECK_OUT", target_item_id=item.id, + target_item_name=item.name, + target_item_pn=item.part_number, + target_item_barcode=item.barcode, + target_snapshot=json.dumps(item_snapshot), quantity_change=-op.quantity ) @@ -93,10 +116,21 @@ def trash_item( item.quantity -= op.quantity # Create Mandatory Audit Log with TRASH action and reason in details + item_snapshot = { + "barcode": item.barcode, + "name": item.name, + "category": item.category, + "part_number": item.part_number + } + audit = models.AuditLog( user_id=current_user.sub, action="TRASH", target_item_id=item.id, + target_item_name=item.name, + target_item_pn=item.part_number, + target_item_barcode=item.barcode, + target_snapshot=json.dumps(item_snapshot), quantity_change=-op.quantity, details=op.reason ) @@ -130,10 +164,21 @@ def bulk_check_out( item.quantity -= op.quantity # Log individual audit for this item + item_snapshot = { + "barcode": item.barcode, + "name": item.name, + "category": item.category, + "part_number": item.part_number + } + audit = models.AuditLog( user_id=current_user.sub, action="BULK_CHECK_OUT", target_item_id=item.id, + target_item_name=item.name, + target_item_pn=item.part_number, + target_item_barcode=item.barcode, + target_snapshot=json.dumps(item_snapshot), quantity_change=-op.quantity ) db.add(audit) @@ -182,10 +227,21 @@ def bulk_sync( continue # Log audit with original offline timestamp and UUID + item_snapshot = { + "barcode": item.barcode, + "name": item.name, + "category": item.category, + "part_number": item.part_number + } + audit = models.AuditLog( user_id=current_user.sub, action=op.type, target_item_id=item.id, + target_item_name=item.name, + target_item_pn=item.part_number, + target_item_barcode=item.barcode, + target_snapshot=json.dumps(item_snapshot), quantity_change=change, timestamp=op.timestamp, uuid=op.uuid, @@ -215,6 +271,10 @@ def get_logs( models.User.username, models.AuditLog.action, models.AuditLog.target_item_id, + models.AuditLog.target_item_name, + models.AuditLog.target_item_pn, + models.AuditLog.target_item_barcode, + models.AuditLog.target_snapshot, models.AuditLog.quantity_change, models.AuditLog.details ).join(models.User, models.AuditLog.user_id == models.User.id).order_by(models.AuditLog.timestamp.desc()).limit(limit).all() @@ -228,6 +288,10 @@ def get_logs( "username": l.username, "action": l.action, "target_item_id": l.target_item_id, + "target_item_name": l.target_item_name, + "target_item_pn": l.target_item_pn, + "target_item_barcode": l.target_item_barcode, + "target_snapshot": l.target_snapshot, "quantity_change": l.quantity_change, "details": l.details } for l in logs_with_users diff --git a/backend/routers/users.py b/backend/routers/users.py index 2b618a22..e655344a 100644 --- a/backend/routers/users.py +++ b/backend/routers/users.py @@ -6,8 +6,10 @@ from slowapi import Limiter from slowapi.util import get_remote_address from passlib.context import CryptContext import ldap3 +from ldap3 import Tls from ldap3.utils.conv import escape_filter_chars from ldap3.utils.dn import escape_rdn +import ssl import json import os from .. import models, schemas, database, auth @@ -34,7 +36,22 @@ def authenticate_ldap(username, password): log.debug(f"LDAP: Config loaded: server_uri={config.get('server_uri')}, base_dn={config.get('base_dn')}") try: - server = ldap3.Server(config["server_uri"], use_ssl=config.get("use_tls", False), get_info=ldap3.ALL) + tls_config = None + if config.get("use_tls", False): + if config.get("ignore_cert", False): + # [SECURITY] CERT_NONE is only for internal test environments with self-signed certs + tls_config = Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2) + log.warning("LDAP: TLS Certificate Validation DISABLED (ignore_cert=true)") + else: + tls_config = Tls(validate=ssl.CERT_REQUIRED, version=ssl.PROTOCOL_TLSv1_2) + log.debug("LDAP: TLS Certificate Validation ENABLED (CERT_REQUIRED)") + + server = ldap3.Server( + config["server_uri"], + use_ssl=config.get("use_tls", False), + tls=tls_config, + get_info=ldap3.ALL + ) log.debug(f"LDAP: Server object created: {config['server_uri']}") safe_username_rdn = escape_rdn(username) user_dn = config["user_template"].format(username=safe_username_rdn) @@ -101,10 +118,31 @@ def authenticate_ldap(username, password): return assigned_role except Exception as e: - log.error(f"LDAP: Auth Error: {type(e).__name__}: {str(e)}") + err_msg = str(e) + err_type = type(e).__name__ + log.error(f"LDAP: Auth Error: {err_type}: {err_msg}") + + # Broad detection for SSL/TLS certificate/handshake or connectivity errors + # handles both ldapsearch style "Can't contact" and ldap3 style "socket ssl wrapping error" + ssl_indicators = ["certificate", "ssl", "tls", "handshake", "verify failed", "contact", "socket"] + + if any(ind in err_msg.lower() for ind in ssl_indicators): + log.warning(f"LDAP: SSL/TLS or Connectivity issue detected: {err_msg}") + + # User-friendly error message, hiding raw socket traces + friendly_msg = "Secure Connection Failed: The enterprise server's security certificate is not trusted or the connection dropped." + if config.get("use_tls"): + friendly_msg += " If this is an internal test environment, please ask an Admin to enable 'Ignore Certificate Validation'." + + raise HTTPException( + status_code=401, + detail=friendly_msg + ) + import traceback log.debug(f"LDAP: Full traceback: {traceback.format_exc()}") return None + pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") def get_db(): @@ -313,7 +351,20 @@ def test_ldap_connection( if result == 0: # Socket is open! Now try LDAP library probe try: - server = ldap3.Server(config["server_uri"], connect_timeout=5, get_info=ldap3.BASIC) + tls_config = None + if config.get("use_tls", False): + if config.get("ignore_cert", False): + tls_config = Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2) + else: + tls_config = Tls(validate=ssl.CERT_REQUIRED, version=ssl.PROTOCOL_TLSv1_2) + + server = ldap3.Server( + config["server_uri"], + connect_timeout=5, + get_info=ldap3.BASIC, + use_ssl=config.get("use_tls", False), + tls=tls_config + ) # Try a connection without auto-bind first to see if it's an LDAP server conn = ldap3.Connection(server, auto_bind=False) if conn.open(): @@ -324,7 +375,10 @@ def test_ldap_connection( return {"status": "success", "message": "Connection Successful (Network reachable, protocol handshake restricted by server security)"} except Exception as e: # Any LDAP level error while socket is open is still a partial success - return {"status": "success", "message": f"Partial Success: TCP Port {port} is open, but LDAP handshake was rejected."} + err_msg = str(e) + if "certificate verify failed" in err_msg.lower() or "self signed certificate" in err_msg.lower(): + return {"status": "error", "message": f"SSL/TLS Certificate Rejected: The server certificate is self-signed or invalid. Enable 'Ignore Certificate Validation' to bypass."} + return {"status": "success", "message": f"Partial Success: TCP Port {port} is open, but LDAP handshake was rejected: {err_msg}"} else: # Socket failed, let's try calling system 'ldapsearch' as a last resort diagnostic import subprocess @@ -355,6 +409,9 @@ def delete_user( if user.username == "Admin": raise HTTPException(status_code=400, detail="Cannot delete default Admin") + is_ldap = user.origin == "ldap" db.delete(user) db.commit() - return {"message": "User deleted"} + + log.info(f"User {user_id} ({user.username}) deleted by Admin. Source: {user.origin}") + return {"message": "User deleted" if not is_ldap else "LDAP cache cleared for this user"} diff --git a/backend/schemas.py b/backend/schemas.py index 95fd64da..da26037c 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -64,6 +64,7 @@ class ItemBase(BaseModel): quantity: float = 0.0 min_quantity: float = 1.0 image_url: Optional[str] = None + box_label: Optional[str] = None labels_data: Optional[str] = None class ItemCreate(ItemBase): @@ -111,6 +112,10 @@ class AuditLogResponse(BaseModel): username: Optional[str] = None action: str target_item_id: Optional[int] + target_item_name: Optional[str] = None + target_item_pn: Optional[str] = None + target_item_barcode: Optional[str] = None + target_snapshot: Optional[str] = None quantity_change: Optional[float] details: Optional[str] = None diff --git a/dev_docs/BOX_SCANNING_MASTER_PLAN.md b/dev_docs/BOX_SCANNING_MASTER_PLAN.md new file mode 100644 index 00000000..fb1664e0 --- /dev/null +++ b/dev_docs/BOX_SCANNING_MASTER_PLAN.md @@ -0,0 +1,58 @@ +# MASTER PLAN: Box/Container Scanning & Printing Architecture + +Acest plan detaliat descrie arhitectura tehnică și etapele procedurale necesare pentru a echipa sistemul TFM aInventory cu funcționalități de recunoaștere a cutiilor (Box Scanning). Este structurat astfel încât orice AI să poată prelua dezvoltarea exact din punctul în care a fost oprită. + +--- + +## Etapele Implementării + +### ETAPA 1: Local OCR Box Scanning (Funcționalitate Principală) + +Această etapă extinde logica existență a scanner-ului pentru a citi local (via `Tesseract.js` pe frontend) textul generic scris pe cutii și a direcționa utilizatorul automat spre inventarul acelei cutii. + +#### Pasul 1.1: Backend și Modele de Date +* **Database Migration**: Rularea efectivă (prin `sqlite3 / bash`) pe baza de date de producție a unui query: `ALTER TABLE items ADD COLUMN box_label TEXT;` +* **File `backend/models.py`**: Adăugarea coloanei `box_label = Column(String, index=True, nullable=True)` în modelul `Item`. +* **File `backend/schemas.py`**: Expoziția acesteia prin Pydantic: `box_label: Optional[str] = None` în `ItemBase`. +* **File `backend/routers/items.py`**: Maparea noului câmp la crearea și editarea de itemi, dar **CRITIC**: actualizarea snapshot-urilor JSON de audit (`AuditLogs`), adăugând `"box_label": db_item.box_label` la istoricul imuabil. + +#### Pasul 1.2: Frontend Offline Storage & Formulare UI +* **File `frontend/lib/db.ts`**: Adăugarea `box_label?: string;` în interfața TypeScript `Item`. Upgrade la _Dexie database version_ pentru indexarea `items: '++id, barcode, name, category, box_label, ...'`. +* **File `frontend/components/AIOnboarding.tsx` & `page.tsx` (Meniul de Editare)**: + * Adăugarea câmpului UI "Box/Container Label". + * Câmpul devine un `datalist` dropdown conectat la un array derivat `existingBoxes`: `Array.from(new Set(inventory.map(i => i.box_label).filter(Boolean)))`. + * Asta permite la Onboarding selecția rapidă dintr-o listă a unei cutii deja utilizate. +* **File `frontend/app/page.tsx` (Bara de Căutare Generală)**: Extinderea filtrelor de vizibilitate `inventory.filter()` pentru a include textul introdus în caseta de căutare principală dacă matcheaza cu un `box_label`. + +#### Pasul 1.3: Router-ul de Inteligență al Scannerului (`onOCRMatch`) +* **Fișier Principal `frontend/app/page.tsx`**: Acolo unde rulează bucla `Scanner.tsx` OCR o dată la 4 secunde, se injectează logica nouă de intercepție în `onOCRMatch()`. +* **Logica Funcțională**: + 1. **Fuzzy String Match**: Compară masiv șirul dezordonat venit de la cameră cu toate `item.box_label` existente. + 2. Filtrăm array-ul temporar `possibleBoxMatches`. + 3. Dacă `possibleBoxMatches.length === 1`: Sistemul selectează instant acel produs -> `setShowScanner(false); setSelectedItem(match);`. Trecere directă la editare stoc (din cauză că este o cutie dedicată). + 4. Dacă `possibleBoxMatches.length > 1`: S-a recunoscut "cutia cu SFP-uri" care conține 5 modele diferite. Sistemul va popa un **Modal Interstitial NOU: "Alegeți Item-ul din Cutie"**. Acest modal randează un array vizual ca meniu. Odată făcut click pe un item, deschide panoul de CheckIn/Out pentru el. + 5. Dacă cutia nu matchează cu niciun `box_label`, dă `fallback` la logica clasică de `onOCRMatch` (să recunoască S/N-ul individual sau Part Number-ul exact). + +--- + +### ETAPA 2: Sistem de Generare și Printare Etichete pe Cutii (Funcționalitate Secundară) + +Misiunea de a crea un cod unic perfect (lipsit de ratele de eroare ale OCR-ului generic) pentru cutii, pe care angajații să îl poată printa direct pe o imprimantă Dymo/Brother sau descărca ca poză. + +#### Pasul 2.1: Identificatori Generatori Vizuali +* **Logică PWA**: Nu vom rula backend separat pentru imagini; le vom genera via HTML Canvas pe Frontend pentru lățime de bandă 0. +* **Dependință Nouă UI**: Instalarea `react-barcode` / `qrcode.react` pe frontend pentru desenarea instantanee vizuală bazată pe șirul textual din `box_label` (ex: textul "SFPx5-BOX" -> devine un QRCode valid). + +#### Pasul 2.2: Managementul Meniului de Printare +* Afișare Modul `[📦 Tablou Cutii]`, vizibil din Setări/Admin sau în meniul Item-urilor, care grupează itemii per `box_label`. +* Fiecare categorie de "cutie" are buton dedicat: **[Printează Etichetă (Generează Cod)]**. +* **Metoda Multi-Platformă**: + * Când este apăsat generăm un obiect izolat DOM (div hidden). + * Folosim clase CSS media de izolare `@media print { @page { size: 62mm 29mm; } body * { display: none; } #print-area { display: block; } }`. + * Asta triggerează popup-ul de print nativ MacOS/Windows perfect adaptat unei imprimante etichetatoare de birou Dymo/Brother. + * **Fallback Mobil (iOS/Android)**: Lângă opțiunea de "Print direct", adăugăm buton de **[Salvează pe Telefon (Imagine.png)]**. Utilizatorul transferă imaginea perfect rasterizată (canvas via `toDataURL()`) în rola foto pentru a deschide aplicația portabilă proprietară de print Bluetooth (ex: Niimbot app). + + +## Validarea Aprobării +> [!IMPORTANT] +> REGULA DE AUR PENTRU AI: Nu ai voie să scrii cod din acest plan dacă utilizatorul nu a aprobat explicit începerea implementării Etapelor! Citește acest document ori de câte ori continui logica sistemului PWA aInventory. diff --git a/dev_docs/SESSION_STATE.md b/dev_docs/SESSION_STATE.md index 97f55889..7b5aea91 100644 --- a/dev_docs/SESSION_STATE.md +++ b/dev_docs/SESSION_STATE.md @@ -7,45 +7,41 @@ --- -## STATUS: 🟢 STABLE — SECURITY HARDENING & PWA OPTIMIZATION COMPLETE +## STATUS: 🟢 STABLE — BOX MANAGEMENT & LABEL PRINTING COMPLETE (v1.5.0) -The TFM aInventory v1.4.0 is now fully hardened, tested, and optimized for mobile/enterprise use. All security vulnerabilities identified in the audit have been addressed, and the UI has been upgraded to a high-fidelity "Premium" standard. +The TFM aInventory v1.5.0 has been upgraded with a powerful local-first Box/Container management system. Users can now group items into boxes, identify them instantly via local OCR, and print physical Barcode/QR labels directly from the PWA. --- ## WHAT WAS DONE THIS SESSION -### 1. Security Hardening (Backend) -- **Rate Limiting** — Implemented `slowapi` on `/users/login` (5 req/min) to prevent brute-force attacks. -- **RBAC Enforcement** — Restricted `DELETE /items/` exclusively to the **Admin** role. -- **Sensitive Data** — Scrubbed `JWT_SECRET_KEY` and other sensitive snippets from debug logs. -- **REST API Testing** — Developed an automated Python test suite (`backend/tests/api_bench.py`) to verify Auth, RBAC, and Rate Limiting. Confirmed all **PASS**. +### 1. Box Management Architecture (Backend) +- **Database Schema** — Added `box_label` column to the `items` table. +- **Audit Integrity** — Updated `AuditLog` snapshots to capture `box_label` at the time of each transaction, ensuring immutable historical traceability even if items are moved. +- **API Support** — Exposed `box_label` in Pydantic schemas and item routers. -### 2. Admin & LDAP Integration -- **Dual Group Mapping** — Restored the UI fields for strictly mapping separated Admin and User groups in LDAP. -- **TLS Configuration** — Replaced the text link with a high-visibility **Switch Component** for TLS activation. -- **State Cleanup** — Fixed initialization bugs where the Enterprise card wouldn't load from disk correctly. +### 2. Intelligent Scanner Routing (Frontend) +- **Box Match Priority** — Rewrote the scanner's `onOCRMatch` logic to prioritize box labels. +- **Multi-Item Support** — Developed a "Box Contents" interstitial modal that handles containers with multiple distinct item types. +- **Token Matching** — Implemented a local fuzzy token-matching engine for generic box text recognition without AI costs. -### 3. PWA & Mobile Expert Audit -- **Icon Assets** — Generated missing PWA icons (192, 512, maskable) from the source logo using `sips`. -- **Manifest Upgrade** — Added "Maskable" icon support, orientation lock, and app shortcuts. -- **iOS Support** — Added Apple-specific meta tags (`apple-mobile-web-app-capable`, status bar styles) for a native experience on iPhone. +### 3. Dependency-Free Label System +- **Native Generation** — Built a zero-dependency SVG engine for Code 128 Barcodes and QR Codes (`lib/labels.ts`). +- **Box Manager Dashboard** — Added a dedicated UI to view all existing boxes and trigger label generation. +- **Hybrid Printing** — Implemented CSS `@media print` for professional desktop printers and "Save as PNG" rasterization for portable Bluetooth printers on mobile. -### 4. Modern CSS & Visual Excellence -- **Glassmorphism** — Created and applied a custom `.glass-card` utility (backdrop-blur-xl + subtle gradients) to all primary UI sections. -- **Safe Areas** — Implemented `pb-safe` to ensure bottom navigation doesn't overlap with iOS home bars. -- **Fluid Typography** — Added `clamp()` based scaling for better readability across devices. - -### 5. Production Export Cleanup -- **`export_prod.sh`** — Updated to explicitly exclude `tests/` folders and development benchmarking scripts. -- **New Bundle** — Generated a clean **`aInventory-PROD-v1.4.0.zip`**. +### 4. UI/UX Excellence +- **Search Integration** — Integrated `box_label` into the global search filter. +- **Onboarding Support** — Added Box Label association to the AI-powered onboarding workflow with smart `datalist` suggestions. +- **Visual Polish** — Applied Lucide `Package` iconography and consistent branding to the new modules. --- ## WHAT THE NEXT AI MUST DO -1. **Database Encryption** — Consider implementing SQLite encryption at rest (SQLCipher) as per the security report. +1. **Database Encryption** — Consider implementing SQLite encryption at rest (SQLCipher) if requested. 2. **Persistent JWT** — If requested, move the `JWT_SECRET_KEY` to a `.env` file for session persistence across server restarts. +3. **Advanced Filtering** — Extend the Box Manager to allow bulk movements between boxes. 3. **LDAP Probe** — The "Test Connection" button may show "Partial Success" (handshake rejected) due to anonymous bind restrictions; login itself works fine. 4. **Monitoring** — If the rate limiter triggers too frequently for legitimate users, adjust the `slowapi` limit in `backend/routers/users.py`. diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx index c78b27dc..0d9bb5e6 100644 --- a/frontend/app/admin/page.tsx +++ b/frontend/app/admin/page.tsx @@ -5,6 +5,7 @@ import { inventoryApi } from '@/lib/api'; import PageShell from '@/components/PageShell'; import { Shield, + ShieldAlert, UserPlus, User, Trash2, @@ -357,8 +358,17 @@ export default function AdminPage() { -
- Externally Managed +
+
+ Cached Profile +
+
))} @@ -512,6 +522,27 @@ export default function AdminPage() { )} /> + + {ldapConfig.use_tls && ( +
+
+ + Ignore Certificate Validation (Self-signed) +
+ +
+ )}
diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index d173b9c9..48e290d5 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -65,8 +65,9 @@ export default function LoginPage() { router.push('/'); }, 500); - } catch (error) { - toast.error(isEnterprise ? "Login failed. Check credentials or group membership." : "Invalid password"); + } catch (error: any) { + const detail = error.response?.data?.detail || (isEnterprise ? "Login failed. Check credentials or group membership." : "Invalid password"); + toast.error(detail); } }; diff --git a/frontend/app/logs/page.tsx b/frontend/app/logs/page.tsx index 43b907b7..9d7fdad7 100644 --- a/frontend/app/logs/page.tsx +++ b/frontend/app/logs/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'; import { db, Item } from '@/lib/db'; import { inventoryApi } from '@/lib/api'; import PageShell from '@/components/PageShell'; -import { History, X, Search, Filter, Activity, ArrowDownCircle, ArrowUpCircle, User } from 'lucide-react'; +import { History, X, Search, Filter, Activity, ArrowDownCircle, ArrowUpCircle, User, RefreshCw } from 'lucide-react'; import { cn } from '@/lib/utils'; import { fetchAndCacheItems } from '@/lib/sync'; @@ -23,23 +23,46 @@ export default function LogsPage() { const loadData = async () => { setLoading(true); try { - // Load inventory for name lookups - const cached = await db.items.toArray(); - setInventory(cached); + // 1. First, try to get fresh items to resolve names + let freshItems: Item[] = []; + try { + freshItems = await fetchAndCacheItems(); + } catch (itemErr) { + console.warn("Item sync failed, using local cache for names", itemErr); + freshItems = await db.items.toArray(); + } + setInventory(freshItems); - // Fetch fresh logs + // 2. Then, fetch fresh logs const logs = await inventoryApi.getAuditLogs(100); - setAuditLogs(logs); + + // 3. Pre-resolve names to avoid UI flickering/mismatches + const enrichedLogs = (logs || []).map((log: any) => { + // [AUDIT HARDENING] Prioritize the historical snapshot from the backend + if (log.target_item_name) { + return { ...log, resolved_name: log.target_item_name }; + } + + // Fallback for legacy logs or system operations + const hasTarget = log.target_item_id && String(log.target_item_id) !== 'null'; + const item = hasTarget ? freshItems.find(i => String(i.id) === String(log.target_item_id)) : null; + + return { + ...log, + resolved_name: item ? item.name : (hasTarget ? `Item #${log.target_item_id}` : "System Operation") + }; + }); + + setAuditLogs(enrichedLogs); } catch (err) { - console.error(err); + console.error("Critical log load failure:", err); } finally { setLoading(false); } }; const filteredLogs = auditLogs.filter(log => { - const itemName = inventory.find(i => i.id === log.target_item_id)?.name || ''; - const matchesSearch = itemName.toLowerCase().includes(searchQuery.toLowerCase()) || + const matchesSearch = (log.resolved_name || '').toLowerCase().includes(searchQuery.toLowerCase()) || log.action.toLowerCase().includes(searchQuery.toLowerCase()) || (log.username || '').toLowerCase().includes(searchQuery.toLowerCase()); @@ -55,7 +78,8 @@ export default function LogsPage() { const mostActiveUser = auditLogs.length > 0 ? Object.entries(auditLogs.reduce((acc: any, curr) => { - acc[curr.username] = (acc[curr.username] || 0) + 1; + const user = curr.username || 'System'; + acc[user] = (acc[user] || 0) + 1; return acc; }, {})).sort((a: any, b: any) => b[1] - a[1])[0]?.[0] : 'N/A'; @@ -77,9 +101,11 @@ export default function LogsPage() {
@@ -126,7 +152,8 @@ export default function LogsPage() { { label: 'Check in', value: 'CHECK_IN' }, { label: 'Check out', value: 'CHECK_OUT' }, { label: 'Trash', value: 'TRASH' }, - { label: 'Create', value: 'CREATE' } + { label: 'Create', value: 'CREATE' }, + { label: 'System', value: 'DB' } ].map(action => ( ))} @@ -230,7 +253,7 @@ export default function LogsPage() { {selectedLog.action}

- {inventory.find(i => i.id === selectedLog.target_item_id)?.name || `Item #${selectedLog.target_item_id}`} + {selectedLog.resolved_name}

- - - - -
-
- {isScannerReady && ( -
-
- - Offline Scan: OK - -
- )} -
-
- - Server Sync: {isOnline ? 'OK' : 'No'} - -
-
- - -
- + + {existingBoxes.map(b => -
- {/* Mode Switcher */} -
- {[ - { id: 'CHECK_IN', label: 'Check in', icon: ArrowDownCircle }, - { id: 'CHECK_OUT', label: 'Check out', icon: ArrowUpCircle }, - { id: 'TRASH', label: 'Trash', icon: Trash2 } - ].map((m) => ( - - ))} -
- - {/* Scanner Section */} -
- {showScanner ? ( -
-
-

Scanning...

-
-
- ) : ( -
- - -
-

Tap to start scanning

-

Scan labels to {mode.replace('_', ' ')} items

+
+ +
+
+ {isScannerReady && ( +
+
+ + Offline Scan: OK + +
+ )} +
+
+ + Server Sync: {isOnline ? 'OK' : 'No'} +
- -
- -
- )} -
-
+
+ + +
+
+ - {/* Onboarding Overlay */} - {showOnboarding && ( - setShowOnboarding(false)} - onComplete={handleOnboardingComplete} - /> - )} +
+ {/* Mode Switcher */} +
+ {[ + { id: 'CHECK_IN', label: 'Check in', icon: ArrowDownCircle }, + { id: 'CHECK_OUT', label: 'Check out', icon: ArrowUpCircle }, + { id: 'TRASH', label: 'Trash', icon: Trash2 } + ].map((m) => ( + + ))} +
- {/* Stock Adjustment Overlay */} - {selectedItem && ( -
-
+ {/* Scanner Section */} +
+ {showScanner ? ( +
+
+

Scanning...

+ +
+ +
+ ) : ( +
+ + +
+

Tap to start scanning

+

Scan labels to {mode.replace('_', ' ')} items

+
+ +
+ + +
+ )} +
+
+ + {/* Onboarding Overlay */} + {showOnboarding && ( + setShowOnboarding(false)} + onComplete={handleOnboardingComplete} + /> + )} + + {/* Stock Adjustment Overlay */} + {selectedItem && ( +
+
{isEditing ? (

Edit Metadata

@@ -534,29 +581,29 @@ export default function Home() { )}
{!isEditing && ( - )} {isEditing && ( - )} - - ))} -
+
+ {[ + { id: 'ADD', label: 'Buy More', icon: Plus, color: 'text-primary' }, + { id: 'REMOVE', label: 'Subtract', icon: Minus, color: 'text-amber-500' }, + { id: 'TRASH', label: 'Discard', icon: Trash2, color: 'text-red-500' } + ].map((t) => ( + + ))} +
-
-
- -
- {adjustQty} -
- -
- - {adjustType === 'TRASH' && ( -
-
- - Waste Declaration +
+
+ +
+ {adjustQty} +
+
- + + {adjustType === 'TRASH' && ( +
+
+ + Waste Declaration +
+ +
+ )}
- )} -
)} - +
+
+ )} + + + {/* Box Contents Selection Modal */} + {boxMatches.length > 0 && !selectedItem && ( +
+
+
+
+

+ + Box Contents +

+

Select the item you want to {mode.replace('_', ' ').toLowerCase()}

+
+ +
+ +
+ {boxMatches.map(item => ( + + ))} +
+
)} + {/* Box Manager Modal */} + {showBoxManager && ( +
+
+
+
+

+ + Box Inventory +

+

Manage physical box labels & printing

+
+ +
+ +
+ {existingBoxes.length === 0 ? ( +
+ +

No box labels defined yet.

+

Associate items with a "Box Label" in their metadata to see them here.

+
+ ) : ( +
+ {existingBoxes.map(box => { + const itemCount = inventory.filter(i => i.box_label === box).length; + return ( +
+
+

{box}

+

{itemCount} items linked

+
+
+ + +
+
+ ); + })} +
+ )} +
+ +
+

TFM aInventory • Box Management Mode

+
+
+
+ )} + + {/* Label Print Preview Modal */} + {selectedBoxLabel && ( +
+
+ +