Compare commits

..

18 Commits

Author SHA1 Message Date
e5bb14d945 Build [v1.14.21] 2026-04-23 15:59:17 +03:00
6e88ea9107 Build [v1.14.20] 2026-04-23 15:36:20 +03:00
b0ac7426b7 Build [v1.14.19] 2026-04-23 15:27:27 +03:00
941761ad83 fix(09): fix syntax error in ExportPanel component 2026-04-23 15:27:27 +03:00
c21ed699ec Build [v1.14.18] 2026-04-23 15:24:27 +03:00
f77582fdcd fix(09): improve Caddy WebSocket support for Next.js HMR 2026-04-23 15:24:27 +03:00
654becfd86 Build [v1.14.17] 2026-04-23 15:19:55 +03:00
dcc3f692f3 fix(09): exact match export buttons to UI standard 2026-04-23 15:19:54 +03:00
1d22f1e85e Build [v1.14.16] 2026-04-23 15:18:14 +03:00
b87b1eceae fix(09): match export UI styles and fix backend user_id error 2026-04-23 15:18:14 +03:00
f2fb81024e Build [v1.14.15] 2026-04-23 15:14:59 +03:00
76f0e83a65 fix(09): refine export UI buttons and fix 404 2026-04-23 15:14:59 +03:00
c85a7b78a6 Build [v1.14.14] 2026-04-23 15:08:46 +03:00
6f9716c32b fix(09): resolve export data quality and integrate UI 2026-04-23 15:08:46 +03:00
96f4a5d228 Build [v1.14.13] 2026-04-23 14:51:52 +03:00
827dfcdf2f fix(08): fix infinite recursion in config manager 2026-04-23 14:51:51 +03:00
9afe335384 Build [v1.14.12] 2026-04-23 14:45:07 +03:00
c14a28b093 fix(08): fix AI key status detection and enable saving to secrets.yaml 2026-04-23 14:45:07 +03:00
22 changed files with 245 additions and 174 deletions

View File

@@ -1,3 +1,3 @@
826698 5397
826699 5398
826713 5414

View File

@@ -5,6 +5,7 @@
local_certs local_certs
skip_install_trust skip_install_trust
auto_https disable_redirects auto_https disable_redirects
on_demand_tls { on_demand_tls {
ask http://localhost:8916/ ask http://localhost:8916/
} }
@@ -17,7 +18,15 @@ https://:8919 {
} }
# Next.js HMR Support (WebSocket) # Next.js HMR Support (WebSocket)
header_up X-Forwarded-For {remote_host} handle /_next/webpack-hmr {
reverse_proxy http://localhost:8917 {
header_up Upgrade {>Upgrade}
header_up Connection {>Connection}
}
}
reverse_proxy http://localhost:8917 {
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto https header_up X-Forwarded-Proto https
header_up X-Forwarded-Host {host} header_up X-Forwarded-Host {host}
} }
@@ -37,7 +46,7 @@ https://:8918 {
} }
reverse_proxy http://localhost:8916 { reverse_proxy http://localhost:8916 {
header_up X-Forwarded-For {remote_host} header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto https header_up X-Forwarded-Proto https
header_up X-Forwarded-Host {host} header_up X-Forwarded-Host {host}
} }

View File

@@ -28,9 +28,10 @@ A unified system for inventory management featuring web administration, offline
### 2.3 Operations & Tooling ### 2.3 Operations & Tooling
- **PWA**: `next-pwa` (Service Workers + Manifest) - **PWA**: `next-pwa` (Service Workers + Manifest)
- **HTTPS Proxy**: Caddy (Port 8909) - **HTTPS Proxy**: Caddy (Ports 8918/8919)
- **Containerization**: Docker & Docker Compose - **Containerization**: Docker & Docker Compose
- **Deployment**: `deploy.sh` (Docker) or `start_server.sh` (Standalone) - **Deployment**: `deploy.py` (Docker) or `run_standalone.py` (Standalone)
- **Configuration**: Domain-specific YAML (`config/`) with `network.yaml` as SSOT.
--- ---
@@ -73,9 +74,9 @@ A unified system for inventory management featuring web administration, offline
- **JWT**: Stateless tokens for API auth. - **JWT**: Stateless tokens for API auth.
- **LDAP**: Primary source of truth for users in enterprise mode. - **LDAP**: Primary source of truth for users in enterprise mode.
- **Password Caching**: Encrypted local cache for offline authentication. - **Password Caching**: Encrypted local cache for offline authentication.
- **CORS**: Restricted origins in production via `config/backend.yaml`. - **CORS**: Restricted origins in production via `config/network.yaml` (SSOT).
--- ---
**Last Updated**: 2026-04-23 **Last Updated**: 2026-04-23
**Version**: 1.14.7 **Version**: 1.14.19

View File

@@ -1,7 +1,7 @@
import os import os
import yaml import yaml
import logging import logging
from .config_loader import load_config, get_config from .config_loader import load_config as loader_load_config, get_config as loader_get_config
log = logging.getLogger("ainventory") log = logging.getLogger("ainventory")
@@ -52,8 +52,8 @@ class ConfigManager:
log.info(f"✅ Updated {path} with new values.") log.info(f"✅ Updated {path} with new values.")
# Reload the global config # Reload the global config
load_config() loader_load_config()
return get_config() return loader_get_config()
except Exception as e: except Exception as e:
log.error(f"❌ Failed to write {path}: {e}") log.error(f"❌ Failed to write {path}: {e}")
raise raise
@@ -72,10 +72,78 @@ class ConfigManager:
except Exception: except Exception:
return False return False
@staticmethod
def get_secrets_path():
"""Returns the absolute path to secrets.yaml."""
base_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(base_dir)
return os.path.join(project_root, "config", "secrets.yaml")
@staticmethod
def update_secrets(updates: dict) -> dict:
"""Update secrets.yaml with new values (flattened structure)."""
path = ConfigManager.get_secrets_path()
current_secrets = {}
if os.path.exists(path):
try:
with open(path, 'r', encoding='utf-8') as f:
current_secrets = yaml.safe_load(f) or {}
except Exception as e:
log.error(f"❌ Failed to read {path}: {e}")
# Update flattened secrets
current_secrets.update(updates)
try:
with open(path, 'w', encoding='utf-8') as f:
# Header for clarity
f.write("# TFM aInventory - Managed Secrets (Updated via Admin UI)\n")
yaml.dump(current_secrets, f, default_flow_style=False, sort_keys=True)
log.info(f"✅ Updated {path} with new secrets.")
# Reload the global config
loader_load_config()
return loader_get_config()
except Exception as e:
log.error(f"❌ Failed to write {path}: {e}")
raise
@staticmethod
def update_keys(updates: dict) -> dict:
"""
Intelligent key update: routes sensitive AI keys to secrets.yaml
and others to backend.yaml.
"""
secrets_updates = {}
backend_updates = {}
sensitive_keys = ["GEMINI_API_KEY", "CLAUDE_API_KEY", "JWT_SECRET_KEY", "LDAP_PASSWORD"]
for key, val in updates.items():
if key in sensitive_keys:
secrets_updates[key] = val
else:
# Non-sensitive or complex nested settings
backend_updates[key] = val
if secrets_updates:
ConfigManager.update_secrets(secrets_updates)
if backend_updates:
ConfigManager.update_config(backend_updates)
return loader_get_config()
@staticmethod
def get_config() -> dict:
"""Return the current global configuration."""
return loader_get_config()
@staticmethod @staticmethod
def get_masked_key(key_name: str): def get_masked_key(key_name: str):
"""Returns a masked version of a configuration value or env var.""" """Returns a masked version of a configuration value or env var."""
config = get_config() config = loader_get_config()
# Try to find in config first # Try to find in config first
val = None val = None
@@ -113,6 +181,10 @@ def update_config(updates: dict) -> dict:
"""Update backend.yaml with new values and return updated config.""" """Update backend.yaml with new values and return updated config."""
return ConfigManager.update_config(updates) return ConfigManager.update_config(updates)
def get_config() -> dict:
"""Return the current global configuration."""
return loader_get_config()
def validate_config_file() -> bool: def validate_config_file() -> bool:
"""Validate backend.yaml syntax and required fields.""" """Validate backend.yaml syntax and required fields."""
return ConfigManager.validate_config_file() return ConfigManager.validate_config_file()

View File

@@ -67,8 +67,11 @@ def get_ai_config(
current_admin: auth.TokenData = Depends(auth.get_current_admin) current_admin: auth.TokenData = Depends(auth.get_current_admin)
): ):
"""Check AI provider status and active provider.""" """Check AI provider status and active provider."""
gemini_key = os.environ.get("GEMINI_API_KEY") config = ConfigManager.get_config() # Alias for config_loader.get_config
claude_key = os.environ.get("CLAUDE_API_KEY") ai_config = config.get("ai", {})
gemini_key = ai_config.get("gemini_api_key")
claude_key = ai_config.get("claude_api_key")
provider_setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first() provider_setting = db.query(models.SystemSetting).filter(models.SystemSetting.key == "ai_provider").first()
active_provider = provider_setting.value if provider_setting else "gemini" active_provider = provider_setting.value if provider_setting else "gemini"
@@ -112,10 +115,14 @@ def update_ai_keys(
if updates: if updates:
ConfigManager.update_keys(updates) ConfigManager.update_keys(updates)
# Re-load config to return accurate status
config = ConfigManager.get_config()
ai_cfg = config.get("ai", {})
return { return {
"status": "success", "status": "success",
"gemini_configured": bool(os.environ.get("GEMINI_API_KEY")), "gemini_configured": bool(ai_cfg.get("gemini_api_key")),
"claude_configured": bool(os.environ.get("CLAUDE_API_KEY")) "claude_configured": bool(ai_cfg.get("claude_api_key"))
} }

View File

@@ -5,7 +5,7 @@ Supports CSV and Excel formats.
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db from backend.database import get_db
@@ -61,7 +61,7 @@ async def export_inventory_snapshot(
# Log export action # Log export action
from backend.models import AuditLog from backend.models import AuditLog
audit_entry = AuditLog( audit_entry = AuditLog(
user_id=admin_user.id, user_id=admin_user.sub,
action="EXPORT_INVENTORY_SNAPSHOT", action="EXPORT_INVENTORY_SNAPSHOT",
details=f"Exported in {format_type} format", details=f"Exported in {format_type} format",
) )
@@ -70,20 +70,16 @@ async def export_inventory_snapshot(
# Return file response # Return file response
if format_type == "csv": if format_type == "csv":
# For CSV, use FileResponse with bytes return StreamingResponse(
import io
return FileResponse(
io.BytesIO(content.encode("utf-8")), io.BytesIO(content.encode("utf-8")),
media_type=media_type, media_type=media_type,
filename=filename, headers={"Content-Disposition": f"attachment; filename={filename}"}
) )
else: else:
# For Excel, content is already bytes return StreamingResponse(
import io
return FileResponse(
io.BytesIO(content), io.BytesIO(content),
media_type=media_type, media_type=media_type,
filename=filename, headers={"Content-Disposition": f"attachment; filename={filename}"}
) )
@@ -117,7 +113,7 @@ async def export_audit_trail(
# Log export action # Log export action
audit_entry = AuditLog( audit_entry = AuditLog(
user_id=admin_user.id, user_id=admin_user.sub,
action="EXPORT_AUDIT_TRAIL", action="EXPORT_AUDIT_TRAIL",
details=f"Exported in {format_type} format", details=f"Exported in {format_type} format",
) )
@@ -126,22 +122,20 @@ async def export_audit_trail(
# Return file response # Return file response
if format_type == "csv": if format_type == "csv":
import io return StreamingResponse(
return FileResponse(
io.BytesIO(content.encode("utf-8")), io.BytesIO(content.encode("utf-8")),
media_type=media_type, media_type=media_type,
filename=filename, headers={"Content-Disposition": f"attachment; filename={filename}"}
) )
else: else:
import io return StreamingResponse(
return FileResponse(
io.BytesIO(content), io.BytesIO(content),
media_type=media_type, media_type=media_type,
filename=filename, headers={"Content-Disposition": f"attachment; filename={filename}"}
) )
@router.get("/db/export") @router.get("/reports/export")
async def export_db( async def export_db(
format: str = Query("csv", description="Export format: csv or xlsx"), format: str = Query("csv", description="Export format: csv or xlsx"),
type: str = Query("inventory", description="Export type: inventory, audit, or combined"), type: str = Query("inventory", description="Export type: inventory, audit, or combined"),
@@ -217,7 +211,7 @@ async def export_db(
# Log export action # Log export action
audit_entry = AuditLog( audit_entry = AuditLog(
user_id=admin_user.id, user_id=admin_user.sub,
action="EXPORT_DB", action="EXPORT_DB",
details=f"Exported {type} in {format_type} format", details=f"Exported {type} in {format_type} format",
) )
@@ -226,14 +220,14 @@ async def export_db(
# Return file response # Return file response
if format_type == "csv": if format_type == "csv":
return FileResponse( return StreamingResponse(
io.BytesIO(content.encode("utf-8")), io.BytesIO(content.encode("utf-8")),
media_type=media_type, media_type=media_type,
filename=filename, headers={"Content-Disposition": f"attachment; filename={filename}"}
) )
else: else:
return FileResponse( return StreamingResponse(
io.BytesIO(content), io.BytesIO(content),
media_type=media_type, media_type=media_type,
filename=filename, headers={"Content-Disposition": f"attachment; filename={filename}"}
) )

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,41 +1,58 @@
# TFM aInventory - Caddy Patched IP Configuration # TFM aInventory - Standalone Caddy Configuration
# Version 1.9.17 - The Dynamic Shield (Production Polish) # Self-signed SSL/TLS reverse proxy for any IP/hostname
{ {
admin off admin off
local_certs local_certs
local_certs
skip_install_trust skip_install_trust
auto_https disable_redirects auto_https disable_redirects
# Configure on-demand TLS for private network IPs
on_demand_tls { on_demand_tls {
ask http://localhost:8916/ ask http://localhost:8916/
# This allows Caddy to generate internal certs for any IP/domain. }
ask http://backend:8000/
}
} }
# Dynamic HTTPS Frontend (port 8919) - Matches ANY IP or hostname # Dynamic HTTPS Frontend (port 8919) - Matches ANY IP or hostname
https:// { https://:8919 {
tls internal { tls internal {
on_demand on_demand
} }
reverse_proxy frontend:3000 # Next.js HMR Support (WebSocket)
handle /_next/webpack-hmr {
header { reverse_proxy http://localhost:8917 {
header_up Upgrade {>Upgrade}
header_up Connection {>Connection}
}
}
reverse_proxy http://localhost:8917 {
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto https
header_up X-Forwarded-Host {host}
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-XSS-Protection "1; mode=block" X-XSS-Protection "1; mode=block"
X-Frame-Options "SAMEORIGIN" X-Frame-Options "SAMEORIGIN"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin" Referrer-Policy "strict-origin-when-cross-origin"
} }
} }
# Dynamic HTTPS Backend (port 8918) - Matches ANY IP or hostname # Dynamic HTTPS Backend (port 8918) - Matches ANY IP or hostname
https://:444 { https://:8918 {
tls internal { tls internal {
on_demand on_demand
} }
} reverse_proxy http://localhost:8916 {
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto https
header_up X-Forwarded-Host {host}
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
}
}
} }

View File

@@ -41,9 +41,10 @@ A unified inventory management system that eliminates manual data entry through
### Phase 7: Config Consolidation (COMPLETED ✓) ### Phase 7: Config Consolidation (COMPLETED ✓)
- **Centralization**: All config in `config/` folder (YAML format). - **Centralization**: All config in `config/` folder (YAML format).
- **Automation**: Bash scripts converted to Python (`scripts/`). - **Automation**: Bash scripts converted to Python (`scripts/`).
- **Structure**: Clear separation of `backend.yaml`, `frontend.yaml`, `network.yaml`, `docker.yaml`, `secrets.yaml`. - **SSOT Architecture**: `network.yaml` established as Master IP/Port authority.
- **Dynamic Injection**: Automatic API URL and CORS calculation from master config.
- **D-06 Load Order**: Env Vars > YAML > Defaults. - **D-06 Load Order**: Env Vars > YAML > Defaults.
- **Deprecation**: `inventory.env` completely removed. - **Deprecation**: `inventory.env` and legacy `.sh` scripts completely removed.
### Phase 8: Hardening & Release (PLANNED) ### Phase 8: Hardening & Release (PLANNED)
- Stability monitoring, final UX refinements, production-ready runbook. - Stability monitoring, final UX refinements, production-ready runbook.

View File

@@ -1,54 +1,41 @@
# CURRENT AI WORKING SESSION — HANDOVER # CURRENT AI WORKING SESSION — HANDOVER
**Active AI:** Claude Haiku 4.5 (Claude Code) **Active AI:** Gemini CLI
**Last Updated:** 2026-04-23 **Last Updated:** 2026-04-23
**Current Version:** v1.14.7 **Current Version:** v1.14.19
**Status**: ✅ PHASE 7 COMPLETE **Status**: ✅ SSL PROXY FIXED | 🟢 READY FOR PHASE 8
--- ---
## SESSION 41 EXECUTION — Phase 7 Execution & Consolidation ## SESSION 42 EXECUTION SUMMARY — SSL Infrastructure Fix
### Work Completed This Session ### 1. SSL Proxy Fix (COMPLETE)
- **Port 80 Conflict**: Identified that Caddy was failing to start because it tried to bind to port 80 for HTTP->HTTPS redirects, while port 80 was already in use by a system-wide Caddy.
- **Caddy Configuration**: Modified `Caddyfile.standalone` (root and `config/`) to include `auto_https disable_redirects` in the global options. This allows Caddy to run on custom ports (8918/8919) without interfering with port 80.
- **Service Restart**: Successfully restarted backend, frontend, and proxy using `scripts/run_standalone.py restart`.
**1. Phase 7 Execution (Config Consolidation):** ### 2. Verification
- ✅ Wave 1: Created `config/` structure, 5 schema examples, 4 actual config files, and 155-line `config/README.md`. - **Backend SSL**: Verified reachable at `https://localhost:8918/` (Port 8916 upstream).
- ✅ Wave 2 (Backend): Refactored `backend/config_loader.py` for YAML and D-06 load order. Updated `config_manager.py`, `main.py`, and `entrypoint.sh`. - **Frontend SSL**: Verified reachable at `https://localhost:8919/` (Port 8917 upstream).
- ✅ Wave 2 (Scripts): Converted `deploy.sh`, `run_standalone.sh`, `install_service.sh`, and `export_prod.sh` to secure Python scripts with YAML parsing. - **Service Status**: `run_standalone.py status` confirms all components are UP.
- ✅ Wave 3 (Docker & Docs): Updated `docker-compose.yml` (volume mounts), `Dockerfile`, `.gitignore`, `DEPLOYMENT.md`, and `README.md`.
- ✅ Post-Wave: Refactored legacy `load_dotenv()` in `ai_vision.py`, `check_models.py`, `gemini.py`, and `claude.py`.
- ✅ Cleanup: Deleted all deprecated bash scripts and legacy `.env` files. Updated `scripts/save_version.py` for new infrastructure.
**2. Versioning & State Sync:**
- ✅ Incremented version to `v1.14.7` across all SSOT files (`VERSION.json`, `frontend/VERSION.json`, `PROJECT_ARCHITECTURE.md`, `dev_docs/PLAN.md`).
- ✅ Updated roadmap in `dev_docs/PLAN.md` marking Phase 7 as COMPLETED.
- ✅ Verified all files pass syntax checks (Python/Bash).
### Phase 7 Artifact Status
- **Plans**: `.planning/phases/07-config-consolidation/07-01-PLAN.md` ✓ COMPLETED
- **Plans**: `.planning/phases/07-config-consolidation/07-02-PLAN.md` ✓ COMPLETED
- **Plans**: `.planning/phases/07-config-consolidation/07-03-PLAN.md` ✓ COMPLETED
- **Plans**: `.planning/phases/07-config-consolidation/07-04-PLAN.md` ✓ COMPLETED
- **Summaries**: `.planning/phases/07-config-consolidation/07-01,02,03,04-SUMMARY.md` ✓ Created
- **Context**: `.planning/phases/07-config-consolidation/07-CONTEXT.md` ✓ Archived
--- ---
## NEXT STEPS ## NEXT STEPS (Phase 8: Hardening & Release)
1. **Start Phase 8 (Hardening & Release)**: 1. **End-to-End Testing**:
- Perform end-to-end testing of the new Python deployment scripts. - Verify data consistency in multi-page Excel exports.
- Verify Docker deployment with `python3 scripts/deploy.py production`. - Stress test the new Python-based deployment scripts.
- Perform final UX refinements and accessibility audits.
- Prepare production-ready runbook for v1.15.0 stable release.
2. **Stability Monitoring**: 2. **UX Refinement**:
- Check logs for any configuration parsing warnings. - Perform a final accessibility audit on the new Admin UI components.
- Ensure all environment variable overrides work as expected (D-06). - Ensure consistent loading states across all Admin panels.
3. **Cleanup**: 3. **Production Preparation**:
- Archive Phase 7 planning artifacts to `dev_docs/ARCHIVE_LOGS.md` or similar. - Finalize the production runbook.
- Prepare for the v1.15.0 "Stable" release milestone.
--- ---
Phase 7 fully implemented, verified, and cleaned up. Ready for the next phase. All tasks for this session are implemented, verified, and committed.
✓ Services are currently RUNNING in the background.

View File

@@ -1,6 +1,6 @@
{ {
"version": "1.14.11", "version": "1.14.21",
"last_build": "2026-04-23-1404", "last_build": "2026-04-23-1559",
"codename": "ConfigCore", "codename": "ConfigCore",
"commit": "e5a24df1" "commit": "6e88ea91"
} }

View File

@@ -1,136 +1,119 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { FileDown, Loader2 } from "lucide-react"; import { FileDown, Loader2, FileSpreadsheet, FileText } from "lucide-react";
import { useExport } from "@/hooks/useExport"; import { useExport } from "@/hooks/useExport";
import { Toast } from "@/components/Toast"; import { toast } from "react-hot-toast";
export function ExportPanel() { export function ExportPanel() {
const { exportSnapshot, exportAuditTrail, isLoading, error } = useExport(); const { exportSnapshot, exportAuditTrail, isLoading, error } = useExport();
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const handleExportSnapshot = async (format: "csv" | "xlsx") => { const handleExportSnapshot = async (format: "csv" | "xlsx") => {
try { try {
setSuccessMessage(null);
await exportSnapshot(format); await exportSnapshot(format);
const formatName = format === "csv" ? "CSV" : "Excel"; const formatName = format === "csv" ? "CSV" : "Excel";
setSuccessMessage(`Inventory snapshot exported as ${formatName}`); toast.success(`Inventory snapshot exported as ${formatName}`);
setTimeout(() => setSuccessMessage(null), 4000);
} catch (err) { } catch (err) {
// Error handled by useExport hook // Error handled by hook
} }
}; };
const handleExportAuditTrail = async (format: "csv" | "xlsx") => { const handleExportAuditTrail = async (format: "csv" | "xlsx") => {
try { try {
setSuccessMessage(null);
await exportAuditTrail(format); await exportAuditTrail(format);
const formatName = format === "csv" ? "CSV" : "Excel"; const formatName = format === "csv" ? "CSV" : "Excel";
setSuccessMessage(`Audit trail exported as ${formatName}`); toast.success(`Audit trail exported as ${formatName}`);
setTimeout(() => setSuccessMessage(null), 4000);
} catch (err) { } catch (err) {
// Error handled by useExport hook // Error handled by hook
} }
}; };
return ( return (
<div className="space-y-6"> <div data-testid="admin-tab-exports" className="bg-surface/50 border border-slate-800/50 rounded-[2.5rem] p-4 md:p-6 flex flex-col shadow-2xl transition-all h-full">
<div className="rounded-lg border border-slate-200 bg-white p-6"> <div className="flex items-center gap-3 mb-4 md:mb-6">
<div className="mb-6 flex items-center gap-3"> <div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-400 border border-indigo-500/20">
<div className="rounded-lg bg-primary/10 p-4 border border-primary/20"> <FileDown size={20} />
<FileDown className="text-primary" size={24} /> </div>
</div> <div>
<div> <h2 className="text-xl font-normal text-white tracking-tight">Export & Reports</h2>
<h2 className="text-2xl font-normal">Export & Reports</h2> <p className="text-[10px] md:text-xs text-muted font-normal tracking-tight">Download system snapshots and action logs</p>
<p className="text-xs text-slate-500"> </div>
Download inventory snapshots and audit trails in CSV or Excel formats </div>
<div className="grid md:grid-cols-2 gap-4">
{/* Inventory Snapshot Section */}
<div className="p-4 bg-background/40 border border-slate-800/40 rounded-2xl hover:border-primary/20 transition-all group flex flex-col justify-between">
<div className="mb-4">
<h3 className="text-sm font-normal text-secondary flex items-center gap-2">
<FileSpreadsheet size={14} className="text-primary" />
Inventory Snapshot
</h3>
<p className="text-xs text-muted mt-1 leading-relaxed">
Export current inventory state with all item details and locations.
</p> </p>
</div> </div>
</div>
<div className="grid grid-cols-2 gap-2">
{/* Inventory Snapshot Section */}
<div className="mb-8 space-y-3">
<h3 className="font-normal text-slate-700">Inventory Snapshot</h3>
<p className="text-sm text-slate-500">
Export current inventory state with all item details
</p>
<div className="flex flex-col gap-2 sm:flex-row">
<button <button
onClick={() => handleExportSnapshot("csv")} onClick={() => handleExportSnapshot("csv")}
disabled={isLoading} disabled={isLoading}
className="flex items-center justify-center gap-2 rounded-md bg-blue-500 px-4 py-2 text-sm font-normal text-white hover:bg-blue-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors" className="px-5 py-1.5 bg-primary hover:bg-primary/90 text-white rounded-xl text-sm font-normal transition-all active:scale-95 shadow-xl shadow-primary/10 tracking-tight border border-primary/30 flex items-center justify-center gap-2 disabled:opacity-50"
aria-label="Export inventory snapshot as CSV" aria-label="Export inventory snapshot as CSV"
> >
{isLoading ? ( {isLoading ? <Loader2 size={14} className="animate-spin" /> : <FileText size={14} />}
<Loader2 size={16} className="animate-spin" /> CSV
) : (
<FileDown size={16} />
)}
{isLoading ? "Exporting..." : "Export as CSV"}
</button> </button>
<button <button
onClick={() => handleExportSnapshot("xlsx")} onClick={() => handleExportSnapshot("xlsx")}
disabled={isLoading} disabled={isLoading}
className="flex items-center justify-center gap-2 rounded-md bg-green-500 px-4 py-2 text-sm font-normal text-white hover:bg-green-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors" className="px-5 py-1.5 bg-primary hover:bg-primary/90 text-white rounded-xl text-sm font-normal transition-all active:scale-95 shadow-xl shadow-primary/10 tracking-tight border border-primary/30 flex items-center justify-center gap-2 disabled:opacity-50"
aria-label="Export inventory snapshot as Excel" aria-label="Export inventory snapshot as Excel"
> >
{isLoading ? ( {isLoading ? <Loader2 size={14} className="animate-spin" /> : <FileSpreadsheet size={14} />}
<Loader2 size={16} className="animate-spin" /> Excel
) : (
<FileDown size={16} />
)}
{isLoading ? "Exporting..." : "Export as Excel"}
</button> </button>
</div> </div>
</div> </div>
{/* Audit Trail Section */} {/* Audit Trail Section */}
<div className="space-y-3"> <div className="p-4 bg-background/40 border border-slate-800/40 rounded-2xl hover:border-primary/20 transition-all group flex flex-col justify-between">
<h3 className="font-normal text-slate-700">Audit Trail</h3> <div className="mb-4">
<p className="text-sm text-slate-500"> <h3 className="text-sm font-normal text-secondary flex items-center gap-2">
Export complete audit log of all system actions and changes <FileText size={14} className="text-indigo-400" />
</p> Audit Trail
<div className="flex flex-col gap-2 sm:flex-row"> </h3>
<p className="text-xs text-muted mt-1 leading-relaxed">
Complete history of system actions, changes, and user operations.
</p>
</div>
<div className="grid grid-cols-2 gap-2">
<button <button
onClick={() => handleExportAuditTrail("csv")} onClick={() => handleExportAuditTrail("csv")}
disabled={isLoading} disabled={isLoading}
className="flex items-center justify-center gap-2 rounded-md bg-blue-500 px-4 py-2 text-sm font-normal text-white hover:bg-blue-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors" className="px-5 py-1.5 bg-primary hover:bg-primary/90 text-white rounded-xl text-sm font-normal transition-all active:scale-95 shadow-xl shadow-primary/10 tracking-tight border border-primary/30 flex items-center justify-center gap-2 disabled:opacity-50"
aria-label="Export audit trail as CSV" aria-label="Export audit trail as CSV"
> >
{isLoading ? ( {isLoading ? <Loader2 size={14} className="animate-spin" /> : <FileText size={14} />}
<Loader2 size={16} className="animate-spin" /> CSV
) : (
<FileDown size={16} />
)}
{isLoading ? "Exporting..." : "Export as CSV"}
</button> </button>
<button <button
onClick={() => handleExportAuditTrail("xlsx")} onClick={() => handleExportAuditTrail("xlsx")}
disabled={isLoading} disabled={isLoading}
className="flex items-center justify-center gap-2 rounded-md bg-green-500 px-4 py-2 text-sm font-normal text-white hover:bg-green-600 disabled:bg-slate-400 disabled:cursor-not-allowed transition-colors" className="px-5 py-1.5 bg-primary hover:bg-primary/90 text-white rounded-xl text-sm font-normal transition-all active:scale-95 shadow-xl shadow-primary/10 tracking-tight border border-primary/30 flex items-center justify-center gap-2 disabled:opacity-50"
aria-label="Export audit trail as Excel" aria-label="Export audit trail as Excel"
> >
{isLoading ? ( {isLoading ? <Loader2 size={14} className="animate-spin" /> : <FileSpreadsheet size={14} />}
<Loader2 size={16} className="animate-spin" /> Excel
) : (
<FileDown size={16} />
)}
{isLoading ? "Exporting..." : "Export as Excel"}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{/* Status Messages */}
{error && ( {error && (
<Toast <div className="mt-4 p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-500 text-[10px] flex items-center gap-2 animate-in slide-in-from-top-2 duration-300">
type="error" <div className="w-1.5 h-1.5 bg-rose-500 rounded-full animate-pulse" />
message={`Export failed: ${error}`} <span className="font-normal truncate">Error: {error}</span>
onClose={() => {}} </div>
/>
)}
{successMessage && (
<Toast type="success" message={successMessage} onClose={() => {}} />
)} )}
</div> </div>
); );

View File

@@ -50,7 +50,7 @@ export function useExport(): UseExportReturn {
try { try {
const response = await axiosInstance.get( const response = await axiosInstance.get(
`/admin/db/export?format=${format}&type=inventory`, `/admin/reports/export?format=${format}&type=inventory`,
{ {
responseType: "blob" responseType: "blob"
} }
@@ -83,7 +83,7 @@ export function useExport(): UseExportReturn {
try { try {
const response = await axiosInstance.get( const response = await axiosInstance.get(
`/admin/db/export?format=${format}&type=audit`, `/admin/reports/export?format=${format}&type=audit`,
{ {
responseType: "blob" responseType: "blob"
} }