From 4a8dcb877444ac210dc9396508532b0ce92ca6bc Mon Sep 17 00:00:00 2001 From: willzoo Date: Mon, 23 Feb 2026 13:32:32 -0500 Subject: [PATCH 01/73] WIP Admin Dashboard --- milventory/package-lock.json | 39 +++ milventory/package.json | 1 + milventory/src/App.js | 93 ++++++- milventory/src/api.js | 113 +++++++++ milventory/src/components/AddLocationModal.js | 226 ++++++++++++++++++ .../src/components/AdminActionsPanel.js | 22 ++ milventory/src/components/AdminDashboard.js | 50 ++++ milventory/src/components/AdminLeftPanel.js | 115 +++++++++ milventory/src/components/AdminMap.js | 100 ++++++++ milventory/src/components/CategoriesTable.js | 156 ++++++++++++ .../src/components/InventoryBoxesTable.js | 103 ++++++++ milventory/src/index.css | 85 +++++++ src/api/app.py | 7 + src/api/middleware/auth.py | 20 ++ src/api/routes/categories.py | 50 +++- src/api/routes/locations.py | 102 ++++++++ src/api/routes/migrate.py | 46 ++++ src/scripts/migrate_locations_schema.py | 116 +++++++++ src/scripts/seed_data.py | 52 +++- src/scripts/seed_locations.py | 18 +- src/sql/location/migrate_add_coordinates.sql | 13 + src/sql/location/table_locations.sql | 6 +- 22 files changed, 1508 insertions(+), 25 deletions(-) create mode 100644 milventory/src/components/AddLocationModal.js create mode 100644 milventory/src/components/AdminActionsPanel.js create mode 100644 milventory/src/components/AdminDashboard.js create mode 100644 milventory/src/components/AdminLeftPanel.js create mode 100644 milventory/src/components/AdminMap.js create mode 100644 milventory/src/components/CategoriesTable.js create mode 100644 milventory/src/components/InventoryBoxesTable.js create mode 100644 src/api/routes/migrate.py create mode 100644 src/scripts/migrate_locations_schema.py create mode 100644 src/sql/location/migrate_add_coordinates.sql diff --git a/milventory/package-lock.json b/milventory/package-lock.json index 362388e..7db8ff2 100644 --- a/milventory/package-lock.json +++ b/milventory/package-lock.json @@ -12,6 +12,7 @@ "http-proxy-middleware": "^2.0.6", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.8.0", "react-scripts": "5.0.1" } }, @@ -2863,6 +2864,14 @@ } } }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -13083,6 +13092,36 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-scripts": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", diff --git a/milventory/package.json b/milventory/package.json index a362cec..b50131f 100644 --- a/milventory/package.json +++ b/milventory/package.json @@ -5,6 +5,7 @@ "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.8.0", "react-scripts": "5.0.1", "d3": "^7.8.5", "http-proxy-middleware": "^2.0.6" diff --git a/milventory/src/App.js b/milventory/src/App.js index 725adb3..55c5340 100644 --- a/milventory/src/App.js +++ b/milventory/src/App.js @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom'; import { InventoryProvider, useInventory } from './context/InventoryContext'; import MapComponent from './components/Map'; import LeftPanel from './components/LeftPanel'; @@ -8,6 +9,7 @@ import EditModal from './components/EditForm'; import AddModePreview from './components/AddModePreview'; import Login from './components/Login'; import ErrorToast from './components/ErrorToast'; +import AdminDashboard from './components/AdminDashboard'; import { auth } from './api'; function App() { @@ -49,19 +51,81 @@ function App() { ); } + return ( + + + + ) : ( + + + + ) + } + /> + + + + + + } + /> + } /> + + + ); +} + +// Protected Route component +function ProtectedRoute({ children, requireLeader = false }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + auth.getCurrentUser() + .then((userData) => { + setUser(userData); + setLoading(false); + }) + .catch(() => { + setUser(null); + setLoading(false); + }); + }, []); + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + if (!user) { - return ; + return ; } - return ( - - - - ); + if (requireLeader && !user.is_leader) { + return ; + } + + return children; } function AppContent({ user, onLogout }) { const { wrapRef, svgRef, isLoading, error, setError } = useInventory(); + const navigate = useNavigate(); // Handle 401 errors by logging out useEffect(() => { @@ -96,6 +160,23 @@ function AppContent({ user, onLogout }) { {user && ( Logged in as {user.first_name} {user.last_name} ({user.email}) + {user.is_leader && ( + + )} + + + {error && ( +
+ {error} +
+ )} + +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ + + ); +}; + +export default AddLocationModal; + diff --git a/milventory/src/components/AdminActionsPanel.js b/milventory/src/components/AdminActionsPanel.js new file mode 100644 index 0000000..eee39c6 --- /dev/null +++ b/milventory/src/components/AdminActionsPanel.js @@ -0,0 +1,22 @@ +import React from 'react'; + +const AdminActionsPanel = ({ onAddLocation }) => { + return ( +
+
+

Actions

+
+
+ +
+
+ ); +}; + +export default AdminActionsPanel; + diff --git a/milventory/src/components/AdminDashboard.js b/milventory/src/components/AdminDashboard.js new file mode 100644 index 0000000..4cb293e --- /dev/null +++ b/milventory/src/components/AdminDashboard.js @@ -0,0 +1,50 @@ +import React, { useRef, useState } from 'react'; +import { useInventory } from '../context/InventoryContext'; +import AdminMap from './AdminMap'; +import AdminLeftPanel from './AdminLeftPanel'; +import AdminActionsPanel from './AdminActionsPanel'; +import AddLocationModal from './AddLocationModal'; + +const AdminDashboard = () => { + const { wrapRef } = useInventory(); + const svgRef = useRef(null); + const [showAddLocationModal, setShowAddLocationModal] = useState(false); + const [refreshTrigger, setRefreshTrigger] = useState(0); + + const handleAddLocation = () => { + setShowAddLocationModal(true); + }; + + const handleLocationAdded = () => { + // Trigger refresh of components + setRefreshTrigger(prev => prev + 1); + // Reload page after a short delay to ensure backend has synced JSON + // The JSON file is synced by sync_locations_json() when location is created + setTimeout(() => { + window.location.reload(); + }, 300); + }; + + return ( + <> + +
+
+ Admin Dashboard +
+ + + setShowAddLocationModal(false)} + onSuccess={handleLocationAdded} + /> +
+ + ); +}; + +export default AdminDashboard; + diff --git a/milventory/src/components/AdminLeftPanel.js b/milventory/src/components/AdminLeftPanel.js new file mode 100644 index 0000000..aa73652 --- /dev/null +++ b/milventory/src/components/AdminLeftPanel.js @@ -0,0 +1,115 @@ +import React, { useState } from 'react'; +import { useInventory } from '../context/InventoryContext'; +import InventoryBoxesTable from './InventoryBoxesTable'; +import CategoriesTable from './CategoriesTable'; + +const AdminLeftPanel = () => { + const { leftPaneWidth, setLeftPaneWidth, leftPaneCollapsed, setLeftPaneCollapsed } = useInventory(); + const [isResizing, setIsResizing] = useState(false); + const [activeTab, setActiveTab] = useState('boxes'); // 'boxes' or 'categories' + const leftPaneRef = React.useRef(null); + const resizeRef = React.useRef(null); + + const handleResizeStart = (e) => { + setIsResizing(true); + e.preventDefault(); + }; + + React.useEffect(() => { + const handleMouseMove = (e) => { + if (isResizing) { + const newWidth = Math.max(200, Math.min(600, e.clientX)); + setLeftPaneWidth(newWidth); + } + }; + + const handleMouseUp = () => { + setIsResizing(false); + }; + + if (isResizing) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + } + }, [isResizing, setLeftPaneWidth]); + + const handleToggleCollapse = () => { + setLeftPaneCollapsed(!leftPaneCollapsed); + }; + + const buttonLeft = leftPaneCollapsed ? 40 : leftPaneWidth; + + if (leftPaneCollapsed) { + return ( + <> +
+ + + ); + } + + return ( + <> +
+
+
+
+

Inventory Boxes

+
+ + {/* Subtabs */} +
+ + +
+ + {/* Tab content */} +
+ {activeTab === 'boxes' && } + {activeTab === 'categories' && } +
+
+
+ + + ); +}; + +export default AdminLeftPanel; + diff --git a/milventory/src/components/AdminMap.js b/milventory/src/components/AdminMap.js new file mode 100644 index 0000000..f19eceb --- /dev/null +++ b/milventory/src/components/AdminMap.js @@ -0,0 +1,100 @@ +import React, { forwardRef, useEffect, useRef } from 'react'; +import * as d3 from 'd3'; +import { useInventory } from '../context/InventoryContext'; + +const AdminMap = forwardRef((props, ref) => { + const { inventoryData, inventoryBounds } = useInventory(); + const worldRef = useRef(null); + const svgRef = useRef(null); + const isPanningRef = useRef(false); + + // Expose svgRef to parent via forwarded ref + useEffect(() => { + if (ref) { + if (typeof ref === 'function') { + ref(svgRef.current); + } else { + ref.current = svgRef.current; + } + } + }, [ref]); + + // Setup D3 zoom/pan + useEffect(() => { + if (!svgRef.current || !worldRef.current) return; + + const zoom = d3.zoom() + .scaleExtent([0.6, 6]) + .on('start', () => { + isPanningRef.current = true; + }) + .on('zoom', (e) => { + if (worldRef.current) { + worldRef.current.setAttribute('transform', e.transform); + } + }) + .on('end', () => { + isPanningRef.current = false; + }); + + const svg = d3.select(svgRef.current); + svg.call(zoom).on('dblclick.zoom', null); + svg.call(zoom.transform, d3.zoomIdentity.scale(1.03)); + }, []); + + const boxes = Array.from(inventoryData.values()); + + const viewBox = inventoryBounds?.viewBox + ? `${inventoryBounds.viewBox.x} ${inventoryBounds.viewBox.y} ${inventoryBounds.viewBox.width} ${inventoryBounds.viewBox.height}` + : "0 0 2000 2200"; + + const roomBounds = inventoryBounds?.room || { + x: 80, + y: 80, + width: 1840, + height: 2000, + rx: 18, + ry: 18 + }; + + return ( + + + + + {boxes.map((box, idx) => ( + + ))} + + + ); +}); + +AdminMap.displayName = 'AdminMap'; + +export default AdminMap; + diff --git a/milventory/src/components/CategoriesTable.js b/milventory/src/components/CategoriesTable.js new file mode 100644 index 0000000..94a77e6 --- /dev/null +++ b/milventory/src/components/CategoriesTable.js @@ -0,0 +1,156 @@ +import React, { useState, useEffect } from 'react'; +import { getCategories, admin } from '../api'; + +const CategoriesTable = () => { + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showAddForm, setShowAddForm] = useState(false); + const [categoryName, setCategoryName] = useState(''); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + loadCategories(); + }, []); + + const loadCategories = async () => { + try { + setLoading(true); + setError(null); + const data = await getCategories(); + setCategories(data); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!categoryName.trim()) { + setError('Category name cannot be empty'); + return; + } + + try { + setSubmitting(true); + setError(null); + await admin.createCategory(categoryName.trim()); + setCategoryName(''); + setShowAddForm(false); + await loadCategories(); + } catch (err) { + setError(err.message); + } finally { + setSubmitting(false); + } + }; + + if (loading) { + return
Loading categories...
; + } + + return ( + <> + {error && ( +
+ {error} +
+ )} + + {showAddForm && ( +
+
+ setCategoryName(e.target.value)} + className="master-search-input" + disabled={submitting} + style={{ marginBottom: '0.5rem' }} + /> +
+ + +
+
+
+ )} + + {categories.length === 0 ? ( +
+ {showAddForm ? 'Enter a category name above' : 'No categories. Click "+ Add Category" to create one.'} +
+ ) : ( + + + + + + + + {categories.map(category => ( + + + + ))} + +
Name
{category.name}
+ )} + + {!showAddForm && ( +
+ +
+ )} + + ); +}; + +export default CategoriesTable; + diff --git a/milventory/src/components/InventoryBoxesTable.js b/milventory/src/components/InventoryBoxesTable.js new file mode 100644 index 0000000..2fce028 --- /dev/null +++ b/milventory/src/components/InventoryBoxesTable.js @@ -0,0 +1,103 @@ +import React, { useState, useEffect } from 'react'; +import { admin } from '../api'; + +const InventoryBoxesTable = () => { + const [locations, setLocations] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + + useEffect(() => { + loadLocations(); + }, []); + + const loadLocations = async () => { + try { + setLoading(true); + setError(null); + const data = await admin.getLocations(); + setLocations(data); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const filteredLocations = locations.filter(loc => + !searchQuery.trim() || + loc.name.toLowerCase().includes(searchQuery.toLowerCase()) || + loc.type.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const sortedLocations = [...filteredLocations].sort((a, b) => + a.name.localeCompare(b.name) + ); + + if (loading) { + return
Loading inventory boxes...
; + } + + return ( + <> + {error && ( +
+ {error} +
+ )} + +
+ setSearchQuery(e.target.value)} + className="master-search-input" + /> +
+ + {sortedLocations.length === 0 ? ( +
+ {searchQuery ? 'No boxes found' : 'No inventory boxes found.'} +
+ ) : ( + + + + + + + + + + + {sortedLocations.map(location => ( + + + + + + + ))} + +
NameTypePositionSize
{location.name} + {location.type.replace('_', ' ')} + + ({location.x}, {location.y}) + + {location.width}×{location.height} +
+ )} + + ); +}; + +export default InventoryBoxesTable; + diff --git a/milventory/src/index.css b/milventory/src/index.css index 497c0ac..6ffe72e 100644 --- a/milventory/src/index.css +++ b/milventory/src/index.css @@ -827,6 +827,37 @@ svg.overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-e padding: 2rem 1rem; } +/* Admin Subtabs */ +.admin-subtabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + border-bottom: 1px solid rgba(255,255,255,.1); + padding-bottom: 0.5rem; + flex-shrink: 0; +} + +.admin-subtab { + background: none; + border: none; + color: var(--muted); + padding: 0.5rem 1rem; + cursor: pointer; + font-size: 0.85rem; + border-bottom: 2px solid transparent; + transition: all 0.2s; + margin-bottom: -0.5rem; +} + +.admin-subtab:hover { + color: var(--text); +} + +.admin-subtab.active { + color: var(--text); + border-bottom-color: var(--accent); +} + .master-table-actions { flex-shrink: 0; margin-top: 0.5rem; @@ -1309,3 +1340,57 @@ svg.overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-e opacity: 0.9; } +/* Admin Actions Panel */ +.admin-actions-panel { + position: absolute; + bottom: 20px; + left: 20px; + background: var(--panel); + border: 1px solid rgba(255,255,255,.15); + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0,0,0,.4); + padding: 1rem; + min-width: 200px; + z-index: 100; +} + +.admin-actions-header { + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid rgba(255,255,255,.1); +} + +.admin-actions-header h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--text); +} + +.admin-actions-content { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.admin-action-button { + background: var(--accent); + color: var(--panel); + border: none; + padding: 0.75rem 1rem; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + font-size: 0.9rem; + transition: opacity 0.2s; + width: 100%; +} + +.admin-action-button:hover { + opacity: 0.9; +} + +.admin-action-button:active { + opacity: 0.8; +} + diff --git a/src/api/app.py b/src/api/app.py index 3f792ac..673ed85 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -115,6 +115,13 @@ def initialize_schema(): # Initialize schema on startup initialize_schema() +# Run migrations for existing tables +try: + from src.scripts.migrate_locations_schema import migrate_locations_schema + migrate_locations_schema() +except Exception as e: + print(f"⚠ Warning: Could not run migrations: {e}") + # Seed test user, teams, categories, and locations try: from src.scripts.seed_data import seed_test_user, seed_teams, seed_categories, seed_locations diff --git a/src/api/middleware/auth.py b/src/api/middleware/auth.py index 89a7284..063fb9d 100644 --- a/src/api/middleware/auth.py +++ b/src/api/middleware/auth.py @@ -22,3 +22,23 @@ def decorated_function(*args, **kwargs): return decorated_function + +def require_leader(f): + """ + Decorator to require leader/admin status for a route. + + Returns 401 if user is not authenticated, 403 if not a leader. + """ + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user_id' not in session: + return jsonify({'error': 'Authentication required'}), 401 + + if not session.get('is_leader', False): + return jsonify({'error': 'Leader access required'}), 403 + + # Add current user info to kwargs for convenience + kwargs['current_user_id'] = session.get('user_id') + return f(*args, **kwargs) + + return decorated_function diff --git a/src/api/routes/categories.py b/src/api/routes/categories.py index 80ec9f1..e7fe3c2 100644 --- a/src/api/routes/categories.py +++ b/src/api/routes/categories.py @@ -1,8 +1,9 @@ """Categories API routes.""" -from flask import Blueprint, jsonify +from flask import Blueprint, request, jsonify import mysql.connector import os from src.scripts.helpers import parse_database_url +from src.api.middleware.auth import require_leader categories_bp = Blueprint('categories', __name__) @@ -58,3 +59,50 @@ def get_category(category_id): except Exception as e: return jsonify({'error': f'Unexpected error: {str(e)}'}), 500 + +@categories_bp.route('/categories', methods=['POST']) +@require_leader +def create_category(): + """Create a new category. Requires leader/admin access.""" + try: + data = request.json + if not data or 'name' not in data: + return jsonify({'error': 'Category name is required'}), 400 + + name = data['name'].strip() + if not name: + return jsonify({'error': 'Category name cannot be empty'}), 400 + + conn = get_db_connection() + cur = conn.cursor() + + try: + cur.execute( + "INSERT INTO categories (name) VALUES (%s)", + (name,) + ) + conn.commit() + + # Fetch created category + cur.execute("SELECT id, name, created_at FROM categories WHERE id = LAST_INSERT_ID()") + row = cur.fetchone() + + from src.api.models.category import Category + category = Category.from_db_row(row) + + cur.close() + conn.close() + + return jsonify(category.to_dict()), 201 + except mysql.connector.IntegrityError as e: + conn.rollback() + cur.close() + conn.close() + if 'Duplicate entry' in str(e) or 'UNIQUE constraint' in str(e): + return jsonify({'error': 'Category with this name already exists'}), 409 + return jsonify({'error': f'Database error: {str(e)}'}), 400 + except mysql.connector.Error as e: + return jsonify({'error': f'Database error: {str(e)}'}), 500 + except Exception as e: + return jsonify({'error': f'Unexpected error: {str(e)}'}), 500 + diff --git a/src/api/routes/locations.py b/src/api/routes/locations.py index aba9424..de3571c 100644 --- a/src/api/routes/locations.py +++ b/src/api/routes/locations.py @@ -2,6 +2,7 @@ Location API routes. """ import sys +import json from pathlib import Path # Add src to path for imports (must be before other imports) @@ -11,10 +12,103 @@ import mysql.connector from src.api.db import get_db from src.api.models.location import Location +from src.api.middleware.auth import require_leader locations_bp = Blueprint('locations', __name__) +def get_fill_for_type(location_type): + """Get CSS fill color variable for location type.""" + type_fills = { + 'drawer': 'var(--drawer)', + 'cabinet': 'var(--table)', + 'tall_cabinet': 'var(--table)', + 'table': 'var(--table)', + 'workbench': 'var(--table)', + } + return type_fills.get(location_type, 'var(--table)') + + +def sync_locations_json(): + """ + Sync inventory-locations.json with database. + Updates the JSON file to match current database state. + """ + try: + # Get project root (go up from src/api/routes to project root) + script_dir = Path(__file__).parent.parent.parent.parent + json_path = script_dir / "milventory" / "public" / "inventory-locations.json" + + if not json_path.exists(): + # Try alternative path + json_path = script_dir / "src" / "seed_data" / "inventory-locations.json" + if not json_path.exists(): + print(f"⚠ Warning: inventory-locations.json not found at {json_path}") + return False + + # Fetch all locations from DB + conn = get_db() + cur = conn.cursor() + cur.execute("SELECT name, x, y, width, height, type FROM locations ORDER BY name") + db_locations = {} + for row in cur.fetchall(): + db_locations[row[0]] = { + 'name': row[0], + 'x': row[1], + 'y': row[2], + 'width': row[3], + 'height': row[4], + 'type': row[5] + } + cur.close() + conn.close() + + # Load existing JSON to preserve inventory-bounds + try: + with open(json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + # Create default structure if file doesn't exist or is invalid + data = { + "inventory-bounds": { + "viewBox": {"x": 0, "y": 0, "width": 4000, "height": 4000}, + "room": {"x": 80, "y": 80, "width": 3600, "height": 3840, "rx": 18, "ry": 18} + }, + "boxes": [] + } + + # Convert DB locations to JSON boxes format + boxes = [] + for name, loc_data in db_locations.items(): + boxes.append({ + 'title': loc_data['name'], + 'x': loc_data['x'], + 'y': loc_data['y'], + 'width': loc_data['width'], + 'height': loc_data['height'], + 'fill': get_fill_for_type(loc_data['type']) + }) + + data['boxes'] = boxes + + # Write back to JSON + with open(json_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2) + + # Also update the alternative path if it exists + alt_path = script_dir / "milventory" / "public" / "inventory-locations.json" + if alt_path.exists() and alt_path != json_path: + with open(alt_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2) + + return True + except Exception as e: + print(f"⚠ Warning: Failed to sync locations JSON: {e}") + import traceback + traceback.print_exc() + return False + + @locations_bp.route('', methods=['GET']) def get_locations(): """ @@ -67,6 +161,7 @@ def get_location(name): @locations_bp.route('', methods=['POST']) +@require_leader def create_location(): """ POST /api/locations @@ -108,6 +203,9 @@ def create_location(): cur.close() conn.close() + # Sync JSON file + sync_locations_json() + return jsonify(location.to_dict()), 201 except mysql.connector.IntegrityError as e: if 'Duplicate entry' in str(e): @@ -187,6 +285,7 @@ def update_location(name): @locations_bp.route('/', methods=['DELETE']) +@require_leader def delete_location(name): """ DELETE /api/locations/ @@ -214,6 +313,9 @@ def delete_location(name): cur.close() conn.close() + # Sync JSON file + sync_locations_json() + return '', 204 except Exception as e: return jsonify({'error': str(e)}), 500 diff --git a/src/api/routes/migrate.py b/src/api/routes/migrate.py new file mode 100644 index 0000000..2ef06cd --- /dev/null +++ b/src/api/routes/migrate.py @@ -0,0 +1,46 @@ +""" +Migration API route to run database migrations. +This can be called to update the database schema. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from flask import Blueprint, jsonify +from src.api.middleware.auth import require_leader +from src.scripts.migrate_locations_schema import migrate_locations_schema + +migrate_bp = Blueprint('migrate', __name__) + + +@migrate_bp.route('/migrate/locations', methods=['POST']) +@require_leader +def migrate_locations(): + """ + POST /api/migrate/locations + Run migration to add coordinate columns to locations table. + Requires leader/admin access. + """ + try: + # Capture print output + import io + import contextlib + + f = io.StringIO() + with contextlib.redirect_stdout(f), contextlib.redirect_stderr(f): + migrate_locations_schema() + + output = f.getvalue() + + return jsonify({ + 'success': True, + 'message': 'Migration completed', + 'output': output + }), 200 + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + diff --git a/src/scripts/migrate_locations_schema.py b/src/scripts/migrate_locations_schema.py new file mode 100644 index 0000000..b847717 --- /dev/null +++ b/src/scripts/migrate_locations_schema.py @@ -0,0 +1,116 @@ +""" +Migration script to add x, y, width, height columns to locations table. +Run this once to update existing database schema. +""" +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +import mysql.connector +from helpers import parse_database_url, get_sql_base_path, execute_sql_file + + +def check_column_exists(cur, table_name, column_name): + """Check if a column exists in a table.""" + cur.execute(""" + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = %s + AND COLUMN_NAME = %s + """, (table_name, column_name)) + return cur.fetchone()[0] > 0 + + +def migrate_locations_schema(): + """Add coordinate columns to locations table if they don't exist.""" + try: + database_url = os.getenv("DATABASE_URL", "mysql://mysqluser:mysqlpassword@db:3306/mydb") + db_params = parse_database_url(database_url) + + print("🔄 Migrating locations table schema...") + + conn = mysql.connector.connect(**db_params) + cur = conn.cursor() + + # Check if table exists + cur.execute(""" + SELECT COUNT(*) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'locations' + """) + if cur.fetchone()[0] == 0: + print("⚠ Locations table does not exist. Creating it...") + sql_base_path = get_sql_base_path(__file__) + locations_file = sql_base_path / "location" / "table_locations.sql" + if locations_file.exists(): + execute_sql_file(cur, locations_file, "locations table") + conn.commit() + print("✓ Locations table created with new schema") + cur.close() + conn.close() + return + else: + print(f"✗ Locations table SQL file not found at {locations_file}") + cur.close() + conn.close() + return + + # Check which columns need to be added + columns_to_add = [] + if not check_column_exists(cur, 'locations', 'x'): + columns_to_add.append(('x', 'INT NOT NULL DEFAULT 0', 'type')) + if not check_column_exists(cur, 'locations', 'y'): + columns_to_add.append(('y', 'INT NOT NULL DEFAULT 0', 'x')) + if not check_column_exists(cur, 'locations', 'width'): + columns_to_add.append(('width', 'INT NOT NULL DEFAULT 150', 'y')) + if not check_column_exists(cur, 'locations', 'height'): + columns_to_add.append(('height', 'INT NOT NULL DEFAULT 150', 'width')) + + if not columns_to_add: + print("✓ All columns already exist, migration not needed") + cur.close() + conn.close() + return + + # Add missing columns + print(f"📋 Adding {len(columns_to_add)} column(s)...") + for col_name, col_def, after_col in columns_to_add: + try: + # Check if column exists first (in case it was added between checks) + if check_column_exists(cur, 'locations', col_name): + print(f" ⊘ Column {col_name} already exists, skipping") + continue + + query = f"ALTER TABLE locations ADD COLUMN {col_name} {col_def} AFTER {after_col}" + cur.execute(query) + print(f" ✓ Added column: {col_name}") + except mysql.connector.Error as e: + # If error is about duplicate column, that's okay + if 'Duplicate column name' in str(e) or '1060' in str(e): + print(f" ⊘ Column {col_name} already exists, skipping") + else: + print(f" ✗ Failed to add column {col_name}: {e}") + + conn.commit() + print("✓ Migration complete") + + cur.close() + conn.close() + + except mysql.connector.Error as e: + print(f"✗ Database error: {e}") + sys.exit(1) + except Exception as e: + print(f"✗ Unexpected error: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + migrate_locations_schema() + diff --git a/src/scripts/seed_data.py b/src/scripts/seed_data.py index 06116e6..a722713 100644 --- a/src/scripts/seed_data.py +++ b/src/scripts/seed_data.py @@ -263,21 +263,51 @@ def seed_locations(): location_type = derive_location_type(name) shelf_count = 6 if location_type == 'tall_cabinet' else 0 + # Get coordinates from JSON box + x = box.get('x', 0) + y = box.get('y', 0) + width = box.get('width', 150) + height = box.get('height', 150) + if name in existing_names: - # Update existing location - cur.execute( - "UPDATE locations SET type = %s, shelf_count = %s WHERE name = %s", - (location_type, shelf_count, name) - ) - if cur.rowcount > 0: - update_count += 1 - else: - # Insert new location + # Update existing location (update all fields including coordinates) + # Check if columns exist first try: cur.execute( - "INSERT INTO locations (name, type, shelf_count) VALUES (%s, %s, %s)", - (name, location_type, shelf_count) + "UPDATE locations SET type = %s, shelf_count = %s, x = %s, y = %s, width = %s, height = %s WHERE name = %s", + (location_type, shelf_count, x, y, width, height, name) ) + if cur.rowcount > 0: + update_count += 1 + except mysql.connector.Error as e: + # If columns don't exist, try without them + if 'Unknown column' in str(e): + cur.execute( + "UPDATE locations SET type = %s, shelf_count = %s WHERE name = %s", + (location_type, shelf_count, name) + ) + if cur.rowcount > 0: + update_count += 1 + else: + raise + else: + # Insert new location with coordinates + try: + # Try with coordinates first + try: + cur.execute( + "INSERT INTO locations (name, type, shelf_count, x, y, width, height) VALUES (%s, %s, %s, %s, %s, %s, %s)", + (name, location_type, shelf_count, x, y, width, height) + ) + except mysql.connector.Error as e: + # If columns don't exist, insert without them + if 'Unknown column' in str(e): + cur.execute( + "INSERT INTO locations (name, type, shelf_count) VALUES (%s, %s, %s)", + (name, location_type, shelf_count) + ) + else: + raise insert_count += 1 except mysql.connector.IntegrityError: # Skip if already exists (race condition) diff --git a/src/scripts/seed_locations.py b/src/scripts/seed_locations.py index 7b2054e..977cfc2 100644 --- a/src/scripts/seed_locations.py +++ b/src/scripts/seed_locations.py @@ -176,20 +176,26 @@ def seed_locations(): location_type = derive_location_type(name) shelf_count = 6 if location_type == 'tall_cabinet' else 0 + # Get coordinates from JSON box + x = box.get('x', 0) + y = box.get('y', 0) + width = box.get('width', 150) + height = box.get('height', 150) + if name in existing_names: - # Update existing location (preserve if exists, but update type/shelf_count if changed) + # Update existing location (update all fields including coordinates) cur.execute( - "UPDATE locations SET type = %s, shelf_count = %s WHERE name = %s", - (location_type, shelf_count, name) + "UPDATE locations SET type = %s, shelf_count = %s, x = %s, y = %s, width = %s, height = %s WHERE name = %s", + (location_type, shelf_count, x, y, width, height, name) ) if cur.rowcount > 0: update_count += 1 else: - # Insert new location + # Insert new location with coordinates try: cur.execute( - "INSERT INTO locations (name, type, shelf_count) VALUES (%s, %s, %s)", - (name, location_type, shelf_count) + "INSERT INTO locations (name, type, shelf_count, x, y, width, height) VALUES (%s, %s, %s, %s, %s, %s, %s)", + (name, location_type, shelf_count, x, y, width, height) ) insert_count += 1 except mysql.connector.IntegrityError: diff --git a/src/sql/location/migrate_add_coordinates.sql b/src/sql/location/migrate_add_coordinates.sql new file mode 100644 index 0000000..bc9b1ab --- /dev/null +++ b/src/sql/location/migrate_add_coordinates.sql @@ -0,0 +1,13 @@ +-- Migration: Add x, y, width, height columns to locations table +-- This migration adds coordinate columns to existing locations table + +-- Check if columns exist before adding (idempotent) +-- Note: MySQL doesn't support IF NOT EXISTS for ALTER TABLE ADD COLUMN directly +-- So we'll use a stored procedure approach or just run ALTER TABLE + +ALTER TABLE locations +ADD COLUMN IF NOT EXISTS x INT NOT NULL DEFAULT 0 AFTER type, +ADD COLUMN IF NOT EXISTS y INT NOT NULL DEFAULT 0 AFTER x, +ADD COLUMN IF NOT EXISTS width INT NOT NULL DEFAULT 150 AFTER y, +ADD COLUMN IF NOT EXISTS height INT NOT NULL DEFAULT 150 AFTER width; + diff --git a/src/sql/location/table_locations.sql b/src/sql/location/table_locations.sql index fe8c1d8..b46e69f 100644 --- a/src/sql/location/table_locations.sql +++ b/src/sql/location/table_locations.sql @@ -1,7 +1,11 @@ --- Location metadata (layout coordinates are in inventory-locations.json, not stored in DB) +-- Location metadata and layout coordinates (all stored in DB, synced to JSON for frontend) CREATE TABLE locations ( name VARCHAR(100) PRIMARY KEY, -- e.g. "Drawer A", "Tall Cabinet 103" type VARCHAR(50) NOT NULL, -- "drawer", "cabinet", "table", "workbench", "tall_cabinet" + x INT NOT NULL DEFAULT 0, -- X coordinate for layout + y INT NOT NULL DEFAULT 0, -- Y coordinate for layout + width INT NOT NULL DEFAULT 150, -- Width in pixels + height INT NOT NULL DEFAULT 150, -- Height in pixels shelf_count INT NOT NULL DEFAULT 0, -- 6 for Tall Cabinets, 0 for everything else created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); From 931b353eabe650cb6abc0df5f2608efd367a1ee2 Mon Sep 17 00:00:00 2001 From: willzoo Date: Mon, 23 Feb 2026 14:20:18 -0500 Subject: [PATCH 02/73] WIP: Drawable boxes --- milventory/src/components/AddLocationModal.js | 225 +++++++++--------- .../src/components/AdminActionsPanel.js | 24 +- milventory/src/components/AdminDashboard.js | 34 ++- milventory/src/components/AdminMap.js | 161 ++++++++++++- src/api/app.py | 10 + src/api/routes/locations.py | 19 +- 6 files changed, 334 insertions(+), 139 deletions(-) diff --git a/milventory/src/components/AddLocationModal.js b/milventory/src/components/AddLocationModal.js index db472d4..be5baae 100644 --- a/milventory/src/components/AddLocationModal.js +++ b/milventory/src/components/AddLocationModal.js @@ -9,7 +9,7 @@ const LOCATION_TYPES = [ { value: 'workbench', label: 'Workbench' } ]; -const AddLocationModal = ({ isOpen, onClose, onSuccess }) => { +const AddLocationModal = ({ isOpen, onClose, onSuccess, initialBox }) => { const [formData, setFormData] = useState({ name: '', type: 'drawer', @@ -21,7 +21,18 @@ const AddLocationModal = ({ isOpen, onClose, onSuccess }) => { const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); - if (!isOpen) return null; + // Update form data when initialBox changes (from drawn box) + React.useEffect(() => { + if (initialBox && isOpen) { + setFormData(prev => ({ + ...prev, + x: initialBox.x, + y: initialBox.y, + width: initialBox.width, + height: initialBox.height + })); + } + }, [initialBox, isOpen]); const handleChange = (e) => { const { name, value } = e.target; @@ -87,13 +98,12 @@ const AddLocationModal = ({ isOpen, onClose, onSuccess }) => { onClose(); }; + if (!isOpen) return null; + return ( -
-
e.stopPropagation()}> -
-

Add New Location

- -
+
+
e.stopPropagation()}> +

Add New Location

{error && (
{
)} -
-
- - -
- -
- - -
- -
-
- - -
- -
- - -
-
- -
-
- - -
- -
- - -
-
- -
- - -
-
+ + +
+ + +
+
+ + +
+
+ + +
); diff --git a/milventory/src/components/AdminActionsPanel.js b/milventory/src/components/AdminActionsPanel.js index eee39c6..9a7b9d4 100644 --- a/milventory/src/components/AdminActionsPanel.js +++ b/milventory/src/components/AdminActionsPanel.js @@ -1,18 +1,28 @@ import React from 'react'; -const AdminActionsPanel = ({ onAddLocation }) => { +const AdminActionsPanel = ({ onAddLocation, drawMode, onCancelDraw }) => { return (

Actions

- + {drawMode ? ( + + ) : ( + + )}
); diff --git a/milventory/src/components/AdminDashboard.js b/milventory/src/components/AdminDashboard.js index 4cb293e..c8997db 100644 --- a/milventory/src/components/AdminDashboard.js +++ b/milventory/src/components/AdminDashboard.js @@ -8,13 +8,27 @@ import AddLocationModal from './AddLocationModal'; const AdminDashboard = () => { const { wrapRef } = useInventory(); const svgRef = useRef(null); + const [drawMode, setDrawMode] = useState(false); const [showAddLocationModal, setShowAddLocationModal] = useState(false); + const [drawnBox, setDrawnBox] = useState(null); const [refreshTrigger, setRefreshTrigger] = useState(0); const handleAddLocation = () => { + setDrawMode(true); + }; + + const handleDrawComplete = (box) => { + setDrawnBox(box); + setDrawMode(false); setShowAddLocationModal(true); }; + const handleModalClose = () => { + setShowAddLocationModal(false); + setDrawnBox(null); + setDrawMode(false); + }; + const handleLocationAdded = () => { // Trigger refresh of components setRefreshTrigger(prev => prev + 1); @@ -31,15 +45,31 @@ const AdminDashboard = () => {
Admin Dashboard + {drawMode && ( + + Draw Mode: Drag on map to create a box + + )}
- + setDrawMode(false)} /> setShowAddLocationModal(false)} + onClose={handleModalClose} onSuccess={handleLocationAdded} + initialBox={drawnBox} />
diff --git a/milventory/src/components/AdminMap.js b/milventory/src/components/AdminMap.js index f19eceb..0809f1a 100644 --- a/milventory/src/components/AdminMap.js +++ b/milventory/src/components/AdminMap.js @@ -1,12 +1,18 @@ -import React, { forwardRef, useEffect, useRef } from 'react'; +import React, { forwardRef, useEffect, useRef, useState } from 'react'; import * as d3 from 'd3'; import { useInventory } from '../context/InventoryContext'; const AdminMap = forwardRef((props, ref) => { + const { drawMode, onDrawComplete } = props; const { inventoryData, inventoryBounds } = useInventory(); const worldRef = useRef(null); const svgRef = useRef(null); const isPanningRef = useRef(false); + const [drawingBox, setDrawingBox] = useState(null); + const [isDrawing, setIsDrawing] = useState(false); + const drawStartRef = useRef(null); + const currentDrawingBoxRef = useRef(null); + const currentTransformRef = useRef(d3.zoomIdentity); // Expose svgRef to parent via forwarded ref useEffect(() => { @@ -19,18 +25,22 @@ const AdminMap = forwardRef((props, ref) => { } }, [ref]); - // Setup D3 zoom/pan + // Setup D3 zoom/pan (disabled when in draw mode) useEffect(() => { if (!svgRef.current || !worldRef.current) return; const zoom = d3.zoom() .scaleExtent([0.6, 6]) + .filter(() => !drawMode) // Disable zoom/pan when in draw mode .on('start', () => { - isPanningRef.current = true; + if (!drawMode) { + isPanningRef.current = true; + } }) .on('zoom', (e) => { - if (worldRef.current) { + if (worldRef.current && !drawMode) { worldRef.current.setAttribute('transform', e.transform); + currentTransformRef.current = e.transform; } }) .on('end', () => { @@ -39,10 +49,111 @@ const AdminMap = forwardRef((props, ref) => { const svg = d3.select(svgRef.current); svg.call(zoom).on('dblclick.zoom', null); - svg.call(zoom.transform, d3.zoomIdentity.scale(1.03)); - }, []); + if (!drawMode) { + svg.call(zoom.transform, d3.zoomIdentity.scale(1.03)); + } + }, [drawMode]); + + // Convert screen coordinates to SVG coordinates (accounting for zoom/pan) + const screenToSVG = (screenX, screenY) => { + if (!svgRef.current || !worldRef.current) return { x: 0, y: 0 }; + const svg = svgRef.current; + const pt = svg.createSVGPoint(); + pt.x = screenX; + pt.y = screenY; + + // Get the transform matrix from the world group (includes zoom/pan) + const worldMatrix = worldRef.current.getScreenCTM(); + if (!worldMatrix) return { x: 0, y: 0 }; + + // Convert to SVG coordinates by inverting the world transform + const svgPoint = pt.matrixTransform(worldMatrix.inverse()); + + return { + x: svgPoint.x, + y: svgPoint.y + }; + }; + + // Handle mouse down for drawing + const handleMouseDown = (e) => { + if (!drawMode || isPanningRef.current) return; + + // Prevent default to avoid conflicts with zoom + e.preventDefault(); + e.stopPropagation(); + + const svgCoords = screenToSVG(e.clientX, e.clientY); + + drawStartRef.current = svgCoords; + const initialBox = { + x: svgCoords.x, + y: svgCoords.y, + width: 0, + height: 0 + }; + currentDrawingBoxRef.current = initialBox; + setIsDrawing(true); + setDrawingBox(initialBox); + }; - const boxes = Array.from(inventoryData.values()); + // Handle mouse move for drawing + const handleMouseMove = (e) => { + if (!drawMode || !isDrawing || !drawStartRef.current) return; + + e.preventDefault(); + e.stopPropagation(); + + const svgCoords = screenToSVG(e.clientX, e.clientY); + const start = drawStartRef.current; + + const updatedBox = { + x: Math.min(start.x, svgCoords.x), + y: Math.min(start.y, svgCoords.y), + width: Math.abs(svgCoords.x - start.x), + height: Math.abs(svgCoords.y - start.y) + }; + currentDrawingBoxRef.current = updatedBox; + setDrawingBox(updatedBox); + }; + + // Handle mouse up to complete drawing + const handleMouseUp = (e) => { + if (!drawMode || !isDrawing) return; + + // Prevent default to avoid conflicts with zoom + e.preventDefault(); + e.stopPropagation(); + + // Get current drawing box from ref (synchronous access) + const currentBox = currentDrawingBoxRef.current; + if (!currentBox || !drawStartRef.current) { + setIsDrawing(false); + setDrawingBox(null); + currentDrawingBoxRef.current = null; + drawStartRef.current = null; + return; + } + + // Only complete if box has minimum size + if (currentBox.width > 10 && currentBox.height > 10) { + if (onDrawComplete) { + onDrawComplete({ + x: Math.round(currentBox.x), + y: Math.round(currentBox.y), + width: Math.round(currentBox.width), + height: Math.round(currentBox.height) + }); + } + } + + setIsDrawing(false); + setDrawingBox(null); + currentDrawingBoxRef.current = null; + drawStartRef.current = null; + }; + + const boxes = inventoryData ? Array.from(inventoryData.values()) : []; const viewBox = inventoryBounds?.viewBox ? `${inventoryBounds.viewBox.x} ${inventoryBounds.viewBox.y} ${inventoryBounds.viewBox.width} ${inventoryBounds.viewBox.height}` @@ -63,7 +174,23 @@ const AdminMap = forwardRef((props, ref) => { className="map" viewBox={viewBox} aria-label="Admin room map" - style={{ touchAction: 'none', userSelect: 'none' }} + style={{ + touchAction: 'none', + userSelect: 'none', + cursor: drawMode ? 'crosshair' : 'default' + }} + onMouseDown={drawMode ? handleMouseDown : undefined} + onMouseMove={drawMode ? handleMouseMove : undefined} + onMouseUp={drawMode ? handleMouseUp : undefined} + onMouseLeave={drawMode ? () => { + // Cancel drawing if mouse leaves + if (isDrawing) { + setIsDrawing(false); + setDrawingBox(null); + currentDrawingBoxRef.current = null; + drawStartRef.current = null; + } + } : undefined} > { height={box.height} fill={box.fill} data-title={box.title} - style={{ cursor: 'default' }} + style={{ cursor: 'default', pointerEvents: drawMode ? 'none' : 'auto' }} /> ))} + + {/* Drawing preview box */} + {drawingBox && ( + + )} ); diff --git a/src/api/app.py b/src/api/app.py index 673ed85..2e2fc11 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -139,6 +139,16 @@ def health_check(): return {'status': 'healthy'}, 200 +# Error handler to ensure CORS headers are always sent +@app.errorhandler(500) +def handle_500_error(e): + """Handle 500 errors and ensure CORS headers are sent.""" + from flask import jsonify + response = jsonify({'error': str(e) if hasattr(e, 'description') and e.description else 'Internal server error'}) + response.status_code = 500 + return response + + if __name__ == '__main__': port = int(os.getenv('PORT', 5000)) app.run(host='0.0.0.0', port=port, debug=True) diff --git a/src/api/routes/locations.py b/src/api/routes/locations.py index de3571c..4be49e0 100644 --- a/src/api/routes/locations.py +++ b/src/api/routes/locations.py @@ -193,18 +193,26 @@ def create_location(): location = Location.from_dict(data) + # Determine shelf_count based on type + shelf_count = 6 if location.type == 'tall_cabinet' else 0 + conn = get_db() cur = conn.cursor() cur.execute( - "INSERT INTO locations (name, x, y, width, height, type) VALUES (%s, %s, %s, %s, %s, %s)", - (location.name, location.x, location.y, location.width, location.height, location.type) + "INSERT INTO locations (name, x, y, width, height, type, shelf_count) VALUES (%s, %s, %s, %s, %s, %s, %s)", + (location.name, location.x, location.y, location.width, location.height, location.type, shelf_count) ) conn.commit() cur.close() conn.close() - # Sync JSON file - sync_locations_json() + # Sync JSON file (don't fail if this errors, just log it) + try: + sync_locations_json() + except Exception as sync_error: + print(f"⚠ Warning: Failed to sync locations JSON after create: {sync_error}") + import traceback + traceback.print_exc() return jsonify(location.to_dict()), 201 except mysql.connector.IntegrityError as e: @@ -212,6 +220,9 @@ def create_location(): return jsonify({'error': 'Location with this name already exists'}), 409 return jsonify({'error': str(e)}), 400 except Exception as e: + print(f"Error creating location: {e}") + import traceback + traceback.print_exc() return jsonify({'error': str(e)}), 500 From 1493ed6d4690b9ee96fda8d759e1ed804f137533 Mon Sep 17 00:00:00 2001 From: willzoo Date: Sat, 28 Feb 2026 14:35:31 -0500 Subject: [PATCH 03/73] Fix locations not putting --- src/api/routes/locations.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/routes/locations.py b/src/api/routes/locations.py index 4be49e0..4e97ebf 100644 --- a/src/api/routes/locations.py +++ b/src/api/routes/locations.py @@ -162,7 +162,7 @@ def get_location(name): @locations_bp.route('', methods=['POST']) @require_leader -def create_location(): +def create_location(current_user_id=None): """ POST /api/locations Create a new location. @@ -297,7 +297,7 @@ def update_location(name): @locations_bp.route('/', methods=['DELETE']) @require_leader -def delete_location(name): +def delete_location(name, current_user_id=None): """ DELETE /api/locations/ Delete a location. From 5a65d337c32b3ef7ce3d4cd2782ee83ba642be35 Mon Sep 17 00:00:00 2001 From: willzoo Date: Sat, 28 Feb 2026 14:50:19 -0500 Subject: [PATCH 04/73] WIP: Protected locations --- milventory/src/components/AdminDashboard.js | 32 +++- milventory/src/components/AdminLeftPanel.js | 9 +- milventory/src/components/AdminMap.js | 75 +++++++-- .../src/components/InventoryBoxesTable.js | 42 +++-- milventory/src/components/LocationPreview.js | 155 ++++++++++++++++++ src/api/routes/locations.py | 15 +- 6 files changed, 286 insertions(+), 42 deletions(-) create mode 100644 milventory/src/components/LocationPreview.js diff --git a/milventory/src/components/AdminDashboard.js b/milventory/src/components/AdminDashboard.js index c8997db..376df01 100644 --- a/milventory/src/components/AdminDashboard.js +++ b/milventory/src/components/AdminDashboard.js @@ -4,14 +4,16 @@ import AdminMap from './AdminMap'; import AdminLeftPanel from './AdminLeftPanel'; import AdminActionsPanel from './AdminActionsPanel'; import AddLocationModal from './AddLocationModal'; +import LocationPreview from './LocationPreview'; const AdminDashboard = () => { - const { wrapRef } = useInventory(); + const { wrapRef, leftPaneWidth, leftPaneCollapsed } = useInventory(); const svgRef = useRef(null); const [drawMode, setDrawMode] = useState(false); const [showAddLocationModal, setShowAddLocationModal] = useState(false); const [drawnBox, setDrawnBox] = useState(null); const [refreshTrigger, setRefreshTrigger] = useState(0); + const [selectedLocation, setSelectedLocation] = useState(null); const handleAddLocation = () => { setDrawMode(true); @@ -39,9 +41,26 @@ const AdminDashboard = () => { }, 300); }; + const handleLocationSelect = (location) => { + setSelectedLocation(location); + }; + + const handleLocationDeselect = () => { + setSelectedLocation(null); + }; + + const handleLocationDeleted = () => { + setSelectedLocation(null); + setRefreshTrigger(prev => prev + 1); + }; + return ( <> - +
Admin Dashboard @@ -59,6 +78,8 @@ const AdminDashboard = () => { ref={svgRef} drawMode={drawMode} onDrawComplete={handleDrawComplete} + selectedLocation={selectedLocation} + onLocationSelect={handleLocationSelect} /> { onSuccess={handleLocationAdded} initialBox={drawnBox} /> +
); diff --git a/milventory/src/components/AdminLeftPanel.js b/milventory/src/components/AdminLeftPanel.js index aa73652..400c828 100644 --- a/milventory/src/components/AdminLeftPanel.js +++ b/milventory/src/components/AdminLeftPanel.js @@ -3,7 +3,7 @@ import { useInventory } from '../context/InventoryContext'; import InventoryBoxesTable from './InventoryBoxesTable'; import CategoriesTable from './CategoriesTable'; -const AdminLeftPanel = () => { +const AdminLeftPanel = ({ selectedLocation, onLocationSelect }) => { const { leftPaneWidth, setLeftPaneWidth, leftPaneCollapsed, setLeftPaneCollapsed } = useInventory(); const [isResizing, setIsResizing] = useState(false); const [activeTab, setActiveTab] = useState('boxes'); // 'boxes' or 'categories' @@ -92,7 +92,12 @@ const AdminLeftPanel = () => { {/* Tab content */}
- {activeTab === 'boxes' && } + {activeTab === 'boxes' && ( + + )} {activeTab === 'categories' && }
diff --git a/milventory/src/components/AdminMap.js b/milventory/src/components/AdminMap.js index 0809f1a..e40fa5c 100644 --- a/milventory/src/components/AdminMap.js +++ b/milventory/src/components/AdminMap.js @@ -1,9 +1,10 @@ import React, { forwardRef, useEffect, useRef, useState } from 'react'; import * as d3 from 'd3'; import { useInventory } from '../context/InventoryContext'; +import { admin } from '../api'; const AdminMap = forwardRef((props, ref) => { - const { drawMode, onDrawComplete } = props; + const { drawMode, onDrawComplete, selectedLocation, onLocationSelect } = props; const { inventoryData, inventoryBounds } = useInventory(); const worldRef = useRef(null); const svgRef = useRef(null); @@ -203,19 +204,65 @@ const AdminMap = forwardRef((props, ref) => { ry={roomBounds.ry} /> - {boxes.map((box, idx) => ( - - ))} + {boxes.map((box, idx) => { + const isSelected = selectedLocation && selectedLocation.name === box.title; + return ( + { + if (!drawMode && onLocationSelect) { + e.stopPropagation(); + try { + // Fetch the full location data from the API + const location = await admin.getLocations().then(locations => + locations.find(loc => loc.name === box.title) + ); + if (location) { + onLocationSelect(location); + } else { + // Fallback: construct from box data if API doesn't have it + const fallbackLocation = { + name: box.title, + x: box.x, + y: box.y, + width: box.width, + height: box.height, + type: 'drawer' + }; + onLocationSelect(fallbackLocation); + } + } catch (err) { + console.error('Error fetching location:', err); + // Fallback: construct from box data + const fallbackLocation = { + name: box.title, + x: box.x, + y: box.y, + width: box.width, + height: box.height, + type: 'drawer' + }; + onLocationSelect(fallbackLocation); + } + } + }} + /> + ); + })} {/* Drawing preview box */} {drawingBox && ( diff --git a/milventory/src/components/InventoryBoxesTable.js b/milventory/src/components/InventoryBoxesTable.js index 2fce028..a481787 100644 --- a/milventory/src/components/InventoryBoxesTable.js +++ b/milventory/src/components/InventoryBoxesTable.js @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { admin } from '../api'; -const InventoryBoxesTable = () => { +const InventoryBoxesTable = ({ selectedLocation, onLocationSelect }) => { const [locations, setLocations] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -78,20 +78,32 @@ const InventoryBoxesTable = () => { - {sortedLocations.map(location => ( - - {location.name} - - {location.type.replace('_', ' ')} - - - ({location.x}, {location.y}) - - - {location.width}×{location.height} - - - ))} + {sortedLocations.map(location => { + const isSelected = selectedLocation && selectedLocation.name === location.name; + return ( + { + if (onLocationSelect) { + onLocationSelect(location); + } + }} + style={{ cursor: 'pointer' }} + > + {location.name} + + {location.type.replace('_', ' ')} + + + ({location.x}, {location.y}) + + + {location.width}×{location.height} + + + ); + })} )} diff --git a/milventory/src/components/LocationPreview.js b/milventory/src/components/LocationPreview.js new file mode 100644 index 0000000..fe3ab2c --- /dev/null +++ b/milventory/src/components/LocationPreview.js @@ -0,0 +1,155 @@ +import React, { useRef, useState, useEffect } from 'react'; +import { admin } from '../api'; + +const LocationPreview = ({ location, onClose, onDelete, leftPaneWidth, leftPaneCollapsed }) => { + const previewRef = useRef(null); + const [deleting, setDeleting] = useState(false); + const [protectedLocations, setProtectedLocations] = useState(new Set()); + + // Load protected locations from JSON file + useEffect(() => { + const loadProtectedLocations = async () => { + try { + const response = await fetch('/inventory-locations.json'); + if (response.ok) { + const data = await response.json(); + const protectedNames = new Set( + (data.boxes || []).map(box => box.title) + ); + setProtectedLocations(protectedNames); + } + } catch (err) { + console.error('Error loading protected locations:', err); + } + }; + loadProtectedLocations(); + }, []); + + // Calculate position to the right of left pane + const leftPaneActualWidth = leftPaneCollapsed ? 40 : leftPaneWidth; + const positionX = leftPaneActualWidth + 20; + const positionY = 20; + + const isProtected = location && protectedLocations.has(location.name); + + const handleDelete = async () => { + if (!location) return; + + // Check if location is protected + if (isProtected) { + alert( + `This location is protected because it's in the inventory-locations.json file. ` + + `It is a permanent inventory location. To delete it, edit the code/JSON file directly.` + ); + return; + } + + const confirmed = window.confirm( + `Are you sure you want to delete "${location.name}"? This action cannot be undone.` + ); + + if (!confirmed) return; + + try { + setDeleting(true); + await admin.deleteLocation(location.name); + if (onDelete) { + onDelete(location.name); + } + if (onClose) { + onClose(); + } + // Reload page to refresh the map + setTimeout(() => { + window.location.reload(); + }, 300); + } catch (err) { + alert(`Failed to delete location: ${err.message}`); + setDeleting(false); + } + }; + + if (!location) return null; + + const typeDisplay = location.type.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()); + + return ( +
+
+
+

{location.name}

+ +
+ +
+ Type: +

{typeDisplay}

+
+ +
+ Position: +
+ X: {location.x}, Y: {location.y} +
+
+ +
+ Size: +
+ Width: {location.width}, Height: {location.height} +
+
+ + {isProtected && ( +
+ ⚠ Protected Location +

+ This location is in inventory-locations.json and is permanent. Edit the JSON file to remove it. +

+
+ )} + +
+ Actions: +
+ +
+
+
+
+ ); +}; + +export default LocationPreview; + diff --git a/src/api/routes/locations.py b/src/api/routes/locations.py index 4e97ebf..e961c13 100644 --- a/src/api/routes/locations.py +++ b/src/api/routes/locations.py @@ -206,13 +206,9 @@ def create_location(current_user_id=None): cur.close() conn.close() - # Sync JSON file (don't fail if this errors, just log it) - try: - sync_locations_json() - except Exception as sync_error: - print(f"⚠ Warning: Failed to sync locations JSON after create: {sync_error}") - import traceback - traceback.print_exc() + # Note: We do NOT sync to JSON file anymore. + # The inventory-locations.json file is now the source of truth for permanent locations. + # New locations created via the API are stored only in the database. return jsonify(location.to_dict()), 201 except mysql.connector.IntegrityError as e: @@ -324,8 +320,9 @@ def delete_location(name, current_user_id=None): cur.close() conn.close() - # Sync JSON file - sync_locations_json() + # Note: We do NOT sync to JSON file anymore. + # The inventory-locations.json file is now the source of truth for permanent locations. + # Deletions only affect the database, not the JSON file. return '', 204 except Exception as e: From a22255a5c242c6f74003ba3f8956bfdf307da69c Mon Sep 17 00:00:00 2001 From: willzoo Date: Sat, 28 Feb 2026 15:06:32 -0500 Subject: [PATCH 05/73] Make inventory context get data solely from api --- milventory/src/context/InventoryContext.js | 77 ++++++++++++++++------ 1 file changed, 58 insertions(+), 19 deletions(-) diff --git a/milventory/src/context/InventoryContext.js b/milventory/src/context/InventoryContext.js index 426bdb1..7f5f919 100644 --- a/milventory/src/context/InventoryContext.js +++ b/milventory/src/context/InventoryContext.js @@ -1,6 +1,6 @@ import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react'; import * as d3 from 'd3'; -import { api } from '../api'; +import { api, admin } from '../api'; const InventoryContext = createContext(null); @@ -67,37 +67,71 @@ export const InventoryProvider = ({ children }) => { const worldRef = useRef(null); const isPanningRef = useRef(false); - // Initialize inventory data from JSON (layout only) and API (inventory data) + // Helper function to get fill color for location type + const getFillForType = (type) => { + const typeFills = { + 'drawer': 'var(--drawer)', + 'cabinet': 'var(--table)', + 'tall_cabinet': 'var(--files)', // Tall cabinets use files color + 'table': 'var(--table)', + 'workbench': '#e7ebf3', // Workbench has special color + }; + return typeFills[type] || 'var(--table)'; + }; + + // Initialize inventory data from database (locations) and API (inventory data) useEffect(() => { const loadInventoryData = async () => { setIsLoading(true); setError(null); try { - // 1. Load layout from JSON (no inventory arrays) - const response = await fetch('/inventory-locations.json'); - if (!response.ok) { - throw new Error('Failed to load inventory layout'); + // 1. Load inventory bounds from JSON (for viewBox and room bounds) + try { + const response = await fetch('/inventory-locations.json'); + if (response.ok) { + const data = await response.json(); + if (data['inventory-bounds']) { + setInventoryBounds(data['inventory-bounds']); + } + } + } catch (boundsError) { + console.warn('Could not load inventory bounds from JSON, using defaults:', boundsError); + // Use default bounds if JSON fails + setInventoryBounds({ + viewBox: { x: 0, y: 0, width: 4000, height: 4000 }, + room: { x: 80, y: 80, width: 3600, height: 3840, rx: 18, ry: 18 } + }); } - const data = await response.json(); - // Store inventory bounds - if (data['inventory-bounds']) { - setInventoryBounds(data['inventory-bounds']); - } + // 2. Load locations from database API + const locations = await admin.getLocations(); - // Initialize inventoryData with layout only (empty inventory arrays) + // Convert API locations to box format expected by map const newInventoryData = new Map(); - data.boxes.forEach(box => { - newInventoryData.set(box.title, { - ...box, - inventory: [] // Will be populated from API - }); + locations.forEach(location => { + const boxData = { + title: location.name, + x: location.x, + y: location.y, + width: location.width, + height: location.height, + fill: getFillForType(location.type), + type: location.type, + inventory: [] // Will be populated from supply locations API + }; + + // Add isWorkbench property if it's a workbench + if (location.type === 'workbench') { + boxData.isWorkbench = true; + } + + newInventoryData.set(location.name, boxData); }); setInventoryData(newInventoryData); - // 2. Load supply locations from API and merge into inventoryData + // 3. Load supply locations from API and merge into inventoryData try { const supplyLocations = await api.getAllSupplyLocations(); @@ -139,8 +173,13 @@ export const InventoryProvider = ({ children }) => { console.error('Error loading inventory data:', error); setError(error.message || 'Failed to load inventory data'); setIsLoading(false); - // Fallback to empty data if JSON fails to load + // Fallback to empty data if API fails setInventoryData(new Map()); + // Set default bounds + setInventoryBounds({ + viewBox: { x: 0, y: 0, width: 4000, height: 4000 }, + room: { x: 80, y: 80, width: 3600, height: 3840, rx: 18, ry: 18 } + }); } }; From 3c366a6d27cee4e6158b3772a044e7829e49b01e Mon Sep 17 00:00:00 2001 From: willzoo Date: Sat, 28 Feb 2026 15:15:54 -0500 Subject: [PATCH 06/73] Make protected a database property --- milventory/public/inventory-locations.json | 74 -------------------- milventory/src/components/LocationPreview.js | 30 ++------ milventory/src/context/InventoryContext.js | 22 ++---- src/api/models/location.py | 12 ++-- src/api/routes/locations.py | 33 ++++----- src/scripts/seed_data.py | 60 +++++++++++----- src/scripts/seed_locations.py | 18 ++--- src/sql/location/table_locations.sql | 3 +- 8 files changed, 87 insertions(+), 165 deletions(-) delete mode 100644 milventory/public/inventory-locations.json diff --git a/milventory/public/inventory-locations.json b/milventory/public/inventory-locations.json deleted file mode 100644 index 76c9996..0000000 --- a/milventory/public/inventory-locations.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "inventory-bounds": { - "viewBox": { - "x": 0, - "y": 0, - "width": 4000, - "height": 4000 - }, - "room": { - "x": 80, - "y": 80, - "width": 3600, - "height": 3840, - "rx": 18, - "ry": 18 - } - }, - "boxes": [ - { "title": "Drawer A" , "x": 720 , "y": 80 , "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer B" , "x": 875 , "y": 80 , "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer C" , "x": 1030, "y": 80 , "width": 300, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer D" , "x": 1335, "y": 80 , "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer E" , "x": 1490, "y": 80 , "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer F" , "x": 1645, "y": 80 , "width": 300, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer G" , "x": 1950, "y": 80 , "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer H" , "x": 2105, "y": 80 , "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer I" , "x": 2260, "y": 80 , "width": 300, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer J" , "x": 2565, "y": 80 , "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer K" , "x": 2720, "y": 80 , "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Cabinet 1" , "x": 720 , "y": 250 , "width": 305, "height": 155, "fill": "var(--table)" }, - { "title": "Cabinet 2" , "x": 1335, "y": 250 , "width": 305, "height": 155, "fill": "var(--table)" }, - { "title": "Cabinet 3" , "x": 1950, "y": 250 , "width": 305, "height": 155, "fill": "var(--table)" }, - { "title": "Cabinet 4" , "x": 2565, "y": 250 , "width": 305, "height": 155, "fill": "var(--table)" }, - { "title": "Drawer L" , "x": 3500, "y": 840 , "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer M" , "x": 3500, "y": 995 , "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer N" , "x": 3500, "y": 1150, "width": 150, "height": 305, "fill": "var(--drawer)" }, - { "title": "Drawer O" , "x": 3500, "y": 1460, "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer P" , "x": 3500, "y": 1615, "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer R" , "x": 3500, "y": 1770, "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer S" , "x": 3500, "y": 1925, "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer T" , "x": 3500, "y": 2080, "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer U" , "x": 3500, "y": 2235, "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer V" , "x": 3500, "y": 2390, "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer W" , "x": 3500, "y": 2545, "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer X" , "x": 3500, "y": 2700, "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer Y" , "x": 3500, "y": 2855, "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer Z" , "x": 3500, "y": 3010, "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer AA" , "x": 3500, "y": 3165, "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Cabinet 5" , "x": 3325, "y": 840 , "width": 155, "height": 305, "fill": "var(--table)" }, - { "title": "Cabinet 6" , "x": 3325, "y": 1150, "width": 155, "height": 305, "fill": "var(--table)" }, - { "title": "Cabinet 7" , "x": 3325, "y": 1460, "width": 155, "height": 305, "fill": "var(--table)" }, - { "title": "Cabinet 8" , "x": 3325, "y": 1770, "width": 155, "height": 305, "fill": "var(--table)" }, - { "title": "Cabinet 9" , "x": 3325, "y": 2080, "width": 155, "height": 305, "fill": "var(--table)" }, - { "title": "Cabinet 10" , "x": 3325, "y": 2390, "width": 155, "height": 305, "fill": "var(--table)" }, - { "title": "Cabinet 11" , "x": 3325, "y": 2700, "width": 155, "height": 305, "fill": "var(--table)" }, - { "title": "Cabinet 12" , "x": 3325, "y": 3010, "width": 155, "height": 305, "fill": "var(--table)" }, - { "title": "Workbench" , "x": 140 , "y": 1680, "width": 350, "height": 520, "fill": "#e7ebf3" , "isWorkbench": true }, - { "title": "Tall Cabinet 103", "x": 140 , "y": 2260, "width": 240, "height": 300, "fill": "var(--files)" }, - { "title": "Tall Cabinet 102", "x": 140 , "y": 2565, "width": 240, "height": 300, "fill": "var(--files)" }, - { "title": "Tall Cabinet 101", "x": 140 , "y": 2870, "width": 240, "height": 300, "fill": "var(--files)" }, - { "title": "Tall Cabinet 100", "x": 140 , "y": 3175, "width": 240, "height": 300, "fill": "var(--files)" }, - { "title": "Tall Cabinet 104", "x": 3410, "y": 520 , "width": 240, "height": 300, "fill": "var(--files)" }, - { "title": "Table A" , "x": 800 , "y": 1080, "width": 720, "height": 300, "fill": "var(--table)" }, - { "title": "Table B" , "x": 800 , "y": 1385, "width": 720, "height": 300, "fill": "var(--table)" }, - { "title": "Table C" , "x": 2100, "y": 1080, "width": 720, "height": 300, "fill": "var(--table)" }, - { "title": "Table D" , "x": 2100, "y": 1385, "width": 720, "height": 300, "fill": "var(--table)" }, - { "title": "Table E" , "x": 800 , "y": 2160, "width": 720, "height": 300, "fill": "var(--table)" }, - { "title": "Table F" , "x": 800 , "y": 2465, "width": 720, "height": 300, "fill": "var(--table)" }, - { "title": "Table G" , "x": 2100, "y": 2160, "width": 720, "height": 300, "fill": "var(--table)" }, - { "title": "Table H" , "x": 2100, "y": 2465, "width": 720, "height": 300, "fill": "var(--table)" }, - { "title": "Table I" , "x": 1400, "y": 3600, "width": 720, "height": 300, "fill": "var(--table)" }, - { "title": "Table J" , "x": 2170, "y": 3600, "width": 720, "height": 300, "fill": "var(--table)" } - ] -} diff --git a/milventory/src/components/LocationPreview.js b/milventory/src/components/LocationPreview.js index fe3ab2c..85e9626 100644 --- a/milventory/src/components/LocationPreview.js +++ b/milventory/src/components/LocationPreview.js @@ -1,36 +1,16 @@ -import React, { useRef, useState, useEffect } from 'react'; +import React, { useRef, useState } from 'react'; import { admin } from '../api'; const LocationPreview = ({ location, onClose, onDelete, leftPaneWidth, leftPaneCollapsed }) => { const previewRef = useRef(null); const [deleting, setDeleting] = useState(false); - const [protectedLocations, setProtectedLocations] = useState(new Set()); - - // Load protected locations from JSON file - useEffect(() => { - const loadProtectedLocations = async () => { - try { - const response = await fetch('/inventory-locations.json'); - if (response.ok) { - const data = await response.json(); - const protectedNames = new Set( - (data.boxes || []).map(box => box.title) - ); - setProtectedLocations(protectedNames); - } - } catch (err) { - console.error('Error loading protected locations:', err); - } - }; - loadProtectedLocations(); - }, []); // Calculate position to the right of left pane const leftPaneActualWidth = leftPaneCollapsed ? 40 : leftPaneWidth; const positionX = leftPaneActualWidth + 20; const positionY = 20; - const isProtected = location && protectedLocations.has(location.name); + const isProtected = location?.protected || false; const handleDelete = async () => { if (!location) return; @@ -38,8 +18,8 @@ const LocationPreview = ({ location, onClose, onDelete, leftPaneWidth, leftPaneC // Check if location is protected if (isProtected) { alert( - `This location is protected because it's in the inventory-locations.json file. ` + - `It is a permanent inventory location. To delete it, edit the code/JSON file directly.` + `This location is protected and is a permanent inventory location. ` + + `To delete it, you must edit the database directly to set protected = FALSE.` ); return; } @@ -127,7 +107,7 @@ const LocationPreview = ({ location, onClose, onDelete, leftPaneWidth, leftPaneC }}> ⚠ Protected Location

- This location is in inventory-locations.json and is permanent. Edit the JSON file to remove it. + This location is protected and cannot be deleted. Edit the database to change protection status.

)} diff --git a/milventory/src/context/InventoryContext.js b/milventory/src/context/InventoryContext.js index 7f5f919..e517f3f 100644 --- a/milventory/src/context/InventoryContext.js +++ b/milventory/src/context/InventoryContext.js @@ -86,23 +86,11 @@ export const InventoryProvider = ({ children }) => { setError(null); try { - // 1. Load inventory bounds from JSON (for viewBox and room bounds) - try { - const response = await fetch('/inventory-locations.json'); - if (response.ok) { - const data = await response.json(); - if (data['inventory-bounds']) { - setInventoryBounds(data['inventory-bounds']); - } - } - } catch (boundsError) { - console.warn('Could not load inventory bounds from JSON, using defaults:', boundsError); - // Use default bounds if JSON fails - setInventoryBounds({ - viewBox: { x: 0, y: 0, width: 4000, height: 4000 }, - room: { x: 80, y: 80, width: 3600, height: 3840, rx: 18, ry: 18 } - }); - } + // 1. Set default inventory bounds (no longer loading from JSON) + setInventoryBounds({ + viewBox: { x: 0, y: 0, width: 4000, height: 4000 }, + room: { x: 80, y: 80, width: 3600, height: 3840, rx: 18, ry: 18 } + }); // 2. Load locations from database API const locations = await admin.getLocations(); diff --git a/src/api/models/location.py b/src/api/models/location.py index bb37a6f..6dc6204 100644 --- a/src/api/models/location.py +++ b/src/api/models/location.py @@ -14,6 +14,7 @@ class Location: width: int height: int type: str + protected: bool = False @classmethod def from_db_row(cls, row: tuple) -> 'Location': @@ -21,7 +22,7 @@ def from_db_row(cls, row: tuple) -> 'Location': Create Location from database row. Args: - row: Tuple from database query (name, x, y, width, height, type) + row: Tuple from database query (name, x, y, width, height, type, protected) Returns: Location instance @@ -32,7 +33,8 @@ def from_db_row(cls, row: tuple) -> 'Location': y=row[2], width=row[3], height=row[4], - type=row[5] + type=row[5], + protected=bool(row[6]) if len(row) > 6 else False ) def to_dict(self) -> Dict[str, Any]: @@ -48,7 +50,8 @@ def to_dict(self) -> Dict[str, Any]: 'y': self.y, 'width': self.width, 'height': self.height, - 'type': self.type + 'type': self.type, + 'protected': self.protected } @classmethod @@ -68,6 +71,7 @@ def from_dict(cls, data: Dict[str, Any]) -> 'Location': y=int(data['y']), width=int(data['width']), height=int(data['height']), - type=data['type'] + type=data['type'], + protected=bool(data.get('protected', False)) ) diff --git a/src/api/routes/locations.py b/src/api/routes/locations.py index e961c13..6f3b55d 100644 --- a/src/api/routes/locations.py +++ b/src/api/routes/locations.py @@ -33,15 +33,17 @@ def sync_locations_json(): """ Sync inventory-locations.json with database. Updates the JSON file to match current database state. + NOTE: This function is deprecated and no longer called. JSON file is now seed data only. """ try: # Get project root (go up from src/api/routes to project root) script_dir = Path(__file__).parent.parent.parent.parent - json_path = script_dir / "milventory" / "public" / "inventory-locations.json" + # JSON file is now in seed_data directory + json_path = script_dir / "src" / "seed_data" / "inventory-locations.json" if not json_path.exists(): - # Try alternative path - json_path = script_dir / "src" / "seed_data" / "inventory-locations.json" + # Try legacy path for backwards compatibility + json_path = script_dir / "milventory" / "public" / "inventory-locations.json" if not json_path.exists(): print(f"⚠ Warning: inventory-locations.json not found at {json_path}") return False @@ -49,7 +51,7 @@ def sync_locations_json(): # Fetch all locations from DB conn = get_db() cur = conn.cursor() - cur.execute("SELECT name, x, y, width, height, type FROM locations ORDER BY name") + cur.execute("SELECT name, x, y, width, height, type, protected FROM locations ORDER BY name") db_locations = {} for row in cur.fetchall(): db_locations[row[0]] = { @@ -58,7 +60,8 @@ def sync_locations_json(): 'y': row[2], 'width': row[3], 'height': row[4], - 'type': row[5] + 'type': row[5], + 'protected': bool(row[6]) if len(row) > 6 else False } cur.close() conn.close() @@ -121,7 +124,7 @@ def get_locations(): try: conn = get_db() cur = conn.cursor() - cur.execute("SELECT name, x, y, width, height, type FROM locations ORDER BY name") + cur.execute("SELECT name, x, y, width, height, type, protected FROM locations ORDER BY name") rows = cur.fetchall() locations = [Location.from_db_row(row).to_dict() for row in rows] cur.close() @@ -146,7 +149,7 @@ def get_location(name): try: conn = get_db() cur = conn.cursor() - cur.execute("SELECT name, x, y, width, height, type FROM locations WHERE name = %s", (name,)) + cur.execute("SELECT name, x, y, width, height, type, protected FROM locations WHERE name = %s", (name,)) row = cur.fetchone() cur.close() conn.close() @@ -199,16 +202,15 @@ def create_location(current_user_id=None): conn = get_db() cur = conn.cursor() cur.execute( - "INSERT INTO locations (name, x, y, width, height, type, shelf_count) VALUES (%s, %s, %s, %s, %s, %s, %s)", - (location.name, location.x, location.y, location.width, location.height, location.type, shelf_count) + "INSERT INTO locations (name, x, y, width, height, type, shelf_count, protected) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", + (location.name, location.x, location.y, location.width, location.height, location.type, shelf_count, False) ) conn.commit() cur.close() conn.close() - # Note: We do NOT sync to JSON file anymore. - # The inventory-locations.json file is now the source of truth for permanent locations. - # New locations created via the API are stored only in the database. + # Note: New locations are stored only in the database with protected=FALSE by default. + # Protected status is managed via the database column, not the JSON file. return jsonify(location.to_dict()), 201 except mysql.connector.IntegrityError as e: @@ -279,7 +281,7 @@ def update_location(name): conn.commit() # Fetch updated location - cur.execute("SELECT name, x, y, width, height, type FROM locations WHERE name = %s", (name,)) + cur.execute("SELECT name, x, y, width, height, type, protected FROM locations WHERE name = %s", (name,)) row = cur.fetchone() location = Location.from_db_row(row).to_dict() @@ -320,9 +322,8 @@ def delete_location(name, current_user_id=None): cur.close() conn.close() - # Note: We do NOT sync to JSON file anymore. - # The inventory-locations.json file is now the source of truth for permanent locations. - # Deletions only affect the database, not the JSON file. + # Note: Deletions only affect the database. + # Protected locations cannot be deleted (enforced by frontend based on protected column). return '', 204 except Exception as e: diff --git a/src/scripts/seed_data.py b/src/scripts/seed_data.py index a722713..316dea2 100644 --- a/src/scripts/seed_data.py +++ b/src/scripts/seed_data.py @@ -270,42 +270,64 @@ def seed_locations(): height = box.get('height', 150) if name in existing_names: - # Update existing location (update all fields including coordinates) + # Update existing location (update all fields including coordinates and protected status) # Check if columns exist first try: cur.execute( - "UPDATE locations SET type = %s, shelf_count = %s, x = %s, y = %s, width = %s, height = %s WHERE name = %s", - (location_type, shelf_count, x, y, width, height, name) + "UPDATE locations SET type = %s, shelf_count = %s, x = %s, y = %s, width = %s, height = %s, protected = %s WHERE name = %s", + (location_type, shelf_count, x, y, width, height, True, name) ) if cur.rowcount > 0: update_count += 1 except mysql.connector.Error as e: # If columns don't exist, try without them if 'Unknown column' in str(e): - cur.execute( - "UPDATE locations SET type = %s, shelf_count = %s WHERE name = %s", - (location_type, shelf_count, name) - ) - if cur.rowcount > 0: - update_count += 1 + # Try without protected column + try: + cur.execute( + "UPDATE locations SET type = %s, shelf_count = %s, x = %s, y = %s, width = %s, height = %s WHERE name = %s", + (location_type, shelf_count, x, y, width, height, name) + ) + if cur.rowcount > 0: + update_count += 1 + except mysql.connector.Error as e2: + if 'Unknown column' in str(e2): + cur.execute( + "UPDATE locations SET type = %s, shelf_count = %s WHERE name = %s", + (location_type, shelf_count, name) + ) + if cur.rowcount > 0: + update_count += 1 + else: + raise else: raise else: - # Insert new location with coordinates + # Insert new location with coordinates - set protected=True for locations from JSON try: - # Try with coordinates first + # Try with coordinates and protected first try: cur.execute( - "INSERT INTO locations (name, type, shelf_count, x, y, width, height) VALUES (%s, %s, %s, %s, %s, %s, %s)", - (name, location_type, shelf_count, x, y, width, height) + "INSERT INTO locations (name, type, shelf_count, x, y, width, height, protected) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", + (name, location_type, shelf_count, x, y, width, height, True) ) except mysql.connector.Error as e: - # If columns don't exist, insert without them - if 'Unknown column' in str(e): - cur.execute( - "INSERT INTO locations (name, type, shelf_count) VALUES (%s, %s, %s)", - (name, location_type, shelf_count) - ) + # If protected column doesn't exist, try without it + if 'Unknown column' in str(e) and 'protected' in str(e): + try: + cur.execute( + "INSERT INTO locations (name, type, shelf_count, x, y, width, height) VALUES (%s, %s, %s, %s, %s, %s, %s)", + (name, location_type, shelf_count, x, y, width, height) + ) + except mysql.connector.Error as e2: + # If coordinate columns don't exist, insert without them + if 'Unknown column' in str(e2): + cur.execute( + "INSERT INTO locations (name, type, shelf_count) VALUES (%s, %s, %s)", + (name, location_type, shelf_count) + ) + else: + raise else: raise insert_count += 1 diff --git a/src/scripts/seed_locations.py b/src/scripts/seed_locations.py index 977cfc2..bac277b 100644 --- a/src/scripts/seed_locations.py +++ b/src/scripts/seed_locations.py @@ -17,11 +17,11 @@ def load_locations_from_json(): - """Load locations from milventory/public/inventory-locations.json.""" + """Load locations from src/seed_data/inventory-locations.json.""" # Get project root (go up from src/scripts to project root) script_dir = Path(__file__).parent project_root = script_dir.parent.parent - json_path = project_root / "milventory" / "public" / "inventory-locations.json" + json_path = project_root / "src" / "seed_data" / "inventory-locations.json" if not json_path.exists(): print(f"⚠ Warning: {json_path} not found, using empty locations list") @@ -57,7 +57,7 @@ def derive_location_type(title): def seed_locations(): - """Sync locations from milventory/public/inventory-locations.json with database.""" + """Sync locations from src/seed_data/inventory-locations.json with database.""" try: # Get database connection parameters database_url = os.getenv("DATABASE_URL", "mysql://mysqluser:mysqlpassword@db:3306/mydb") @@ -183,19 +183,19 @@ def seed_locations(): height = box.get('height', 150) if name in existing_names: - # Update existing location (update all fields including coordinates) + # Update existing location (update all fields including coordinates and protected status) cur.execute( - "UPDATE locations SET type = %s, shelf_count = %s, x = %s, y = %s, width = %s, height = %s WHERE name = %s", - (location_type, shelf_count, x, y, width, height, name) + "UPDATE locations SET type = %s, shelf_count = %s, x = %s, y = %s, width = %s, height = %s, protected = %s WHERE name = %s", + (location_type, shelf_count, x, y, width, height, True, name) ) if cur.rowcount > 0: update_count += 1 else: - # Insert new location with coordinates + # Insert new location with coordinates - set protected=True for locations from JSON try: cur.execute( - "INSERT INTO locations (name, type, shelf_count, x, y, width, height) VALUES (%s, %s, %s, %s, %s, %s, %s)", - (name, location_type, shelf_count, x, y, width, height) + "INSERT INTO locations (name, type, shelf_count, x, y, width, height, protected) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", + (name, location_type, shelf_count, x, y, width, height, True) ) insert_count += 1 except mysql.connector.IntegrityError: diff --git a/src/sql/location/table_locations.sql b/src/sql/location/table_locations.sql index b46e69f..f30a712 100644 --- a/src/sql/location/table_locations.sql +++ b/src/sql/location/table_locations.sql @@ -1,4 +1,4 @@ --- Location metadata and layout coordinates (all stored in DB, synced to JSON for frontend) +-- Location metadata and layout coordinates (all stored in DB) CREATE TABLE locations ( name VARCHAR(100) PRIMARY KEY, -- e.g. "Drawer A", "Tall Cabinet 103" type VARCHAR(50) NOT NULL, -- "drawer", "cabinet", "table", "workbench", "tall_cabinet" @@ -7,6 +7,7 @@ CREATE TABLE locations ( width INT NOT NULL DEFAULT 150, -- Width in pixels height INT NOT NULL DEFAULT 150, -- Height in pixels shelf_count INT NOT NULL DEFAULT 0, -- 6 for Tall Cabinets, 0 for everything else + protected BOOLEAN NOT NULL DEFAULT FALSE, -- TRUE for permanent locations from JSON created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); From 3affdf1a05ef0bd91ccc218fee7dc8c93b48f034 Mon Sep 17 00:00:00 2001 From: willzoo Date: Sat, 28 Feb 2026 15:25:31 -0500 Subject: [PATCH 07/73] Remove workbench category - add "other" category --- milventory/src/components/AddLocationModal.js | 2 +- milventory/src/context/InventoryContext.js | 7 +------ src/api/routes/locations.py | 2 +- src/scripts/seed_data.py | 2 +- src/scripts/seed_locations.py | 4 ++-- src/seed_data/inventory-locations.json | 2 +- src/sql/location/table_locations.sql | 2 +- 7 files changed, 8 insertions(+), 13 deletions(-) diff --git a/milventory/src/components/AddLocationModal.js b/milventory/src/components/AddLocationModal.js index be5baae..fb32ea1 100644 --- a/milventory/src/components/AddLocationModal.js +++ b/milventory/src/components/AddLocationModal.js @@ -6,7 +6,7 @@ const LOCATION_TYPES = [ { value: 'cabinet', label: 'Cabinet' }, { value: 'tall_cabinet', label: 'Tall Cabinet' }, { value: 'table', label: 'Table' }, - { value: 'workbench', label: 'Workbench' } + { value: 'other', label: 'Other' } ]; const AddLocationModal = ({ isOpen, onClose, onSuccess, initialBox }) => { diff --git a/milventory/src/context/InventoryContext.js b/milventory/src/context/InventoryContext.js index e517f3f..50642e2 100644 --- a/milventory/src/context/InventoryContext.js +++ b/milventory/src/context/InventoryContext.js @@ -74,7 +74,7 @@ export const InventoryProvider = ({ children }) => { 'cabinet': 'var(--table)', 'tall_cabinet': 'var(--files)', // Tall cabinets use files color 'table': 'var(--table)', - 'workbench': '#e7ebf3', // Workbench has special color + 'other': '#e7ebf3', // Other category (includes workbench) has special color }; return typeFills[type] || 'var(--table)'; }; @@ -109,11 +109,6 @@ export const InventoryProvider = ({ children }) => { inventory: [] // Will be populated from supply locations API }; - // Add isWorkbench property if it's a workbench - if (location.type === 'workbench') { - boxData.isWorkbench = true; - } - newInventoryData.set(location.name, boxData); }); diff --git a/src/api/routes/locations.py b/src/api/routes/locations.py index 6f3b55d..f77c514 100644 --- a/src/api/routes/locations.py +++ b/src/api/routes/locations.py @@ -24,7 +24,7 @@ def get_fill_for_type(location_type): 'cabinet': 'var(--table)', 'tall_cabinet': 'var(--table)', 'table': 'var(--table)', - 'workbench': 'var(--table)', + 'other': 'var(--table)', } return type_fills.get(location_type, 'var(--table)') diff --git a/src/scripts/seed_data.py b/src/scripts/seed_data.py index 316dea2..d1af7d7 100644 --- a/src/scripts/seed_data.py +++ b/src/scripts/seed_data.py @@ -78,7 +78,7 @@ def derive_location_type(title): elif title_lower.startswith('table'): return 'table' elif 'workbench' in title_lower or title_lower == 'workbench': - return 'workbench' + return 'other' else: return 'unknown' diff --git a/src/scripts/seed_locations.py b/src/scripts/seed_locations.py index bac277b..548f92d 100644 --- a/src/scripts/seed_locations.py +++ b/src/scripts/seed_locations.py @@ -51,9 +51,9 @@ def derive_location_type(title): elif title_lower.startswith('table'): return 'table' elif 'workbench' in title_lower or title_lower == 'workbench': - return 'workbench' + return 'other' else: - return 'unknown' + return 'other' # Default to 'other' instead of 'unknown' def seed_locations(): diff --git a/src/seed_data/inventory-locations.json b/src/seed_data/inventory-locations.json index 76c9996..aafc427 100644 --- a/src/seed_data/inventory-locations.json +++ b/src/seed_data/inventory-locations.json @@ -54,7 +54,7 @@ { "title": "Cabinet 10" , "x": 3325, "y": 2390, "width": 155, "height": 305, "fill": "var(--table)" }, { "title": "Cabinet 11" , "x": 3325, "y": 2700, "width": 155, "height": 305, "fill": "var(--table)" }, { "title": "Cabinet 12" , "x": 3325, "y": 3010, "width": 155, "height": 305, "fill": "var(--table)" }, - { "title": "Workbench" , "x": 140 , "y": 1680, "width": 350, "height": 520, "fill": "#e7ebf3" , "isWorkbench": true }, + { "title": "Workbench" , "x": 140 , "y": 1680, "width": 350, "height": 520, "fill": "#e7ebf3" }, { "title": "Tall Cabinet 103", "x": 140 , "y": 2260, "width": 240, "height": 300, "fill": "var(--files)" }, { "title": "Tall Cabinet 102", "x": 140 , "y": 2565, "width": 240, "height": 300, "fill": "var(--files)" }, { "title": "Tall Cabinet 101", "x": 140 , "y": 2870, "width": 240, "height": 300, "fill": "var(--files)" }, diff --git a/src/sql/location/table_locations.sql b/src/sql/location/table_locations.sql index f30a712..34c3d6a 100644 --- a/src/sql/location/table_locations.sql +++ b/src/sql/location/table_locations.sql @@ -1,7 +1,7 @@ -- Location metadata and layout coordinates (all stored in DB) CREATE TABLE locations ( name VARCHAR(100) PRIMARY KEY, -- e.g. "Drawer A", "Tall Cabinet 103" - type VARCHAR(50) NOT NULL, -- "drawer", "cabinet", "table", "workbench", "tall_cabinet" + type VARCHAR(50) NOT NULL, -- "drawer", "cabinet", "table", "tall_cabinet", "other" x INT NOT NULL DEFAULT 0, -- X coordinate for layout y INT NOT NULL DEFAULT 0, -- Y coordinate for layout width INT NOT NULL DEFAULT 150, -- Width in pixels From d0505e50d7b2ae9bfb90ef2eb4b4a94c83ed5816 Mon Sep 17 00:00:00 2001 From: willzoo Date: Sat, 28 Feb 2026 16:05:45 -0500 Subject: [PATCH 08/73] Add new location pane edits --- milventory/src/components/AddLocationModal.js | 394 +++++++++++++----- milventory/src/components/AdminDashboard.js | 23 + milventory/src/components/AdminMap.js | 169 +++++++- 3 files changed, 470 insertions(+), 116 deletions(-) diff --git a/milventory/src/components/AddLocationModal.js b/milventory/src/components/AddLocationModal.js index fb32ea1..bf8e919 100644 --- a/milventory/src/components/AddLocationModal.js +++ b/milventory/src/components/AddLocationModal.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useRef } from 'react'; import { admin } from '../api'; const LOCATION_TYPES = [ @@ -9,14 +9,14 @@ const LOCATION_TYPES = [ { value: 'other', label: 'Other' } ]; -const AddLocationModal = ({ isOpen, onClose, onSuccess, initialBox }) => { +const AddLocationModal = ({ isOpen, onClose, onSuccess, initialBox, leftPaneWidth, leftPaneCollapsed, onPreviewUpdate, previewBox, onEdgeDrag }) => { const [formData, setFormData] = useState({ name: '', type: 'drawer', - x: 0, - y: 0, - width: 150, - height: 150 + topY: '', + bottomY: '', + leftX: '', + rightX: '' }); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); @@ -26,19 +26,69 @@ const AddLocationModal = ({ isOpen, onClose, onSuccess, initialBox }) => { if (initialBox && isOpen) { setFormData(prev => ({ ...prev, - x: initialBox.x, - y: initialBox.y, - width: initialBox.width, - height: initialBox.height + topY: String(initialBox.y), + bottomY: String(initialBox.y + initialBox.height), + leftX: String(initialBox.x), + rightX: String(initialBox.x + initialBox.width) })); } }, [initialBox, isOpen]); + // Edge drag handler is set up via onEdgeDrag ref in useEffect below + + // Update preview on map when form data changes (only if valid) + React.useEffect(() => { + if (isOpen && onPreviewUpdate) { + const topY = parseFloat(formData.topY); + const bottomY = parseFloat(formData.bottomY); + const leftX = parseFloat(formData.leftX); + const rightX = parseFloat(formData.rightX); + + // Only show preview if all values are valid numbers and edges make sense + if (!isNaN(topY) && !isNaN(bottomY) && !isNaN(leftX) && !isNaN(rightX) && + bottomY > topY && rightX > leftX) { + onPreviewUpdate({ + x: leftX, + y: topY, + width: rightX - leftX, + height: bottomY - topY + }); + } else { + // Hide preview if invalid + onPreviewUpdate(null); + } + } + }, [formData.topY, formData.bottomY, formData.leftX, formData.rightX, isOpen, onPreviewUpdate]); + + // Update form data when an edge value is changed + const handleEdgeChange = (field, value) => { + setFormData(prev => ({ + ...prev, + [field]: value + })); + setError(null); + }; + + // Expose edge drag handler to parent + React.useEffect(() => { + if (onEdgeDrag) { + onEdgeDrag.current = (edges) => { + setFormData(prev => ({ + ...prev, + topY: String(edges.topY), + bottomY: String(edges.bottomY), + leftX: String(edges.leftX), + rightX: String(edges.rightX) + })); + }; + } + }, [onEdgeDrag]); + const handleChange = (e) => { const { name, value } = e.target; setFormData(prev => ({ ...prev, - [name]: name === 'name' || name === 'type' ? value : parseInt(value, 10) || 0 + [name]: value // Store as string to allow empty values })); setError(null); }; @@ -56,24 +106,59 @@ const AddLocationModal = ({ isOpen, onClose, onSuccess, initialBox }) => { return; } - if (formData.width <= 0 || formData.height <= 0) { - setError('Width and height must be greater than 0'); + // Parse edge values and calculate x, y, width, height + const topY = parseFloat(formData.topY); + const bottomY = parseFloat(formData.bottomY); + const leftX = parseFloat(formData.leftX); + const rightX = parseFloat(formData.rightX); + + if (isNaN(topY) || isNaN(bottomY) || isNaN(leftX) || isNaN(rightX)) { + setError('All edge values must be valid numbers'); + setSubmitting(false); + return; + } + + if (bottomY <= topY) { + setError('Bottom Y must be greater than Top Y'); setSubmitting(false); return; } - await admin.createLocation(formData); + if (rightX <= leftX) { + setError('Right X must be greater than Left X'); + setSubmitting(false); + return; + } + + const x = leftX; + const y = topY; + const width = rightX - leftX; + const height = bottomY - topY; + + await admin.createLocation({ + name: formData.name, + type: formData.type, + x: x, + y: y, + width: width, + height: height + }); // Reset form setFormData({ name: '', type: 'drawer', - x: 0, - y: 0, - width: 150, - height: 150 + topY: '', + bottomY: '', + leftX: '', + rightX: '' }); + // Clear preview + if (onPreviewUpdate) { + onPreviewUpdate(null); + } + if (onSuccess) { onSuccess(); } @@ -89,124 +174,219 @@ const AddLocationModal = ({ isOpen, onClose, onSuccess, initialBox }) => { setFormData({ name: '', type: 'drawer', - x: 0, - y: 0, - width: 150, - height: 150 + topY: '', + bottomY: '', + leftX: '', + rightX: '' }); setError(null); + // Clear preview + if (onPreviewUpdate) { + onPreviewUpdate(null); + } onClose(); }; if (!isOpen) return null; + // Calculate position to the right of left pane (same as LocationPreview) + const leftPaneActualWidth = leftPaneCollapsed ? 40 : leftPaneWidth; + const positionX = leftPaneActualWidth + 20; + const positionY = 20; + return ( -
-
e.stopPropagation()}> -

Add New Location

+
+
+
+

Add New Location

+ +
{error && ( -
{error}
)} - - -
- +
-
-
- - -
-
- - + {LOCATION_TYPES.map(type => ( + + ))} + +
+
+ Position & Size +
+
+
+
+ Top Y +
+ handleEdgeChange('topY', e.target.value)} + disabled={submitting} + style={{ + width: '100%', + fontSize: '0.85rem', + padding: '0.35rem', + background: '#27292E', + border: '1px solid var(--border)', + borderRadius: '4px', + color: 'var(--text)' + }} + /> +
+
+
+ Bottom Y +
+ handleEdgeChange('bottomY', e.target.value)} + disabled={submitting} + style={{ + width: '100%', + fontSize: '0.85rem', + padding: '0.35rem', + background: '#27292E', + border: '1px solid var(--border)', + borderRadius: '4px', + color: 'var(--text)' + }} + /> +
+
+
+ Left X +
+ handleEdgeChange('leftX', e.target.value)} + disabled={submitting} + style={{ + width: '100%', + fontSize: '0.85rem', + padding: '0.35rem', + background: '#27292E', + border: '1px solid var(--border)', + borderRadius: '4px', + color: 'var(--text)' + }} + /> +
+
+
+ Right X +
+ handleEdgeChange('rightX', e.target.value)} + disabled={submitting} + style={{ + width: '100%', + fontSize: '0.85rem', + padding: '0.35rem', + background: '#27292E', + border: '1px solid var(--border)', + borderRadius: '4px', + color: 'var(--text)' + }} + /> +
+
+
+
+ + +
diff --git a/milventory/src/components/AdminDashboard.js b/milventory/src/components/AdminDashboard.js index 376df01..faac8b2 100644 --- a/milventory/src/components/AdminDashboard.js +++ b/milventory/src/components/AdminDashboard.js @@ -12,8 +12,10 @@ const AdminDashboard = () => { const [drawMode, setDrawMode] = useState(false); const [showAddLocationModal, setShowAddLocationModal] = useState(false); const [drawnBox, setDrawnBox] = useState(null); + const [previewBox, setPreviewBox] = useState(null); const [refreshTrigger, setRefreshTrigger] = useState(0); const [selectedLocation, setSelectedLocation] = useState(null); + const edgeDragHandlerRef = useRef(null); const handleAddLocation = () => { setDrawMode(true); @@ -28,6 +30,7 @@ const AdminDashboard = () => { const handleModalClose = () => { setShowAddLocationModal(false); setDrawnBox(null); + setPreviewBox(null); setDrawMode(false); }; @@ -80,6 +83,21 @@ const AdminDashboard = () => { onDrawComplete={handleDrawComplete} selectedLocation={selectedLocation} onLocationSelect={handleLocationSelect} + previewBox={previewBox} + onPreviewEdgeDrag={(edges) => { + // Update preview box state directly + const newPreviewBox = { + x: edges.leftX, + y: edges.topY, + width: edges.rightX - edges.leftX, + height: edges.bottomY - edges.topY + }; + setPreviewBox(newPreviewBox); + // Also trigger form update via edge drag handler if available + if (edgeDragHandlerRef.current) { + edgeDragHandlerRef.current(edges); + } + }} /> { onClose={handleModalClose} onSuccess={handleLocationAdded} initialBox={drawnBox} + leftPaneWidth={leftPaneWidth} + leftPaneCollapsed={leftPaneCollapsed} + onPreviewUpdate={setPreviewBox} + previewBox={previewBox} + onEdgeDrag={edgeDragHandlerRef} /> { - const { drawMode, onDrawComplete, selectedLocation, onLocationSelect } = props; + const { drawMode, onDrawComplete, selectedLocation, onLocationSelect, previewBox, onPreviewEdgeDrag } = props; const { inventoryData, inventoryBounds } = useInventory(); const worldRef = useRef(null); const svgRef = useRef(null); @@ -14,6 +14,9 @@ const AdminMap = forwardRef((props, ref) => { const drawStartRef = useRef(null); const currentDrawingBoxRef = useRef(null); const currentTransformRef = useRef(d3.zoomIdentity); + const [isDraggingEdge, setIsDraggingEdge] = useState(false); + const [draggingEdge, setDraggingEdge] = useState(null); // 'top', 'bottom', 'left', 'right' + const edgeDragStartRef = useRef(null); // Expose svgRef to parent via forwarded ref useEffect(() => { @@ -76,6 +79,11 @@ const AdminMap = forwardRef((props, ref) => { }; }; + // Snap value to nearest multiple of 5 + const snapTo5 = (value) => { + return Math.round(value / 5) * 5; + }; + // Handle mouse down for drawing const handleMouseDown = (e) => { if (!drawMode || isPanningRef.current) return; @@ -108,11 +116,26 @@ const AdminMap = forwardRef((props, ref) => { const svgCoords = screenToSVG(e.clientX, e.clientY); const start = drawStartRef.current; + // Calculate raw dimensions + const rawWidth = Math.abs(svgCoords.x - start.x); + const rawHeight = Math.abs(svgCoords.y - start.y); + + // Snap width and height to nearest multiple of 5 + const snappedWidth = snapTo5(rawWidth); + const snappedHeight = snapTo5(rawHeight); + + // Calculate box position: always use the minimum of start and current position + // This ensures the box always starts from the top-left corner + const minX = Math.min(start.x, svgCoords.x); + const minY = Math.min(start.y, svgCoords.y); + + // Adjust position if we're dragging left or up to account for snapped dimensions + // If dragging left, adjust x; if dragging up, adjust y const updatedBox = { - x: Math.min(start.x, svgCoords.x), - y: Math.min(start.y, svgCoords.y), - width: Math.abs(svgCoords.x - start.x), - height: Math.abs(svgCoords.y - start.y) + x: svgCoords.x < start.x ? start.x - snappedWidth : minX, + y: svgCoords.y < start.y ? start.y - snappedHeight : minY, + width: snappedWidth, + height: snappedHeight }; currentDrawingBoxRef.current = updatedBox; setDrawingBox(updatedBox); @@ -120,6 +143,14 @@ const AdminMap = forwardRef((props, ref) => { // Handle mouse up to complete drawing const handleMouseUp = (e) => { + if (isDraggingEdge) { + // End edge dragging + setIsDraggingEdge(false); + setDraggingEdge(null); + edgeDragStartRef.current = null; + return; + } + if (!drawMode || !isDrawing) return; // Prevent default to avoid conflicts with zoom @@ -137,13 +168,14 @@ const AdminMap = forwardRef((props, ref) => { } // Only complete if box has minimum size + // Dimensions are already snapped to 5px intervals from handleMouseMove if (currentBox.width > 10 && currentBox.height > 10) { if (onDrawComplete) { onDrawComplete({ x: Math.round(currentBox.x), y: Math.round(currentBox.y), - width: Math.round(currentBox.width), - height: Math.round(currentBox.height) + width: currentBox.width, // Already snapped to 5px + height: currentBox.height // Already snapped to 5px }); } } @@ -154,6 +186,63 @@ const AdminMap = forwardRef((props, ref) => { drawStartRef.current = null; }; + // Handle edge drag start + const handleEdgeMouseDown = (e, edge) => { + if (!previewBox || !onPreviewEdgeDrag) return; + e.preventDefault(); + e.stopPropagation(); + + const svgCoords = screenToSVG(e.clientX, e.clientY); + setIsDraggingEdge(true); + setDraggingEdge(edge); + edgeDragStartRef.current = { + edge, + startCoords: svgCoords, + initialBox: { ...previewBox } + }; + }; + + // Handle edge drag move + const handleEdgeMouseMove = (e) => { + if (!isDraggingEdge || !draggingEdge || !edgeDragStartRef.current || !onPreviewEdgeDrag) return; + + e.preventDefault(); + e.stopPropagation(); + + const svgCoords = screenToSVG(e.clientX, e.clientY); + const { initialBox } = edgeDragStartRef.current; + + let newTopY = initialBox.y; + let newBottomY = initialBox.y + initialBox.height; + let newLeftX = initialBox.x; + let newRightX = initialBox.x + initialBox.width; + + // Snap to 5px intervals + const snapTo5 = (value) => Math.round(value / 5) * 5; + + if (draggingEdge === 'top') { + newTopY = snapTo5(svgCoords.y); + if (newTopY >= newBottomY) newTopY = newBottomY - 5; + } else if (draggingEdge === 'bottom') { + newBottomY = snapTo5(svgCoords.y); + if (newBottomY <= newTopY) newBottomY = newTopY + 5; + } else if (draggingEdge === 'left') { + newLeftX = snapTo5(svgCoords.x); + if (newLeftX >= newRightX) newLeftX = newRightX - 5; + } else if (draggingEdge === 'right') { + newRightX = snapTo5(svgCoords.x); + if (newRightX <= newLeftX) newRightX = newLeftX + 5; + } + + // Update preview box + onPreviewEdgeDrag({ + topY: newTopY, + bottomY: newBottomY, + leftX: newLeftX, + rightX: newRightX + }); + }; + const boxes = inventoryData ? Array.from(inventoryData.values()) : []; const viewBox = inventoryBounds?.viewBox @@ -181,8 +270,8 @@ const AdminMap = forwardRef((props, ref) => { cursor: drawMode ? 'crosshair' : 'default' }} onMouseDown={drawMode ? handleMouseDown : undefined} - onMouseMove={drawMode ? handleMouseMove : undefined} - onMouseUp={drawMode ? handleMouseUp : undefined} + onMouseMove={drawMode ? handleMouseMove : isDraggingEdge ? handleEdgeMouseMove : undefined} + onMouseUp={drawMode ? handleMouseUp : isDraggingEdge ? handleMouseUp : undefined} onMouseLeave={drawMode ? () => { // Cancel drawing if mouse leaves if (isDrawing) { @@ -279,6 +368,68 @@ const AdminMap = forwardRef((props, ref) => { style={{ pointerEvents: 'none' }} /> )} + + {/* Preview box from AddLocationModal with draggable edges */} + {previewBox && !drawingBox && ( + <> + + {/* Top edge */} + handleEdgeMouseDown(e, 'top')} + /> + {/* Bottom edge */} + handleEdgeMouseDown(e, 'bottom')} + /> + {/* Left edge */} + handleEdgeMouseDown(e, 'left')} + /> + {/* Right edge */} + handleEdgeMouseDown(e, 'right')} + /> + + )} ); From 17124a4f5a1bd44c2ea6a09e22ad7ca2377d2e32 Mon Sep 17 00:00:00 2001 From: willzoo Date: Sat, 28 Feb 2026 16:08:15 -0500 Subject: [PATCH 09/73] Make location edge drag fix --- milventory/src/components/AdminMap.js | 89 +++++++++++++++++++++++---- 1 file changed, 77 insertions(+), 12 deletions(-) diff --git a/milventory/src/components/AdminMap.js b/milventory/src/components/AdminMap.js index b7c6e61..bdeb815 100644 --- a/milventory/src/components/AdminMap.js +++ b/milventory/src/components/AdminMap.js @@ -15,6 +15,7 @@ const AdminMap = forwardRef((props, ref) => { const currentDrawingBoxRef = useRef(null); const currentTransformRef = useRef(d3.zoomIdentity); const [isDraggingEdge, setIsDraggingEdge] = useState(false); + const isDraggingEdgeRef = useRef(false); // Synchronous ref for D3 filter const [draggingEdge, setDraggingEdge] = useState(null); // 'top', 'bottom', 'left', 'right' const edgeDragStartRef = useRef(null); @@ -35,7 +36,14 @@ const AdminMap = forwardRef((props, ref) => { const zoom = d3.zoom() .scaleExtent([0.6, 6]) - .filter(() => !drawMode) // Disable zoom/pan when in draw mode + .filter((event) => { + // Disable zoom/pan when in draw mode or when dragging an edge handle + if (drawMode) return false; + if (isDraggingEdgeRef.current) return false; + // Check if the event target is an edge handle + if (event.target && event.target.dataset && event.target.dataset.edgeHandle) return false; + return true; + }) .on('start', () => { if (!drawMode) { isPanningRef.current = true; @@ -143,8 +151,9 @@ const AdminMap = forwardRef((props, ref) => { // Handle mouse up to complete drawing const handleMouseUp = (e) => { - if (isDraggingEdge) { + if (isDraggingEdgeRef.current) { // End edge dragging + isDraggingEdgeRef.current = false; setIsDraggingEdge(false); setDraggingEdge(null); edgeDragStartRef.current = null; @@ -193,6 +202,7 @@ const AdminMap = forwardRef((props, ref) => { e.stopPropagation(); const svgCoords = screenToSVG(e.clientX, e.clientY); + isDraggingEdgeRef.current = true; setIsDraggingEdge(true); setDraggingEdge(edge); edgeDragStartRef.current = { @@ -204,7 +214,7 @@ const AdminMap = forwardRef((props, ref) => { // Handle edge drag move const handleEdgeMouseMove = (e) => { - if (!isDraggingEdge || !draggingEdge || !edgeDragStartRef.current || !onPreviewEdgeDrag) return; + if (!isDraggingEdgeRef.current || !edgeDragStartRef.current || !onPreviewEdgeDrag) return; e.preventDefault(); e.stopPropagation(); @@ -270,17 +280,28 @@ const AdminMap = forwardRef((props, ref) => { cursor: drawMode ? 'crosshair' : 'default' }} onMouseDown={drawMode ? handleMouseDown : undefined} - onMouseMove={drawMode ? handleMouseMove : isDraggingEdge ? handleEdgeMouseMove : undefined} - onMouseUp={drawMode ? handleMouseUp : isDraggingEdge ? handleMouseUp : undefined} - onMouseLeave={drawMode ? () => { - // Cancel drawing if mouse leaves + onMouseMove={(e) => { + if (drawMode) { handleMouseMove(e); return; } + if (isDraggingEdgeRef.current) { handleEdgeMouseMove(e); return; } + }} + onMouseUp={(e) => { + if (isDraggingEdgeRef.current) { handleMouseUp(e); return; } + if (drawMode) { handleMouseUp(e); return; } + }} + onMouseLeave={() => { if (isDrawing) { setIsDrawing(false); setDrawingBox(null); currentDrawingBoxRef.current = null; drawStartRef.current = null; } - } : undefined} + if (isDraggingEdgeRef.current) { + isDraggingEdgeRef.current = false; + setIsDraggingEdge(false); + setDraggingEdge(null); + edgeDragStartRef.current = null; + } + }} > { x2={previewBox.x + previewBox.width} y2={previewBox.y} stroke="var(--accent)" - strokeWidth="4" + strokeWidth="8" + strokeOpacity="0" + data-edge-handle="top" style={{ cursor: 'ns-resize', pointerEvents: 'auto' }} onMouseDown={(e) => handleEdgeMouseDown(e, 'top')} /> + {/* Bottom edge */} { x2={previewBox.x + previewBox.width} y2={previewBox.y + previewBox.height} stroke="var(--accent)" - strokeWidth="4" + strokeWidth="8" + strokeOpacity="0" + data-edge-handle="bottom" style={{ cursor: 'ns-resize', pointerEvents: 'auto' }} onMouseDown={(e) => handleEdgeMouseDown(e, 'bottom')} /> + {/* Left edge */} { x2={previewBox.x} y2={previewBox.y + previewBox.height} stroke="var(--accent)" - strokeWidth="4" + strokeWidth="8" + strokeOpacity="0" + data-edge-handle="left" style={{ cursor: 'ew-resize', pointerEvents: 'auto' }} onMouseDown={(e) => handleEdgeMouseDown(e, 'left')} /> + {/* Right edge */} { x2={previewBox.x + previewBox.width} y2={previewBox.y + previewBox.height} stroke="var(--accent)" - strokeWidth="4" + strokeWidth="8" + strokeOpacity="0" + data-edge-handle="right" style={{ cursor: 'ew-resize', pointerEvents: 'auto' }} onMouseDown={(e) => handleEdgeMouseDown(e, 'right')} /> + )} From 8c1031600a7cce6f1051cdb068c966741e3f3c2b Mon Sep 17 00:00:00 2001 From: willzoo Date: Sat, 28 Feb 2026 16:19:06 -0500 Subject: [PATCH 10/73] Move top drawer/cabinet y pos down --- src/seed_data/inventory-locations.json | 30 +++++++++++++------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/seed_data/inventory-locations.json b/src/seed_data/inventory-locations.json index aafc427..24ef87b 100644 --- a/src/seed_data/inventory-locations.json +++ b/src/seed_data/inventory-locations.json @@ -16,21 +16,21 @@ } }, "boxes": [ - { "title": "Drawer A" , "x": 720 , "y": 80 , "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer B" , "x": 875 , "y": 80 , "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer C" , "x": 1030, "y": 80 , "width": 300, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer D" , "x": 1335, "y": 80 , "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer E" , "x": 1490, "y": 80 , "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer F" , "x": 1645, "y": 80 , "width": 300, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer G" , "x": 1950, "y": 80 , "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer H" , "x": 2105, "y": 80 , "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer I" , "x": 2260, "y": 80 , "width": 300, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer J" , "x": 2565, "y": 80 , "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Drawer K" , "x": 2720, "y": 80 , "width": 150, "height": 150, "fill": "var(--drawer)" }, - { "title": "Cabinet 1" , "x": 720 , "y": 250 , "width": 305, "height": 155, "fill": "var(--table)" }, - { "title": "Cabinet 2" , "x": 1335, "y": 250 , "width": 305, "height": 155, "fill": "var(--table)" }, - { "title": "Cabinet 3" , "x": 1950, "y": 250 , "width": 305, "height": 155, "fill": "var(--table)" }, - { "title": "Cabinet 4" , "x": 2565, "y": 250 , "width": 305, "height": 155, "fill": "var(--table)" }, + { "title": "Drawer A" , "x": 720 , "y": 120 , "width": 150, "height": 150, "fill": "var(--drawer)" }, + { "title": "Drawer B" , "x": 875 , "y": 120 , "width": 150, "height": 150, "fill": "var(--drawer)" }, + { "title": "Drawer C" , "x": 1030, "y": 120 , "width": 300, "height": 150, "fill": "var(--drawer)" }, + { "title": "Drawer D" , "x": 1335, "y": 120 , "width": 150, "height": 150, "fill": "var(--drawer)" }, + { "title": "Drawer E" , "x": 1490, "y": 120 , "width": 150, "height": 150, "fill": "var(--drawer)" }, + { "title": "Drawer F" , "x": 1645, "y": 120 , "width": 300, "height": 150, "fill": "var(--drawer)" }, + { "title": "Drawer G" , "x": 1950, "y": 120 , "width": 150, "height": 150, "fill": "var(--drawer)" }, + { "title": "Drawer H" , "x": 2105, "y": 120 , "width": 150, "height": 150, "fill": "var(--drawer)" }, + { "title": "Drawer I" , "x": 2260, "y": 120 , "width": 300, "height": 150, "fill": "var(--drawer)" }, + { "title": "Drawer J" , "x": 2565, "y": 120 , "width": 150, "height": 150, "fill": "var(--drawer)" }, + { "title": "Drawer K" , "x": 2720, "y": 120 , "width": 150, "height": 150, "fill": "var(--drawer)" }, + { "title": "Cabinet 1" , "x": 720 , "y": 290 , "width": 305, "height": 155, "fill": "var(--table)" }, + { "title": "Cabinet 2" , "x": 1335, "y": 290 , "width": 305, "height": 155, "fill": "var(--table)" }, + { "title": "Cabinet 3" , "x": 1950, "y": 290 , "width": 305, "height": 155, "fill": "var(--table)" }, + { "title": "Cabinet 4" , "x": 2565, "y": 290 , "width": 305, "height": 155, "fill": "var(--table)" }, { "title": "Drawer L" , "x": 3500, "y": 840 , "width": 150, "height": 150, "fill": "var(--drawer)" }, { "title": "Drawer M" , "x": 3500, "y": 995 , "width": 150, "height": 150, "fill": "var(--drawer)" }, { "title": "Drawer N" , "x": 3500, "y": 1150, "width": 150, "height": 305, "fill": "var(--drawer)" }, From ca11f7f32e27358e22b232df5f643c311d2b7f22 Mon Sep 17 00:00:00 2001 From: willzoo Date: Sat, 28 Feb 2026 16:19:29 -0500 Subject: [PATCH 11/73] Fix zoom reset bug on add location --- milventory/src/components/AdminMap.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/milventory/src/components/AdminMap.js b/milventory/src/components/AdminMap.js index bdeb815..f1e88a9 100644 --- a/milventory/src/components/AdminMap.js +++ b/milventory/src/components/AdminMap.js @@ -14,6 +14,7 @@ const AdminMap = forwardRef((props, ref) => { const drawStartRef = useRef(null); const currentDrawingBoxRef = useRef(null); const currentTransformRef = useRef(d3.zoomIdentity); + const isZoomInitializedRef = useRef(false); // Track if initial zoom has been set const [isDraggingEdge, setIsDraggingEdge] = useState(false); const isDraggingEdgeRef = useRef(false); // Synchronous ref for D3 filter const [draggingEdge, setDraggingEdge] = useState(null); // 'top', 'bottom', 'left', 'right' @@ -61,8 +62,10 @@ const AdminMap = forwardRef((props, ref) => { const svg = d3.select(svgRef.current); svg.call(zoom).on('dblclick.zoom', null); - if (!drawMode) { + // Only set initial transform on first mount, not when drawMode changes + if (!isZoomInitializedRef.current && !drawMode) { svg.call(zoom.transform, d3.zoomIdentity.scale(1.03)); + isZoomInitializedRef.current = true; } }, [drawMode]); From c072d78101c16f1794d67edeede60983068e62c2 Mon Sep 17 00:00:00 2001 From: willzoo Date: Sun, 1 Mar 2026 10:29:18 -0500 Subject: [PATCH 12/73] WIP: Move items --- milventory/src/components/ArrowConnections.js | 127 +++++--- milventory/src/components/Map.js | 57 +++- .../src/components/MasterItemPreview.js | 38 ++- milventory/src/components/MoveModeBoxes.js | 270 ++++++++++++++++++ milventory/src/context/InventoryContext.js | 162 +++++++++++ 5 files changed, 598 insertions(+), 56 deletions(-) create mode 100644 milventory/src/components/MoveModeBoxes.js diff --git a/milventory/src/components/ArrowConnections.js b/milventory/src/components/ArrowConnections.js index 38389d5..80912df 100644 --- a/milventory/src/components/ArrowConnections.js +++ b/milventory/src/components/ArrowConnections.js @@ -2,13 +2,23 @@ import React, { useEffect, useRef, useCallback } from 'react'; import { useInventory } from '../context/InventoryContext'; import * as d3 from 'd3'; +const SHELF_NAMES = [ + 'Shelf 6 (Top)', + 'Shelf 5', + 'Shelf 4', + 'Shelf 3', + 'Shelf 2', + 'Shelf 1 (Bottom)' +]; + const ArrowConnections = () => { const { selectedMasterItem, getItemLocations, inventoryData, svgRef, - worldRef + worldRef, + moveModeItem } = useInventory(); const arrowsRef = useRef(null); @@ -53,50 +63,87 @@ const ArrowConnections = () => { const previewX = preview.x; const previewY = preview.y; - // Draw arrows to each location box + // Draw arrows to each location box (or move boxes if in move mode) locations.forEach((boxTitle) => { const boxData = inventoryData.get(boxTitle); if (!boxData) return; - const boxX = boxData.x + boxData.width / 2; - const boxY = boxData.y + boxData.height / 2; - - const path = d3.path(); - const dx = boxX - previewX; - const dy = boxY - previewY; - - const cp1x = previewX + dx * 0.3; - const cp1y = previewY; - const cp2x = boxX - dx * 0.3; - const cp2y = boxY; - - path.moveTo(previewX, previewY); - path.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, boxX, boxY); - - // Arrowhead - const angle = Math.atan2(dy, dx); - const arrowLength = 12; - const arrowAngle = Math.PI / 6; - const arrowX = boxX - Math.cos(angle) * 20; - const arrowY = boxY - Math.sin(angle) * 20; - - path.moveTo(arrowX, arrowY); - path.lineTo( - arrowX - arrowLength * Math.cos(angle - arrowAngle), - arrowY - arrowLength * Math.sin(angle - arrowAngle) - ); - path.moveTo(arrowX, arrowY); - path.lineTo( - arrowX - arrowLength * Math.cos(angle + arrowAngle), - arrowY - arrowLength * Math.sin(angle + arrowAngle) - ); - - const pathElement = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - pathElement.setAttribute('d', path.toString()); - pathElement.setAttribute('class', 'master-arrow-path'); - arrowsGroup.appendChild(pathElement); + let boxX, boxY; + + if (moveModeItem && moveModeItem === selectedMasterItem) { + // In move mode, point to the little red boxes + const matchingItems = boxData.inventory.filter(item => item.name === moveModeItem); + if (matchingItems.length === 0) return; + + const isTallCabinet = boxTitle.startsWith('Tall Cabinet'); + + if (isTallCabinet) { + // For Tall Cabinets, point to each shelf's move box + matchingItems.forEach(item => { + const shelfIdx = item.shelf ?? 0; + const shelfH = boxData.height / SHELF_NAMES.length; + const shelfY = boxData.y + shelfIdx * shelfH; + + const boxSize = Math.min(shelfH * 0.6, boxData.width * 0.4, 60); + boxX = boxData.x + (boxData.width - boxSize) / 2 + boxSize / 2; + boxY = shelfY + (shelfH - boxSize) / 2 + boxSize / 2; + + drawArrowToPoint(previewX, previewY, boxX, boxY, arrowsGroup); + }); + return; // Skip the regular box arrow for Tall Cabinets + } else { + // For regular boxes, point to the center move box + const boxSize = Math.min(boxData.height * 0.5, boxData.width * 0.4, 60); + boxX = boxData.x + (boxData.width - boxSize) / 2 + boxSize / 2; + boxY = boxData.y + (boxData.height - boxSize) / 2 + boxSize / 2; + } + } else { + // Normal mode: point to center of inventory box + boxX = boxData.x + boxData.width / 2; + boxY = boxData.y + boxData.height / 2; + } + + drawArrowToPoint(previewX, previewY, boxX, boxY, arrowsGroup); }); - }, [selectedMasterItem, getItemLocations, inventoryData, svgRef, screenToWorld]); + }, [selectedMasterItem, getItemLocations, inventoryData, svgRef, screenToWorld, moveModeItem]); + + const drawArrowToPoint = (previewX, previewY, boxX, boxY, arrowsGroup) => { + + const path = d3.path(); + const dx = boxX - previewX; + const dy = boxY - previewY; + + const cp1x = previewX + dx * 0.3; + const cp1y = previewY; + const cp2x = boxX - dx * 0.3; + const cp2y = boxY; + + path.moveTo(previewX, previewY); + path.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, boxX, boxY); + + // Arrowhead + const angle = Math.atan2(dy, dx); + const arrowLength = 12; + const arrowAngle = Math.PI / 6; + const arrowX = boxX - Math.cos(angle) * 20; + const arrowY = boxY - Math.sin(angle) * 20; + + path.moveTo(arrowX, arrowY); + path.lineTo( + arrowX - arrowLength * Math.cos(angle - arrowAngle), + arrowY - arrowLength * Math.sin(angle - arrowAngle) + ); + path.moveTo(arrowX, arrowY); + path.lineTo( + arrowX - arrowLength * Math.cos(angle + arrowAngle), + arrowY - arrowLength * Math.sin(angle + arrowAngle) + ); + + const pathElement = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + pathElement.setAttribute('d', path.toString()); + pathElement.setAttribute('class', 'master-arrow-path'); + arrowsGroup.appendChild(pathElement); + }; // Draw arrows when selectedMasterItem changes useEffect(() => { diff --git a/milventory/src/components/Map.js b/milventory/src/components/Map.js index a62ea05..8bc1868 100644 --- a/milventory/src/components/Map.js +++ b/milventory/src/components/Map.js @@ -4,6 +4,7 @@ import MasterItemPreview from './MasterItemPreview'; import ArrowConnections from './ArrowConnections'; import BoxInventoryOverlay from './BoxInventoryOverlay'; import AddModeArrow from './AddModeArrow'; +import MoveModeBoxes from './MoveModeBoxes'; const SHELF_NAMES = [ 'Shelf 6 (Top)', @@ -15,7 +16,7 @@ const SHELF_NAMES = [ ]; const MapComponent = forwardRef((props, ref) => { - const { worldRef, inventoryData, inventoryBounds, selectedBox, currentDragOverBox, handleBoxClick, handleBoxHover, handleBoxHoverLeave, handleDrop, setCurrentDragOverBox, addModeItem, addModePending, handleBoxClickAddMode, boxHasAnyPending, selectedMasterItem, getItemLocations } = useInventory(); + const { worldRef, inventoryData, inventoryBounds, selectedBox, currentDragOverBox, handleBoxClick, handleBoxHover, handleBoxHoverLeave, handleDrop, setCurrentDragOverBox, addModeItem, addModePending, handleBoxClickAddMode, boxHasAnyPending, selectedMasterItem, getItemLocations, moveModeItem, moveModeDragging, handleMoveModeDrop } = useInventory(); // Compute highlighted box set from selected Master item (for React-managed className) const highlightedBoxes = selectedMasterItem ? new Set(getItemLocations(selectedMasterItem)) : null; @@ -33,16 +34,30 @@ const MapComponent = forwardRef((props, ref) => { const handleDragEnter = (e, boxTitle) => { e.preventDefault(); - if (boxTitle !== currentDragOverBox) { - setCurrentDragOverBox(boxTitle); + if (moveModeItem && moveModeDragging) { + // In move mode, track drag over for move boxes + if (boxTitle !== currentDragOverBox) { + setCurrentDragOverBox(boxTitle); + } + } else { + if (boxTitle !== currentDragOverBox) { + setCurrentDragOverBox(boxTitle); + } } }; const handleDragOver = (e, boxTitle) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; - if (boxTitle !== currentDragOverBox) { - setCurrentDragOverBox(boxTitle); + if (moveModeItem && moveModeDragging) { + // In move mode, track drag over for move boxes + if (boxTitle !== currentDragOverBox) { + setCurrentDragOverBox(boxTitle); + } + } else { + if (boxTitle !== currentDragOverBox) { + setCurrentDragOverBox(boxTitle); + } } }; @@ -65,8 +80,35 @@ const MapComponent = forwardRef((props, ref) => { const handleDropBox = (e, boxTitle) => { e.preventDefault(); e.stopPropagation(); - handleDrop(boxTitle); - setCurrentDragOverBox(null); + if (moveModeItem && moveModeDragging) { + // In move mode, handle drop for moving items + // For Tall Cabinets, we need to determine which shelf was dropped on + const boxData = inventoryData.get(boxTitle); + let targetShelf = undefined; + + if (boxData && boxTitle.startsWith('Tall Cabinet') && worldRef.current) { + // Calculate which shelf based on mouse position + const svg = e.currentTarget.ownerSVGElement; + if (svg) { + const pt = svg.createSVGPoint(); + pt.x = e.clientX; + pt.y = e.clientY; + const ctm = worldRef.current.getScreenCTM(); + if (ctm) { + const worldPt = pt.matrixTransform(ctm.inverse()); + const shelfH = boxData.height / SHELF_NAMES.length; + const relativeY = worldPt.y - boxData.y; + targetShelf = Math.max(0, Math.min(SHELF_NAMES.length - 1, Math.floor(relativeY / shelfH))); + } + } + } + + handleMoveModeDrop(boxTitle, targetShelf); + setCurrentDragOverBox(null); + } else { + handleDrop(boxTitle); + setCurrentDragOverBox(null); + } }; const boxes = Array.from(inventoryData.values()); @@ -181,6 +223,7 @@ const MapComponent = forwardRef((props, ref) => { + diff --git a/milventory/src/components/MasterItemPreview.js b/milventory/src/components/MasterItemPreview.js index f46f9f9..0680184 100644 --- a/milventory/src/components/MasterItemPreview.js +++ b/milventory/src/components/MasterItemPreview.js @@ -12,6 +12,9 @@ const MasterItemPreview = () => { clearSelectedMasterItem, deleteMasterItem, startAddMode, + startMoveMode, + cancelMoveMode, + moveModeItem, leftPaneWidth, leftPaneCollapsed } = useInventory(); @@ -87,9 +90,15 @@ const MasterItemPreview = () => { startAddMode(selectedMasterItem); }; + const handleMove = () => { + startMoveMode(selectedMasterItem); + }; + const handleEdit = () => { setEditingItem(selectedMasterItem); }; + + const isInMoveMode = moveModeItem === selectedMasterItem; if (!selectedMasterItem || !item) return null; @@ -200,15 +209,26 @@ const MasterItemPreview = () => {
Actions:
- - - + {isInMoveMode ? ( + + ) : ( + <> + + + + + + )}
diff --git a/milventory/src/components/MoveModeBoxes.js b/milventory/src/components/MoveModeBoxes.js new file mode 100644 index 0000000..a68615c --- /dev/null +++ b/milventory/src/components/MoveModeBoxes.js @@ -0,0 +1,270 @@ +import React, { useEffect, useRef } from 'react'; +import { useInventory } from '../context/InventoryContext'; + +const SHELF_NAMES = [ + 'Shelf 6 (Top)', + 'Shelf 5', + 'Shelf 4', + 'Shelf 3', + 'Shelf 2', + 'Shelf 1 (Bottom)' +]; + +const MoveModeBoxes = () => { + const { + moveModeItem, + inventoryData, + moveModeDragging, + handleMoveModeDragStart, + handleMoveModeDragMove, + handleMoveModeDrop, + clearMoveModeDragging, + currentDragOverBox, + svgRef, + worldRef, + isDraggingMoveBoxRef + } = useInventory(); + + const dragStartRef = useRef(null); + + // Track mouse position during drag + useEffect(() => { + if (!moveModeDragging) { + dragStartRef.current = null; + return; + } + + const handleMouseMove = (e) => { + if (!svgRef.current || !worldRef.current || !moveModeDragging) return; + + const svg = svgRef.current; + const pt = svg.createSVGPoint(); + pt.x = e.clientX; + pt.y = e.clientY; + + const ctm = worldRef.current.getScreenCTM(); + if (!ctm) return; + + const worldPt = pt.matrixTransform(ctm.inverse()); + handleMoveModeDragMove(worldPt.x - 30, worldPt.y - 30); // Offset by half box size + }; + + const handleMouseUp = (e) => { + if (!moveModeDragging || !dragStartRef.current) return; + + // Check if we're over an inventory box + const svg = svgRef.current; + if (!svg || !worldRef.current) { + // Reset to original position + clearMoveModeDragging(); + return; + } + + const pt = svg.createSVGPoint(); + pt.x = e.clientX; + pt.y = e.clientY; + const ctm = worldRef.current.getScreenCTM(); + if (!ctm) { + clearMoveModeDragging(); + return; + } + + const worldPt = pt.matrixTransform(ctm.inverse()); + + // Find which inventory box we're over + let targetBox = null; + let targetShelf = undefined; + + for (const [boxTitle, boxData] of inventoryData.entries()) { + if (worldPt.x >= boxData.x && worldPt.x <= boxData.x + boxData.width && + worldPt.y >= boxData.y && worldPt.y <= boxData.y + boxData.height) { + targetBox = boxTitle; + + // If it's a Tall Cabinet, determine shelf + if (boxTitle.startsWith('Tall Cabinet')) { + const shelfH = boxData.height / SHELF_NAMES.length; + const relativeY = worldPt.y - boxData.y; + targetShelf = Math.max(0, Math.min(SHELF_NAMES.length - 1, Math.floor(relativeY / shelfH))); + } + break; + } + } + + // Check if dropped on a different location (different box or different shelf of same box) + const isDifferentLocation = targetBox && ( + targetBox !== dragStartRef.current.boxTitle || + (targetBox === dragStartRef.current.boxTitle && targetShelf !== dragStartRef.current.shelf) + ); + + if (isDifferentLocation) { + // Dropped on a different location - move the item + handleMoveModeDrop(targetBox, targetShelf); + } else { + // Dropped outside or on same location - reset to original position + clearMoveModeDragging(); + } + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [moveModeDragging, svgRef, worldRef, handleMoveModeDragMove, handleMoveModeDrop, clearMoveModeDragging, inventoryData]); + + if (!moveModeItem) return null; + + const boxes = Array.from(inventoryData.values()); + const moveBoxes = []; + + boxes.forEach(box => { + const boxData = inventoryData.get(box.title); + if (!boxData) return; + + const matchingItems = boxData.inventory.filter(item => item.name === moveModeItem); + if (matchingItems.length === 0) return; + + const isTallCabinet = box.title.startsWith('Tall Cabinet'); + + if (isTallCabinet) { + // For Tall Cabinets, show a box per shelf + matchingItems.forEach(item => { + const shelfIdx = item.shelf ?? 0; + const shelfH = box.height / SHELF_NAMES.length; + const shelfY = box.y + shelfIdx * shelfH; + + // Position box in center of shelf + const boxSize = Math.min(shelfH * 0.6, box.width * 0.4, 60); + const boxX = box.x + (box.width - boxSize) / 2; + const boxY = shelfY + (shelfH - boxSize) / 2; + + const isDragging = moveModeDragging && + moveModeDragging.boxTitle === box.title && + moveModeDragging.shelf === shelfIdx; + const isDragOver = currentDragOverBox === box.title; + + moveBoxes.push({ + key: `${box.title}||${shelfIdx}`, + x: boxX, + y: boxY, + width: boxSize, + height: boxSize, + qty: item.qty, + boxTitle: box.title, + shelf: shelfIdx, + isDragging, + isDragOver + }); + }); + } else { + // For regular boxes, show one box with total quantity + const totalQty = matchingItems.reduce((sum, item) => sum + (item.qty || 0), 0); + + // Position box in center + const boxSize = Math.min(box.height * 0.5, box.width * 0.4, 60); + const boxX = box.x + (box.width - boxSize) / 2; + const boxY = box.y + (box.height - boxSize) / 2; + + const isDragging = moveModeDragging && + moveModeDragging.boxTitle === box.title && + moveModeDragging.shelf === undefined; + const isDragOver = currentDragOverBox === box.title; + + moveBoxes.push({ + key: box.title, + x: boxX, + y: boxY, + width: boxSize, + height: boxSize, + qty: totalQty, + boxTitle: box.title, + shelf: undefined, + isDragging, + isDragOver + }); + } + }); + + const handleMouseDown = (e, boxTitle, shelf, qty, x, y) => { + // Prevent D3 zoom from starting + e.preventDefault(); + e.stopPropagation(); + // Set dragging state immediately so D3 filter can see it + isDraggingMoveBoxRef.current = true; + dragStartRef.current = { boxTitle, shelf, qty, x, y }; + handleMoveModeDragStart(boxTitle, shelf, qty, x, y); + }; + + return ( + + {moveBoxes.map(moveBox => { + if (moveBox.isDragging) return null; // Don't render the dragging box + + return ( + + handleMouseDown(e, moveBox.boxTitle, moveBox.shelf, moveBox.qty, moveBox.x, moveBox.y)} + /> + + {moveBox.qty} + + + ); + })} + {/* Render dragging box at cursor position */} + {moveModeDragging && ( + + + + {moveModeDragging.qty} + + + )} + + ); +}; + +export default MoveModeBoxes; + diff --git a/milventory/src/context/InventoryContext.js b/milventory/src/context/InventoryContext.js index 50642e2..4580860 100644 --- a/milventory/src/context/InventoryContext.js +++ b/milventory/src/context/InventoryContext.js @@ -48,6 +48,12 @@ export const InventoryProvider = ({ children }) => { const addModePendingRef = useRef(new Map()); const addModeQtyPerClickRef = useRef(1); + // Move Mode state + const [moveModeItem, setMoveModeItem] = useState(null); + const [moveModeDragging, setMoveModeDragging] = useState(null); // { boxTitle, shelf, qty, x, y } + const moveModeItemRef = useRef(null); + const isDraggingMoveBoxRef = useRef(false); // Synchronous ref for D3 filter + // Keep refs in sync with state useEffect(() => { addModeItemRef.current = addModeItem; @@ -61,6 +67,10 @@ export const InventoryProvider = ({ children }) => { addModeQtyPerClickRef.current = addModeQtyPerClick; }, [addModeQtyPerClick]); + useEffect(() => { + moveModeItemRef.current = moveModeItem; + }, [moveModeItem]); + // Refs const wrapRef = useRef(null); const svgRef = useRef(null); @@ -175,6 +185,13 @@ export const InventoryProvider = ({ children }) => { const zoom = d3.zoom() .scaleExtent([0.6, 6]) + .filter((event) => { + // Disable zoom/pan when dragging a move box + if (isDraggingMoveBoxRef.current) return false; + // Check if the event target is a move box + if (event.target && event.target.dataset && event.target.dataset.moveBox) return false; + return true; + }) .on('start', () => { isPanningRef.current = true; }) @@ -507,6 +524,141 @@ export const InventoryProvider = ({ children }) => { setAddModePending(new Map()); }, []); + // Move Mode functions + const startMoveMode = useCallback((itemName) => { + setMoveModeItem(itemName); + setMoveModeDragging(null); + setSelectedBox(null); // Clear box selection when entering move mode + }, []); + + const cancelMoveMode = useCallback(() => { + setMoveModeItem(null); + setMoveModeDragging(null); + setCurrentDragOverBox(null); + isDraggingMoveBoxRef.current = false; + }, []); + + const clearMoveModeDragging = useCallback(() => { + setMoveModeDragging(null); + setCurrentDragOverBox(null); + isDraggingMoveBoxRef.current = false; + }, []); + + const handleMoveModeDragStart = useCallback((boxTitle, shelf, qty, x, y) => { + isDraggingMoveBoxRef.current = true; + setMoveModeDragging({ boxTitle, shelf, qty, x, y, originalX: x, originalY: y }); + }, []); + + const handleMoveModeDragMove = useCallback((x, y) => { + if (moveModeDragging) { + setMoveModeDragging(prev => ({ ...prev, x, y })); + } + }, [moveModeDragging]); + + const handleMoveModeDrop = useCallback(async (targetBoxTitle, targetShelf) => { + if (!moveModeDragging || !moveModeItemRef.current) return; + + const { boxTitle: sourceBoxTitle, shelf: sourceShelf, qty } = moveModeDragging; + + // Don't allow dropping on the same location + if (sourceBoxTitle === targetBoxTitle && sourceShelf === targetShelf) { + setMoveModeDragging(null); + return; + } + + const supplyId = supplyNameToId.get(moveModeItemRef.current); + if (!supplyId) { + console.error(`Supply ID not found for item: ${moveModeItemRef.current}`); + setError(`Supply ID not found for item: ${moveModeItemRef.current}`); + setMoveModeDragging(null); + return; + } + + try { + await api.moveSupplyLocations({ + from_location: sourceBoxTitle, + to_location: targetBoxTitle, + supply_id: supplyId, + shelf_from: sourceShelf !== undefined ? sourceShelf : null, + shelf_to: targetShelf !== undefined ? targetShelf : null, + amount: qty + }); + + // Update local state optimistically + const sourceBoxData = inventoryData.get(sourceBoxTitle); + const targetBoxData = inventoryData.get(targetBoxTitle); + + if (!sourceBoxData || !targetBoxData) { + setMoveModeDragging(null); + return; + } + + // Remove from source - find the exact item and subtract qty + const newSourceInventory = []; + let foundSource = false; + + for (const item of sourceBoxData.inventory) { + if (item.name === moveModeItemRef.current) { + const itemShelf = item.shelf ?? 0; + const sourceShelfValue = sourceShelf ?? 0; + + if (itemShelf === sourceShelfValue && !foundSource) { + // This is the source item - subtract qty + foundSource = true; + const newQty = item.qty - qty; + if (newQty > 0) { + // Keep item with reduced qty + newSourceInventory.push({ ...item, qty: newQty }); + } + // If newQty <= 0, don't add it (effectively removing it) + } else { + // Different shelf or already found - keep as is + newSourceInventory.push(item); + } + } else { + // Different item - keep as is + newSourceInventory.push(item); + } + } + + // Add to target (combine if exists) + const newTargetInventory = [...targetBoxData.inventory]; + const existingIndex = newTargetInventory.findIndex(item => { + if (item.name !== moveModeItemRef.current) return false; + if (targetShelf !== undefined) return (item.shelf ?? 0) === targetShelf; + return item.shelf === undefined; + }); + + if (existingIndex >= 0) { + newTargetInventory[existingIndex] = { + ...newTargetInventory[existingIndex], + qty: newTargetInventory[existingIndex].qty + qty + }; + } else { + const newItem = { name: moveModeItemRef.current, qty }; + if (targetShelf !== undefined) newItem.shelf = targetShelf; + newTargetInventory.push(newItem); + } + + setInventoryData(prev => { + const next = new Map(prev); + next.set(sourceBoxTitle, { ...sourceBoxData, inventory: newSourceInventory }); + next.set(targetBoxTitle, { ...targetBoxData, inventory: newTargetInventory }); + return next; + }); + + setMoveModeDragging(null); + isDraggingMoveBoxRef.current = false; + } catch (error) { + console.error('Error moving item:', error); + if (!isPanningRef.current) { + setError(error.message || 'Failed to move item'); + } + setMoveModeDragging(null); + isDraggingMoveBoxRef.current = false; + } + }, [moveModeDragging, inventoryData, supplyNameToId]); + const handleDragStart = useCallback((boxTitle, index, isMultiple, selectedIndices) => { const boxData = inventoryData.get(boxTitle); if (!boxData) return; @@ -869,6 +1021,16 @@ export const InventoryProvider = ({ children }) => { cancelAddMode, handleBoxClickAddMode, boxHasAnyPending, + // Move Mode + moveModeItem, + moveModeDragging, + startMoveMode, + cancelMoveMode, + clearMoveModeDragging, + handleMoveModeDragStart, + handleMoveModeDragMove, + handleMoveModeDrop, + isDraggingMoveBoxRef, }; return ( From 57a93f1ec2e7d958bb05ebf131c7b029beed6951 Mon Sep 17 00:00:00 2001 From: willzoo Date: Sun, 1 Mar 2026 10:40:09 -0500 Subject: [PATCH 13/73] Move working --- milventory/src/components/Map.js | 13 +++++++------ milventory/src/components/MasterItemPreview.js | 16 ++++++++++++---- milventory/src/context/InventoryContext.js | 16 +++++++++++++--- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/milventory/src/components/Map.js b/milventory/src/components/Map.js index 8bc1868..3da1b52 100644 --- a/milventory/src/components/Map.js +++ b/milventory/src/components/Map.js @@ -126,8 +126,8 @@ const MapComponent = forwardRef((props, ref) => { ry: 18 }; - // All Tall Cabinets get shelf overlays in add mode - const tallCabinets = addModeItem + // All Tall Cabinets get shelf overlays in add mode or move mode + const tallCabinets = (addModeItem || moveModeItem) ? boxes.filter(b => b.title.startsWith('Tall Cabinet')) : []; @@ -167,7 +167,7 @@ const MapComponent = forwardRef((props, ref) => { /> ))} - {/* Shelf overlays for all Tall Cabinets in add mode — always visible */} + {/* Shelf overlays for all Tall Cabinets in add mode or move mode */} {tallCabinets.map(box => { const shelfH = box.height / SHELF_NAMES.length; return ( @@ -175,7 +175,7 @@ const MapComponent = forwardRef((props, ref) => { {SHELF_NAMES.map((name, idx) => { const shelfY = box.y + idx * shelfH; const pendingKey = `${box.title}||${idx}`; - const isAffected = addModePending.has(pendingKey); + const isAffected = addModeItem && addModePending.has(pendingKey); const pendingQty = addModePending.get(pendingKey); return ( @@ -186,10 +186,11 @@ const MapComponent = forwardRef((props, ref) => { y={shelfY} width={box.width} height={shelfH} - onClick={(e) => { + onClick={addModeItem ? (e) => { e.stopPropagation(); handleBoxClickAddMode(box.title, idx); - }} + } : undefined} + style={moveModeItem ? { pointerEvents: 'none' } : undefined} /> {

{item.name}

+ <> + + + ) : ( <>
Position & Size @@ -281,8 +281,8 @@ const AddLocationModal = ({ isOpen, onClose, onSuccess, initialBox, leftPaneWidt
Top Y
- handleEdgeChange('topY', e.target.value)} @@ -302,8 +302,8 @@ const AddLocationModal = ({ isOpen, onClose, onSuccess, initialBox, leftPaneWidt
Bottom Y
- handleEdgeChange('bottomY', e.target.value)} @@ -317,14 +317,14 @@ const AddLocationModal = ({ isOpen, onClose, onSuccess, initialBox, leftPaneWidt borderRadius: '4px', color: 'var(--text)' }} - /> -
+ /> +
Left X
- handleEdgeChange('leftX', e.target.value)} @@ -344,8 +344,8 @@ const AddLocationModal = ({ isOpen, onClose, onSuccess, initialBox, leftPaneWidt
Right X
- handleEdgeChange('rightX', e.target.value)} @@ -359,33 +359,33 @@ const AddLocationModal = ({ isOpen, onClose, onSuccess, initialBox, leftPaneWidt borderRadius: '4px', color: 'var(--text)' }} - /> + />
- - + + > + {submitting ? 'Creating...' : 'Create Location'} +
diff --git a/milventory/src/components/ArrowConnections.js b/milventory/src/components/ArrowConnections.js index 80912df..0b1afb0 100644 --- a/milventory/src/components/ArrowConnections.js +++ b/milventory/src/components/ArrowConnections.js @@ -121,12 +121,50 @@ const ArrowConnections = () => { path.moveTo(previewX, previewY); path.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, boxX, boxY); - // Arrowhead - const angle = Math.atan2(dy, dx); + // Arrowhead - calculate angle from curve tangent at endpoint + // For a cubic Bezier P(t), the derivative at t=1 is: P'(1) = 3(P₃ - P₂) + // However, to get the visual direction of the curve as it enters the box, + // we sample two points very close to the endpoint to get the actual curve direction + const t1 = 0.95; // Sample point before endpoint + const t2 = 1.0; // Endpoint + + // Cubic Bezier evaluation: P(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃ + const evalBezier = (t, p0, p1, p2, p3) => { + const mt = 1 - t; + const mt2 = mt * mt; + const mt3 = mt2 * mt; + const t2 = t * t; + const t3 = t2 * t; + return mt3 * p0 + 3 * mt2 * t * p1 + 3 * mt * t2 * p2 + t3 * p3; + }; + + // Sample the curve at t1 and t2 + const x1 = evalBezier(t1, previewX, cp1x, cp2x, boxX); + const y1 = evalBezier(t1, previewY, cp1y, cp2y, boxY); + const x2 = boxX; // t2 = 1.0, endpoint + const y2 = boxY; + + // Calculate the direction vector from sampled point to endpoint + const tangentDx = x2 - x1; + const tangentDy = y2 - y1; + + // Calculate angle from this direction vector + // If the vector is too small (degenerate case), fall back to derivative formula + const tangentLength = Math.sqrt(tangentDx * tangentDx + tangentDy * tangentDy); + let angle; + if (tangentLength < 0.001) { + // Degenerate case: use the derivative formula P'(1) = 3(P₃ - P₂) + const derivDx = 3 * (boxX - cp2x); + const derivDy = 3 * (boxY - cp2y); + angle = Math.atan2(derivDy, derivDx); + } else { + // Use the sampled direction + angle = Math.atan2(tangentDy, tangentDx); + } const arrowLength = 12; const arrowAngle = Math.PI / 6; - const arrowX = boxX - Math.cos(angle) * 20; - const arrowY = boxY - Math.sin(angle) * 20; + const arrowX = boxX; + const arrowY = boxY; path.moveTo(arrowX, arrowY); path.lineTo( diff --git a/src/scripts/seed_data.py b/src/scripts/seed_data.py index d1af7d7..e168c44 100644 --- a/src/scripts/seed_data.py +++ b/src/scripts/seed_data.py @@ -292,16 +292,16 @@ def seed_locations(): update_count += 1 except mysql.connector.Error as e2: if 'Unknown column' in str(e2): - cur.execute( - "UPDATE locations SET type = %s, shelf_count = %s WHERE name = %s", - (location_type, shelf_count, name) - ) - if cur.rowcount > 0: - update_count += 1 - else: - raise + cur.execute( + "UPDATE locations SET type = %s, shelf_count = %s WHERE name = %s", + (location_type, shelf_count, name) + ) + if cur.rowcount > 0: + update_count += 1 else: raise + else: + raise else: # Insert new location with coordinates - set protected=True for locations from JSON try: @@ -322,10 +322,10 @@ def seed_locations(): except mysql.connector.Error as e2: # If coordinate columns don't exist, insert without them if 'Unknown column' in str(e2): - cur.execute( - "INSERT INTO locations (name, type, shelf_count) VALUES (%s, %s, %s)", - (name, location_type, shelf_count) - ) + cur.execute( + "INSERT INTO locations (name, type, shelf_count) VALUES (%s, %s, %s)", + (name, location_type, shelf_count) + ) else: raise else: From f79ed1575d16ec4c9cf3868994d9084e4441a84f Mon Sep 17 00:00:00 2001 From: willzoo Date: Sun, 1 Mar 2026 13:00:31 -0500 Subject: [PATCH 15/73] show +X on non shelves on add mode --- milventory/src/components/AddModeArrow.js | 45 +++++++++++-- milventory/src/components/Map.js | 78 ++++++++++++++--------- 2 files changed, 90 insertions(+), 33 deletions(-) diff --git a/milventory/src/components/AddModeArrow.js b/milventory/src/components/AddModeArrow.js index 126ffbb..5d48556 100644 --- a/milventory/src/components/AddModeArrow.js +++ b/milventory/src/components/AddModeArrow.js @@ -51,12 +51,49 @@ const AddModeArrow = () => { path.moveTo(previewX, previewY); path.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, mx, my); - // Arrowhead - const angle = Math.atan2(dy, dx); + // Arrowhead - calculate angle from curve tangent at endpoint + // Sample the curve near the endpoint to get the actual curve direction + const t1 = 0.95; // Sample point before endpoint + const t2 = 1.0; // Endpoint (mouse position) + + // Cubic Bezier evaluation: P(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃ + const evalBezier = (t, p0, p1, p2, p3) => { + const mt = 1 - t; + const mt2 = mt * mt; + const mt3 = mt2 * mt; + const t2 = t * t; + const t3 = t2 * t; + return mt3 * p0 + 3 * mt2 * t * p1 + 3 * mt * t2 * p2 + t3 * p3; + }; + + // Sample the curve at t1 and t2 + const x1 = evalBezier(t1, previewX, cp1x, cp2x, mx); + const y1 = evalBezier(t1, previewY, cp1y, cp2y, my); + const x2 = mx; // t2 = 1.0, endpoint (mouse position) + const y2 = my; + + // Calculate the direction vector from sampled point to endpoint + const tangentDx = x2 - x1; + const tangentDy = y2 - y1; + + // Calculate angle from this direction vector + // If the vector is too small (degenerate case), fall back to derivative formula + const tangentLength = Math.sqrt(tangentDx * tangentDx + tangentDy * tangentDy); + let angle; + if (tangentLength < 0.001) { + // Degenerate case: use the derivative formula P'(1) = 3(P₃ - P₂) + const derivDx = 3 * (mx - cp2x); + const derivDy = 3 * (my - cp2y); + angle = Math.atan2(derivDy, derivDx); + } else { + // Use the sampled direction + angle = Math.atan2(tangentDy, tangentDx); + } + const arrowLength = 12; const arrowAngle = Math.PI / 6; - const arrowX = mx - Math.cos(angle) * 20; - const arrowY = my - Math.sin(angle) * 20; + const arrowX = mx; + const arrowY = my; path.moveTo(arrowX, arrowY); path.lineTo( diff --git a/milventory/src/components/Map.js b/milventory/src/components/Map.js index 3da1b52..8c7224b 100644 --- a/milventory/src/components/Map.js +++ b/milventory/src/components/Map.js @@ -137,35 +137,55 @@ const MapComponent = forwardRef((props, ref) => { - {boxes.map((box, idx) => ( - { - e.stopPropagation(); - if (addModeItem) { - // For Tall Cabinets, shelf rects on top handle clicks - if (!box.title.startsWith('Tall Cabinet')) { - handleBoxClickAddMode(box.title); - } - } else { - handleBoxClick(box.title); - } - }} - onMouseEnter={(e) => handleBoxMouseEnter(e, box.title)} - onMouseLeave={handleBoxHoverLeave} - onDragEnter={(e) => handleDragEnter(e, box.title)} - onDragOver={(e) => handleDragOver(e, box.title)} - onDragLeave={(e) => handleDragLeave(e, box.title)} - onDrop={(e) => handleDropBox(e, box.title)} - /> - ))} + {boxes.map((box, idx) => { + // For regular boxes (not Tall Cabinets), check if they have pending items + const isRegularBox = !box.title.startsWith('Tall Cabinet'); + const hasPending = isRegularBox && addModeItem && addModePending.has(box.title); + const pendingQty = hasPending ? addModePending.get(box.title) : null; + + return ( + + { + e.stopPropagation(); + if (addModeItem) { + // For Tall Cabinets, shelf rects on top handle clicks + if (!box.title.startsWith('Tall Cabinet')) { + handleBoxClickAddMode(box.title); + } + } else { + handleBoxClick(box.title); + } + }} + onMouseEnter={(e) => handleBoxMouseEnter(e, box.title)} + onMouseLeave={handleBoxHoverLeave} + onDragEnter={(e) => handleDragEnter(e, box.title)} + onDragOver={(e) => handleDragOver(e, box.title)} + onDragLeave={(e) => handleDragLeave(e, box.title)} + onDrop={(e) => handleDropBox(e, box.title)} + /> + {hasPending && ( + + +{pendingQty} + + )} + + ); + })} {/* Shelf overlays for all Tall Cabinets in add mode or move mode */} {tallCabinets.map(box => { From 171798f031293f262b89b2a087db6a148858c5ce Mon Sep 17 00:00:00 2001 From: willzoo Date: Sun, 1 Mar 2026 21:33:11 -0500 Subject: [PATCH 16/73] Fix delete not applying, other minor things --- .../src/components/DeleteModePreview.js | 146 +++++++++ milventory/src/components/Map.js | 105 ++++++- .../src/components/MasterItemPreview.js | 16 +- milventory/src/context/InventoryContext.js | 294 +++++++++++++++--- milventory/src/index.css | 3 - src/api/models/category.py | 2 + src/api/models/team.py | 2 + src/api/routes/migrate.py | 2 + src/api/routes/teams.py | 2 + src/sql/categories/table_categories.sql | 2 + src/sql/location/migrate_add_coordinates.sql | 2 + .../table_supplies_categories.sql | 2 + 12 files changed, 520 insertions(+), 58 deletions(-) create mode 100644 milventory/src/components/DeleteModePreview.js diff --git a/milventory/src/components/DeleteModePreview.js b/milventory/src/components/DeleteModePreview.js new file mode 100644 index 0000000..ea742eb --- /dev/null +++ b/milventory/src/components/DeleteModePreview.js @@ -0,0 +1,146 @@ +import React from 'react'; +import { useInventory } from '../context/InventoryContext'; + +const DeleteModePreview = () => { + const { + deleteModeItem, + deleteModeQtyPerClick, + setDeleteModeQtyPerClick, + deleteModePending, + finishDeleteMode, + cancelDeleteMode, + leftPaneWidth, + leftPaneCollapsed, + resolveMasterItem, + deleteModePreviewRef + } = useInventory(); + + const item = deleteModeItem ? resolveMasterItem(deleteModeItem) : null; + + // Calculate position to the right of left pane + const leftPaneActualWidth = leftPaneCollapsed ? 40 : leftPaneWidth; + const positionX = leftPaneActualWidth + 20; + const positionY = 20; + + const handleQtyChange = (delta) => { + setDeleteModeQtyPerClick(prev => Math.max(1, prev + delta)); + }; + + const handleFinish = () => { + finishDeleteMode(); + }; + + const handleCancel = () => { + cancelDeleteMode(); + }; + + if (!deleteModeItem || !item) return null; + + const SHELF_NAMES = [ + 'Shelf 6 (Top)', + 'Shelf 5', + 'Shelf 4', + 'Shelf 3', + 'Shelf 2', + 'Shelf 1 (Bottom)' + ]; + + const pendingCount = Array.from(deleteModePending.values()).reduce((sum, qty) => sum + qty, 0); + const pendingEntries = Array.from(deleteModePending.entries()); + + // Format a pending key for display + const formatPendingKey = (key) => { + const parts = key.split('||'); + if (parts.length > 1) { + const shelfIdx = parseInt(parts[1], 10); + return `${parts[0]} → ${SHELF_NAMES[shelfIdx] || `Shelf ${shelfIdx}`}`; + } + return parts[0]; + }; + + return ( +
+
+
+

Delete: {item.name}

+ +
+ +
+ +
+ + { + const val = parseInt(e.target.value) || 1; + setDeleteModeQtyPerClick(Math.max(1, val)); + }} + min="1" + /> + +
+
+ + {pendingEntries.length > 0 && ( +
+ Pending deletions: +
    + {pendingEntries.map(([key, qty]) => ( +
  • + {formatPendingKey(key)}: -{qty} +
  • + ))} +
+
+ Total: {pendingCount} items +
+
+ )} + +
+ + +
+
+
+ ); +}; + +export default DeleteModePreview; + + + diff --git a/milventory/src/components/Map.js b/milventory/src/components/Map.js index 8c7224b..1afbabc 100644 --- a/milventory/src/components/Map.js +++ b/milventory/src/components/Map.js @@ -5,6 +5,7 @@ import ArrowConnections from './ArrowConnections'; import BoxInventoryOverlay from './BoxInventoryOverlay'; import AddModeArrow from './AddModeArrow'; import MoveModeBoxes from './MoveModeBoxes'; +import DeleteModePreview from './DeleteModePreview'; const SHELF_NAMES = [ 'Shelf 6 (Top)', @@ -16,10 +17,13 @@ const SHELF_NAMES = [ ]; const MapComponent = forwardRef((props, ref) => { - const { worldRef, inventoryData, inventoryBounds, selectedBox, currentDragOverBox, handleBoxClick, handleBoxHover, handleBoxHoverLeave, handleDrop, setCurrentDragOverBox, addModeItem, addModePending, handleBoxClickAddMode, boxHasAnyPending, selectedMasterItem, getItemLocations, moveModeItem, moveModeDragging, handleMoveModeDrop } = useInventory(); + const { worldRef, inventoryData, inventoryBounds, selectedBox, currentDragOverBox, handleBoxClick, handleBoxHover, handleBoxHoverLeave, handleDrop, setCurrentDragOverBox, addModeItem, addModePending, handleBoxClickAddMode, boxHasAnyPending, selectedMasterItem, getItemLocations, moveModeItem, moveModeDragging, handleMoveModeDrop, deleteModeItem, deleteModePending, handleBoxClickDeleteMode, boxHasAnyDeletePending } = useInventory(); // Compute highlighted box set from selected Master item (for React-managed className) const highlightedBoxes = selectedMasterItem ? new Set(getItemLocations(selectedMasterItem)) : null; + + // Compute highlighted box set for delete mode (all boxes containing the item) + const deleteModeHighlightedBoxes = deleteModeItem ? new Set(getItemLocations(deleteModeItem)) : null; const handleBoxMouseEnter = (e, boxTitle) => { const rect = e.currentTarget.getBoundingClientRect(); @@ -126,8 +130,8 @@ const MapComponent = forwardRef((props, ref) => { ry: 18 }; - // All Tall Cabinets get shelf overlays in add mode or move mode - const tallCabinets = (addModeItem || moveModeItem) + // All Tall Cabinets get shelf overlays in add mode, delete mode, or move mode + const tallCabinets = (addModeItem || deleteModeItem || moveModeItem) ? boxes.filter(b => b.title.startsWith('Tall Cabinet')) : []; @@ -140,13 +144,28 @@ const MapComponent = forwardRef((props, ref) => { {boxes.map((box, idx) => { // For regular boxes (not Tall Cabinets), check if they have pending items const isRegularBox = !box.title.startsWith('Tall Cabinet'); - const hasPending = isRegularBox && addModeItem && addModePending.has(box.title); - const pendingQty = hasPending ? addModePending.get(box.title) : null; + const hasAddPending = isRegularBox && addModeItem && addModePending.has(box.title); + const addPendingQty = hasAddPending ? addModePending.get(box.title) : null; + + // For delete mode, get current quantity, deleted quantity, and remaining + const hasDeletePending = isRegularBox && deleteModeItem && deleteModePending.has(box.title); + const deletePendingQty = hasDeletePending ? deleteModePending.get(box.title) : 0; + const hasDeleteItem = isRegularBox && deleteModeItem && deleteModeHighlightedBoxes && deleteModeHighlightedBoxes.has(box.title); + let currentQty = 0; + let remainingQty = 0; + if (deleteModeItem && isRegularBox) { + const boxData = inventoryData.get(box.title); + if (boxData) { + const matchingItems = boxData.inventory.filter(item => item.name === deleteModeItem); + currentQty = matchingItems.reduce((sum, item) => sum + (item.qty || 0), 0); + remainingQty = Math.max(0, currentQty - deletePendingQty); + } + } return ( { if (!box.title.startsWith('Tall Cabinet')) { handleBoxClickAddMode(box.title); } + } else if (deleteModeItem) { + // For Tall Cabinets, shelf rects on top handle clicks + if (!box.title.startsWith('Tall Cabinet')) { + handleBoxClickDeleteMode(box.title); + } } else { handleBoxClick(box.title); } @@ -171,7 +195,19 @@ const MapComponent = forwardRef((props, ref) => { onDragLeave={(e) => handleDragLeave(e, box.title)} onDrop={(e) => handleDropBox(e, box.title)} /> - {hasPending && ( + {hasAddPending && ( + + +{addPendingQty} + + )} + {hasDeleteItem && ( { textAnchor="end" dominantBaseline="middle" pointerEvents="none" + fill={hasDeletePending ? "#ff6b6b" : "var(--accent)"} > - +{pendingQty} + {hasDeletePending ? `${currentQty} / -${deletePendingQty} / ${remainingQty}` : currentQty} )} ); })} - {/* Shelf overlays for all Tall Cabinets in add mode or move mode */} + {/* Shelf overlays for all Tall Cabinets in add mode, delete mode, or move mode */} {tallCabinets.map(box => { const shelfH = box.height / SHELF_NAMES.length; return ( @@ -195,8 +232,28 @@ const MapComponent = forwardRef((props, ref) => { {SHELF_NAMES.map((name, idx) => { const shelfY = box.y + idx * shelfH; const pendingKey = `${box.title}||${idx}`; - const isAffected = addModeItem && addModePending.has(pendingKey); - const pendingQty = addModePending.get(pendingKey); + const isAddAffected = addModeItem && addModePending.has(pendingKey); + const addPendingQty = addModePending.get(pendingKey); + + // For delete mode, get current quantity, deleted quantity, and remaining + const isDeleteAffected = deleteModeItem && deleteModePending.has(pendingKey); + const deletePendingQty = isDeleteAffected ? deleteModePending.get(pendingKey) : 0; + let currentQty = 0; + let remainingQty = 0; + let hasDeleteItemOnShelf = false; + if (deleteModeItem) { + const boxData = inventoryData.get(box.title); + if (boxData) { + const matchingItems = boxData.inventory.filter(item => + item.name === deleteModeItem && (item.shelf ?? 0) === idx + ); + currentQty = matchingItems.reduce((sum, item) => sum + (item.qty || 0), 0); + remainingQty = Math.max(0, currentQty - deletePendingQty); + hasDeleteItemOnShelf = currentQty > 0; + } + } + + const isAffected = isAddAffected || isDeleteAffected || hasDeleteItemOnShelf; return ( @@ -206,9 +263,13 @@ const MapComponent = forwardRef((props, ref) => { y={shelfY} width={box.width} height={shelfH} - onClick={addModeItem ? (e) => { + onClick={(addModeItem || deleteModeItem) ? (e) => { e.stopPropagation(); - handleBoxClickAddMode(box.title, idx); + if (addModeItem) { + handleBoxClickAddMode(box.title, idx); + } else if (deleteModeItem) { + handleBoxClickDeleteMode(box.title, idx); + } } : undefined} style={moveModeItem ? { pointerEvents: 'none' } : undefined} /> @@ -222,7 +283,19 @@ const MapComponent = forwardRef((props, ref) => { > {name} - {isAffected && ( + {isAddAffected && ( + + +{addPendingQty} + + )} + {hasDeleteItemOnShelf && ( { textAnchor="end" dominantBaseline="middle" pointerEvents="none" + fill={isDeleteAffected ? "#ff6b6b" : "var(--accent)"} > - +{pendingQty} + {isDeleteAffected ? `${currentQty} / -${deletePendingQty} / ${remainingQty}` : currentQty} )} @@ -248,6 +322,7 @@ const MapComponent = forwardRef((props, ref) => {
+ ); }); diff --git a/milventory/src/components/MasterItemPreview.js b/milventory/src/components/MasterItemPreview.js index 1a4e7f8..f85a1ab 100644 --- a/milventory/src/components/MasterItemPreview.js +++ b/milventory/src/components/MasterItemPreview.js @@ -13,6 +13,7 @@ const MasterItemPreview = () => { deleteMasterItem, startAddMode, startMoveMode, + startDeleteMode, cancelMoveMode, moveModeItem, leftPaneWidth, @@ -76,7 +77,7 @@ const MasterItemPreview = () => { const positionX = leftPaneActualWidth + 20; const positionY = 20; - const handleDelete = () => { + const handleDeleteItem = () => { if (locations.length > 0) { const confirmed = window.confirm( `This item is used in ${locations.length} box(es). Delete from all boxes?` @@ -94,6 +95,10 @@ const MasterItemPreview = () => { startMoveMode(selectedMasterItem); }; + const handleDelete = () => { + startDeleteMode(selectedMasterItem); + }; + const handleEdit = () => { setEditingItem(selectedMasterItem); }; @@ -224,7 +229,10 @@ const MasterItemPreview = () => { ) : ( <> + - )} diff --git a/milventory/src/context/InventoryContext.js b/milventory/src/context/InventoryContext.js index eaa6a04..5a94e9f 100644 --- a/milventory/src/context/InventoryContext.js +++ b/milventory/src/context/InventoryContext.js @@ -48,6 +48,15 @@ export const InventoryProvider = ({ children }) => { const addModePendingRef = useRef(new Map()); const addModeQtyPerClickRef = useRef(1); + // Delete Mode state + const [deleteModeItem, setDeleteModeItem] = useState(null); + const [deleteModeQtyPerClick, setDeleteModeQtyPerClick] = useState(1); + const [deleteModePending, setDeleteModePending] = useState(new Map()); // Map + const deleteModePreviewRef = useRef(null); + const deleteModeItemRef = useRef(null); + const deleteModePendingRef = useRef(new Map()); + const deleteModeQtyPerClickRef = useRef(1); + // Move Mode state const [moveModeItem, setMoveModeItem] = useState(null); const [moveModeDragging, setMoveModeDragging] = useState(null); // { boxTitle, shelf, qty, x, y } @@ -66,6 +75,18 @@ export const InventoryProvider = ({ children }) => { useEffect(() => { addModeQtyPerClickRef.current = addModeQtyPerClick; }, [addModeQtyPerClick]); + + useEffect(() => { + deleteModeItemRef.current = deleteModeItem; + }, [deleteModeItem]); + + useEffect(() => { + deleteModePendingRef.current = deleteModePending; + }, [deleteModePending]); + + useEffect(() => { + deleteModeQtyPerClickRef.current = deleteModeQtyPerClick; + }, [deleteModeQtyPerClick]); useEffect(() => { moveModeItemRef.current = moveModeItem; @@ -89,6 +110,45 @@ export const InventoryProvider = ({ children }) => { return typeFills[type] || 'var(--table)'; }; + // Function to reload supply locations from API + const reloadSupplyLocations = useCallback(async () => { + try { + const supplyLocations = await api.getAllSupplyLocations(); + + // Group by location_name and merge into inventoryData + const locationMap = new Map(); + supplyLocations.forEach(sl => { + const key = sl.location; + if (!locationMap.has(key)) { + locationMap.set(key, []); + } + locationMap.get(key).push({ + id: sl.id, // supply_location_id from API + name: sl.supply_name || '', // From JOIN in API + qty: sl.qty, // API maps amount to qty + shelf: sl.shelf !== null ? sl.shelf : undefined + }); + }); + + // Merge into inventoryData + setInventoryData(prev => { + const next = new Map(prev); + locationMap.forEach((items, locationName) => { + const boxData = next.get(locationName); + if (boxData) { + next.set(locationName, { + ...boxData, + inventory: items + }); + } + }); + return next; + }); + } catch (apiError) { + console.error('Error reloading supply locations from API:', apiError); + } + }, []); + // Initialize inventory data from database (locations) and API (inventory data) useEffect(() => { const loadInventoryData = async () => { @@ -125,41 +185,7 @@ export const InventoryProvider = ({ children }) => { setInventoryData(newInventoryData); // 3. Load supply locations from API and merge into inventoryData - try { - const supplyLocations = await api.getAllSupplyLocations(); - - // Group by location_name and merge into inventoryData - const locationMap = new Map(); - supplyLocations.forEach(sl => { - const key = sl.location; - if (!locationMap.has(key)) { - locationMap.set(key, []); - } - locationMap.get(key).push({ - name: sl.supply_name || '', // From JOIN in API - qty: sl.qty, // API maps amount to qty - shelf: sl.shelf !== null ? sl.shelf : undefined - }); - }); - - // Merge into inventoryData - setInventoryData(prev => { - const next = new Map(prev); - locationMap.forEach((items, locationName) => { - const boxData = next.get(locationName); - if (boxData) { - next.set(locationName, { - ...boxData, - inventory: items - }); - } - }); - return next; - }); - } catch (apiError) { - console.error('Error loading supply locations from API:', apiError); - // Continue with empty inventory arrays if API fails - } + await reloadSupplyLocations(); setIsLoading(false); } catch (error) { @@ -505,6 +531,9 @@ export const InventoryProvider = ({ children }) => { }); }); + // Reload supply locations to get the IDs for newly added items + await reloadSupplyLocations(); + // Clear add mode setAddModeItem(null); setAddModeQtyPerClick(1); @@ -516,7 +545,7 @@ export const InventoryProvider = ({ children }) => { setError(error.message || 'Failed to add items'); } } - }, [inventoryData, supplyNameToId]); + }, [inventoryData, supplyNameToId, reloadSupplyLocations]); const cancelAddMode = useCallback(() => { setAddModeItem(null); @@ -524,6 +553,188 @@ export const InventoryProvider = ({ children }) => { setAddModePending(new Map()); }, []); + // Delete Mode functions + const startDeleteMode = useCallback((itemName) => { + setDeleteModeItem(itemName); + setDeleteModeQtyPerClick(1); + setDeleteModePending(new Map()); + setSelectedBox(null); // Clear box selection when entering delete mode + setSelectedMasterItem(null); // Clear Master preview when entering delete mode + }, []); + + // shelf is optional — undefined for non-shelf boxes, number for Tall Cabinet shelves + const handleBoxClickDeleteMode = useCallback((boxTitle, shelf) => { + const qty = deleteModeQtyPerClickRef.current; + const key = shelf !== undefined ? `${boxTitle}||${shelf}` : boxTitle; + + // Get current quantity in this location + const boxData = inventoryData.get(boxTitle); + if (!boxData) return; + + const matchingItems = boxData.inventory.filter(item => { + if (item.name !== deleteModeItemRef.current) return false; + if (shelf !== undefined) return (item.shelf ?? 0) === shelf; + return item.shelf === undefined; + }); + + const currentQty = matchingItems.reduce((sum, item) => sum + (item.qty || 0), 0); + const existingPending = deleteModePendingRef.current.get(key) || 0; + + // Don't allow deleting more than what's available + const maxDeletable = currentQty - existingPending; + const toDelete = Math.min(qty, maxDeletable); + + if (toDelete <= 0) return; // Nothing to delete + + setDeleteModePending(prev => { + const next = new Map(prev); + const existing = next.get(key) || 0; + next.set(key, existing + toDelete); + return next; + }); + }, [inventoryData]); + + // Check if any pending deletion belongs to a given box (handles compound keys) + const boxHasAnyDeletePending = useCallback((boxTitle) => { + for (const key of deleteModePending.keys()) { + if (key === boxTitle || key.startsWith(boxTitle + '||')) return true; + } + return false; + }, [deleteModePending]); + + const finishDeleteMode = useCallback(async () => { + const currentItem = deleteModeItemRef.current; + const pending = deleteModePendingRef.current; + + if (!currentItem) { + setDeleteModeItem(null); + setDeleteModeQtyPerClick(1); + setDeleteModePending(new Map()); + return; + } + + // Get supply_id for the item + const supplyId = supplyNameToId.get(currentItem); + if (!supplyId) { + console.error(`Supply ID not found for item: ${currentItem}`); + setError(`Supply ID not found for item: ${currentItem}`); + return; + } + + // Convert pending map to deletions + const deletions = []; + pending.forEach((pendingQty, key) => { + const parts = key.split('||'); + const boxTitle = parts[0]; + const shelf = parts.length > 1 ? parseInt(parts[1], 10) : null; + + // Find the supply_location_id for this item at this location + const boxData = inventoryData.get(boxTitle); + if (!boxData) return; + + const matchingItems = boxData.inventory.filter(item => { + if (item.name !== currentItem) return false; + if (shelf !== null && shelf !== undefined) return (item.shelf ?? 0) === shelf; + return item.shelf === undefined; + }); + + // For each matching item, we need to delete or reduce it + let remainingToDelete = pendingQty; + matchingItems.forEach(item => { + if (item.id && remainingToDelete > 0) { + const deleteQty = Math.min(remainingToDelete, item.qty); + deletions.push({ + id: item.id, + location: boxTitle, + shelf: shelf, + amount: deleteQty + }); + remainingToDelete -= deleteQty; + } + }); + }); + + if (deletions.length === 0) { + setDeleteModeItem(null); + setDeleteModeQtyPerClick(1); + setDeleteModePending(new Map()); + return; + } + + try { + // Delete items via API + for (const deletion of deletions) { + const item = inventoryData.get(deletion.location)?.inventory.find(i => i.id === deletion.id); + if (!item) continue; + + if (item.qty <= deletion.amount) { + // Delete the entire entry + await api.deleteSupplyLocation(deletion.id); + } else { + // Reduce the quantity + await api.updateSupplyLocation(deletion.id, { amount: item.qty - deletion.amount }); + } + } + + // Update local state optimistically + const byBox = new Map(); + deletions.forEach(({ location, shelf, amount, id }) => { + if (!byBox.has(location)) byBox.set(location, []); + byBox.get(location).push({ shelf, amount, id }); + }); + + byBox.forEach((entries, boxTitle) => { + const boxData = inventoryData.get(boxTitle); + if (!boxData) return; + + const newInventory = [...boxData.inventory]; + + entries.forEach(({ shelf, amount, id }) => { + const existingIndex = newInventory.findIndex(item => item.id === id); + if (existingIndex >= 0) { + const newQty = newInventory[existingIndex].qty - amount; + if (newQty <= 0) { + // Remove item + newInventory.splice(existingIndex, 1); + } else { + // Update quantity + newInventory[existingIndex] = { + ...newInventory[existingIndex], + qty: newQty + }; + } + } + }); + + setInventoryData(prev => { + const next = new Map(prev); + const box = next.get(boxTitle); + if (box) { + next.set(boxTitle, { ...box, inventory: newInventory }); + } + return next; + }); + }); + + // Clear delete mode + setDeleteModeItem(null); + setDeleteModeQtyPerClick(1); + setDeleteModePending(new Map()); + } catch (error) { + console.error('Error finishing delete mode:', error); + // Only set error if not panning (to avoid breaking pan) + if (!isPanningRef.current) { + setError(error.message || 'Failed to delete items'); + } + } + }, [inventoryData, supplyNameToId]); + + const cancelDeleteMode = useCallback(() => { + setDeleteModeItem(null); + setDeleteModeQtyPerClick(1); + setDeleteModePending(new Map()); + }, []); + // Move Mode functions const startMoveMode = useCallback((itemName) => { setMoveModeItem(itemName); @@ -1031,6 +1242,17 @@ export const InventoryProvider = ({ children }) => { cancelAddMode, handleBoxClickAddMode, boxHasAnyPending, + // Delete Mode + deleteModeItem, + deleteModeQtyPerClick, + setDeleteModeQtyPerClick, + deleteModePending, + deleteModePreviewRef, + startDeleteMode, + finishDeleteMode, + cancelDeleteMode, + handleBoxClickDeleteMode, + boxHasAnyDeletePending, // Move Mode moveModeItem, moveModeDragging, diff --git a/milventory/src/index.css b/milventory/src/index.css index 6ffe72e..9dd76c5 100644 --- a/milventory/src/index.css +++ b/milventory/src/index.css @@ -1331,9 +1331,6 @@ svg.overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-e padding: 0.25rem 0.75rem; border-radius: 4px; cursor: pointer; - font-weight: 600; - font-size: 0.85rem; - margin-right: 0.5rem; } .add-button:hover { diff --git a/src/api/models/category.py b/src/api/models/category.py index 5912c3d..4b4430a 100644 --- a/src/api/models/category.py +++ b/src/api/models/category.py @@ -20,3 +20,5 @@ def to_dict(self): 'created_at': self.created_at.isoformat() if self.created_at else None } + + diff --git a/src/api/models/team.py b/src/api/models/team.py index f95f787..d775f6f 100644 --- a/src/api/models/team.py +++ b/src/api/models/team.py @@ -16,3 +16,5 @@ def to_dict(self): 'name': self.name } + + diff --git a/src/api/routes/migrate.py b/src/api/routes/migrate.py index 2ef06cd..34cc744 100644 --- a/src/api/routes/migrate.py +++ b/src/api/routes/migrate.py @@ -44,3 +44,5 @@ def migrate_locations(): 'error': str(e) }), 500 + + diff --git a/src/api/routes/teams.py b/src/api/routes/teams.py index 331036f..0743b47 100644 --- a/src/api/routes/teams.py +++ b/src/api/routes/teams.py @@ -33,3 +33,5 @@ def get_teams(): except Exception as e: return jsonify({'error': f'Unexpected error: {str(e)}'}), 500 + + diff --git a/src/sql/categories/table_categories.sql b/src/sql/categories/table_categories.sql index 9470217..037be6a 100644 --- a/src/sql/categories/table_categories.sql +++ b/src/sql/categories/table_categories.sql @@ -5,3 +5,5 @@ CREATE TABLE IF NOT EXISTS categories ( INDEX idx_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + diff --git a/src/sql/location/migrate_add_coordinates.sql b/src/sql/location/migrate_add_coordinates.sql index bc9b1ab..3542688 100644 --- a/src/sql/location/migrate_add_coordinates.sql +++ b/src/sql/location/migrate_add_coordinates.sql @@ -11,3 +11,5 @@ ADD COLUMN IF NOT EXISTS y INT NOT NULL DEFAULT 0 AFTER x, ADD COLUMN IF NOT EXISTS width INT NOT NULL DEFAULT 150 AFTER y, ADD COLUMN IF NOT EXISTS height INT NOT NULL DEFAULT 150 AFTER width; + + diff --git a/src/sql/supplies_categories/table_supplies_categories.sql b/src/sql/supplies_categories/table_supplies_categories.sql index 4e7191a..275ac75 100644 --- a/src/sql/supplies_categories/table_supplies_categories.sql +++ b/src/sql/supplies_categories/table_supplies_categories.sql @@ -12,3 +12,5 @@ CREATE TABLE IF NOT EXISTS supplies_categories ( INDEX idx_category_id (category_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + From f6fb6c9a185a9a1e5927f44e0c222793acfd5f7e Mon Sep 17 00:00:00 2001 From: willzoo Date: Mon, 2 Mar 2026 13:33:30 -0500 Subject: [PATCH 17/73] Add move mode to admin dashboard --- milventory/src/api.js | 34 +++ .../src/components/AdminActionsPanel.js | 30 +- milventory/src/components/AdminDashboard.js | 90 +++++- milventory/src/components/AdminMap.js | 261 ++++++++++++++++-- .../src/components/MoveLocationsModal.js | 193 +++++++++++++ milventory/src/index.css | 233 ++++++++++++++++ src/api/routes/categories.py | 18 +- 7 files changed, 808 insertions(+), 51 deletions(-) create mode 100644 milventory/src/components/MoveLocationsModal.js diff --git a/milventory/src/api.js b/milventory/src/api.js index 3a358d8..207cf1a 100644 --- a/milventory/src/api.js +++ b/milventory/src/api.js @@ -446,6 +446,40 @@ export const admin = { throw err; }), + updateLocation: (name, data) => + fetch(`${API_BASE}/locations/${encodeURIComponent(name)}`, { + method: 'PUT', + credentials: 'include', + headers: authHeaders(), + body: JSON.stringify(data) + }).then(r => { + if (r.status === 401) { + const error = new Error('Authentication required'); + error.response = { status: 401 }; + throw error; + } + if (r.status === 403) { + const error = new Error('Leader access required'); + error.response = { status: 403 }; + throw error; + } + if (!r.ok) { + return r.json().then(data => { + const error = new Error(data.error || 'Request failed'); + error.response = r; + throw error; + }); + } + return r.json(); + }).catch(err => { + if (err instanceof TypeError && err.message.includes('fetch')) { + const networkError = new Error('Network error: Unable to connect to server. Please check if the server is running.'); + networkError.response = { status: 0 }; + throw networkError; + } + throw err; + }), + deleteLocation: (name) => fetch(`${API_BASE}/locations/${encodeURIComponent(name)}`, { method: 'DELETE', diff --git a/milventory/src/components/AdminActionsPanel.js b/milventory/src/components/AdminActionsPanel.js index 9a7b9d4..4de9cd7 100644 --- a/milventory/src/components/AdminActionsPanel.js +++ b/milventory/src/components/AdminActionsPanel.js @@ -1,6 +1,6 @@ import React from 'react'; -const AdminActionsPanel = ({ onAddLocation, drawMode, onCancelDraw }) => { +const AdminActionsPanel = ({ onAddLocation, drawMode, onCancelDraw, onStartMove, isInMoveMode }) => { return (
@@ -16,12 +16,28 @@ const AdminActionsPanel = ({ onAddLocation, drawMode, onCancelDraw }) => { Cancel Draw ) : ( - + <> + + + )}
diff --git a/milventory/src/components/AdminDashboard.js b/milventory/src/components/AdminDashboard.js index faac8b2..01cef72 100644 --- a/milventory/src/components/AdminDashboard.js +++ b/milventory/src/components/AdminDashboard.js @@ -1,10 +1,11 @@ -import React, { useRef, useState } from 'react'; +import React, { useRef, useState, useCallback } from 'react'; import { useInventory } from '../context/InventoryContext'; import AdminMap from './AdminMap'; import AdminLeftPanel from './AdminLeftPanel'; import AdminActionsPanel from './AdminActionsPanel'; import AddLocationModal from './AddLocationModal'; import LocationPreview from './LocationPreview'; +import MoveLocationsModal from './MoveLocationsModal'; const AdminDashboard = () => { const { wrapRef, leftPaneWidth, leftPaneCollapsed } = useInventory(); @@ -17,6 +18,12 @@ const AdminDashboard = () => { const [selectedLocation, setSelectedLocation] = useState(null); const edgeDragHandlerRef = useRef(null); + // Move mode state + // 'idle' | 'selecting' | 'moving' + const [moveMode, setMoveMode] = useState('idle'); + const [moveSelectedBoxes, setMoveSelectedBoxes] = useState([]); // [{name, x, y, width, height, ...}] + const [moveTransform, setMoveTransform] = useState({ x: 0, y: 0 }); // current dx, dy offset + const handleAddLocation = () => { setDrawMode(true); }; @@ -35,10 +42,7 @@ const AdminDashboard = () => { }; const handleLocationAdded = () => { - // Trigger refresh of components setRefreshTrigger(prev => prev + 1); - // Reload page after a short delay to ensure backend has synced JSON - // The JSON file is synced by sync_locations_json() when location is created setTimeout(() => { window.location.reload(); }, 300); @@ -57,6 +61,48 @@ const AdminDashboard = () => { setRefreshTrigger(prev => prev + 1); }; + // Move mode handlers + const handleStartMove = useCallback(() => { + setMoveMode('selecting'); + setMoveSelectedBoxes([]); + setMoveTransform({ x: 0, y: 0 }); + setSelectedLocation(null); + }, []); + + const handleSelectBoxes = useCallback(() => { + // Transition from modal state to actual rectangle selection on map + setMoveMode('selecting'); + }, []); + + const handleBoxesSelected = useCallback((boxes) => { + setMoveSelectedBoxes(boxes); + setMoveMode('moving'); + setMoveTransform({ x: 0, y: 0 }); + }, []); + + const handleMoveTransformChange = useCallback((newTransform) => { + setMoveTransform(newTransform); + }, []); + + const handleCancelMove = useCallback(() => { + setMoveMode('idle'); + setMoveSelectedBoxes([]); + setMoveTransform({ x: 0, y: 0 }); + }, []); + + const handleApplyMove = useCallback(() => { + // Applied successfully from the modal - refresh + setMoveMode('idle'); + setMoveSelectedBoxes([]); + setMoveTransform({ x: 0, y: 0 }); + setRefreshTrigger(prev => prev + 1); + setTimeout(() => { + window.location.reload(); + }, 300); + }, []); + + const isInMoveMode = moveMode !== 'idle'; + return ( <> { Draw Mode: Drag on map to create a box
)} + {moveMode === 'selecting' && ( + + Move Mode: Drag on map to select boxes + + )} + {moveMode === 'moving' && ( + + Move Mode: Drag selected boxes or type transform values + + )} { onLocationSelect={handleLocationSelect} previewBox={previewBox} onPreviewEdgeDrag={(edges) => { - // Update preview box state directly const newPreviewBox = { x: edges.leftX, y: edges.topY, @@ -93,16 +156,31 @@ const AdminDashboard = () => { height: edges.bottomY - edges.topY }; setPreviewBox(newPreviewBox); - // Also trigger form update via edge drag handler if available if (edgeDragHandlerRef.current) { edgeDragHandlerRef.current(edges); } }} + moveMode={moveMode} + moveSelectedBoxes={moveSelectedBoxes} + moveTransform={moveTransform} + onBoxesSelected={handleBoxesSelected} + onMoveTransformChange={handleMoveTransformChange} /> setDrawMode(false)} + onStartMove={handleStartMove} + isInMoveMode={isInMoveMode} + /> + { - const { drawMode, onDrawComplete, selectedLocation, onLocationSelect, previewBox, onPreviewEdgeDrag } = props; + const { + drawMode, onDrawComplete, selectedLocation, onLocationSelect, previewBox, onPreviewEdgeDrag, + // Move mode props + moveMode, moveSelectedBoxes, moveTransform, onBoxesSelected, onMoveTransformChange + } = props; const { inventoryData, inventoryBounds } = useInventory(); const worldRef = useRef(null); const svgRef = useRef(null); @@ -14,12 +18,23 @@ const AdminMap = forwardRef((props, ref) => { const drawStartRef = useRef(null); const currentDrawingBoxRef = useRef(null); const currentTransformRef = useRef(d3.zoomIdentity); - const isZoomInitializedRef = useRef(false); // Track if initial zoom has been set + const isZoomInitializedRef = useRef(false); const [isDraggingEdge, setIsDraggingEdge] = useState(false); - const isDraggingEdgeRef = useRef(false); // Synchronous ref for D3 filter - const [draggingEdge, setDraggingEdge] = useState(null); // 'top', 'bottom', 'left', 'right' + const isDraggingEdgeRef = useRef(false); + const [draggingEdge, setDraggingEdge] = useState(null); const edgeDragStartRef = useRef(null); + // Move mode: selection rectangle state + const [selectionRect, setSelectionRect] = useState(null); + const [isSelecting, setIsSelecting] = useState(false); + const selectionStartRef = useRef(null); + const currentSelectionRectRef = useRef(null); + + // Move mode: drag-to-move state + const [isDraggingMove, setIsDraggingMove] = useState(false); + const isDraggingMoveRef = useRef(false); + const moveDragStartRef = useRef(null); + // Expose svgRef to parent via forwarded ref useEffect(() => { if (ref) { @@ -31,27 +46,33 @@ const AdminMap = forwardRef((props, ref) => { } }, [ref]); - // Setup D3 zoom/pan (disabled when in draw mode) + // Determine if we're in an active move interaction mode + const isInMoveInteraction = moveMode === 'selecting' || moveMode === 'moving'; + + // Setup D3 zoom/pan (disabled when in draw mode or move mode) useEffect(() => { if (!svgRef.current || !worldRef.current) return; const zoom = d3.zoom() .scaleExtent([0.6, 6]) .filter((event) => { - // Disable zoom/pan when in draw mode or when dragging an edge handle if (drawMode) return false; if (isDraggingEdgeRef.current) return false; - // Check if the event target is an edge handle + if (isDraggingMoveRef.current) return false; + // Disable zoom during selection or moving + if (moveMode === 'selecting') return false; + if (moveMode === 'moving') return false; if (event.target && event.target.dataset && event.target.dataset.edgeHandle) return false; + if (event.target && event.target.dataset && event.target.dataset.moveHandle) return false; return true; }) .on('start', () => { - if (!drawMode) { + if (!drawMode && !isInMoveInteraction) { isPanningRef.current = true; } }) .on('zoom', (e) => { - if (worldRef.current && !drawMode) { + if (worldRef.current && !drawMode && !isInMoveInteraction) { worldRef.current.setAttribute('transform', e.transform); currentTransformRef.current = e.transform; } @@ -62,12 +83,11 @@ const AdminMap = forwardRef((props, ref) => { const svg = d3.select(svgRef.current); svg.call(zoom).on('dblclick.zoom', null); - // Only set initial transform on first mount, not when drawMode changes - if (!isZoomInitializedRef.current && !drawMode) { + if (!isZoomInitializedRef.current && !drawMode && !isInMoveInteraction) { svg.call(zoom.transform, d3.zoomIdentity.scale(1.03)); isZoomInitializedRef.current = true; } - }, [drawMode]); + }, [drawMode, moveMode, isInMoveInteraction]); // Convert screen coordinates to SVG coordinates (accounting for zoom/pan) const screenToSVG = (screenX, screenY) => { @@ -256,6 +276,125 @@ const AdminMap = forwardRef((props, ref) => { }); }; + // ===== Move Mode: Selection rectangle handlers ===== + const handleSelectionMouseDown = (e) => { + if (moveMode !== 'selecting') return; + e.preventDefault(); + e.stopPropagation(); + + const svgCoords = screenToSVG(e.clientX, e.clientY); + selectionStartRef.current = svgCoords; + const initialRect = { x: svgCoords.x, y: svgCoords.y, width: 0, height: 0 }; + currentSelectionRectRef.current = initialRect; + setIsSelecting(true); + setSelectionRect(initialRect); + }; + + const handleSelectionMouseMove = (e) => { + if (!isSelecting || !selectionStartRef.current) return; + e.preventDefault(); + e.stopPropagation(); + + const svgCoords = screenToSVG(e.clientX, e.clientY); + const start = selectionStartRef.current; + + const rect = { + x: Math.min(start.x, svgCoords.x), + y: Math.min(start.y, svgCoords.y), + width: Math.abs(svgCoords.x - start.x), + height: Math.abs(svgCoords.y - start.y) + }; + currentSelectionRectRef.current = rect; + setSelectionRect(rect); + }; + + const handleSelectionMouseUp = (e) => { + if (!isSelecting) return; + e.preventDefault(); + e.stopPropagation(); + + const rect = currentSelectionRectRef.current; + setIsSelecting(false); + setSelectionRect(null); + selectionStartRef.current = null; + currentSelectionRectRef.current = null; + + if (!rect || rect.width < 5 || rect.height < 5) return; + + // Find all boxes that intersect the selection rectangle + const selected = boxes.filter(box => { + return !( + box.x + box.width < rect.x || + box.x > rect.x + rect.width || + box.y + box.height < rect.y || + box.y > rect.y + rect.height + ); + }).map(box => ({ + name: box.title, + x: box.x, + y: box.y, + width: box.width, + height: box.height + })); + + if (selected.length > 0 && onBoxesSelected) { + onBoxesSelected(selected); + } + }; + + // ===== Move Mode: Drag-to-move selected boxes ===== + const handleMoveDragMouseDown = (e) => { + if (moveMode !== 'moving' || !moveSelectedBoxes || moveSelectedBoxes.length === 0) return; + e.preventDefault(); + e.stopPropagation(); + + const svgCoords = screenToSVG(e.clientX, e.clientY); + isDraggingMoveRef.current = true; + setIsDraggingMove(true); + moveDragStartRef.current = { + startCoords: svgCoords, + initialTransform: { ...moveTransform } + }; + }; + + const handleMoveDragMouseMove = (e) => { + if (!isDraggingMoveRef.current || !moveDragStartRef.current) return; + e.preventDefault(); + e.stopPropagation(); + + const svgCoords = screenToSVG(e.clientX, e.clientY); + const { startCoords, initialTransform } = moveDragStartRef.current; + + const dx = snapTo5(svgCoords.x - startCoords.x); + const dy = snapTo5(svgCoords.y - startCoords.y); + + const initX = typeof initialTransform.x === 'number' ? initialTransform.x : 0; + const initY = typeof initialTransform.y === 'number' ? initialTransform.y : 0; + + if (onMoveTransformChange) { + onMoveTransformChange({ x: initX + dx, y: initY + dy }); + } + }; + + const handleMoveDragMouseUp = (e) => { + if (!isDraggingMoveRef.current) return; + e.preventDefault(); + e.stopPropagation(); + + isDraggingMoveRef.current = false; + setIsDraggingMove(false); + moveDragStartRef.current = null; + }; + + // Build a set of selected box names for quick lookup + const moveSelectedNames = useMemo(() => { + if (!moveSelectedBoxes) return new Set(); + return new Set(moveSelectedBoxes.map(b => b.name)); + }, [moveSelectedBoxes]); + + const moveDx = typeof moveTransform?.x === 'number' ? moveTransform.x : 0; + const moveDy = typeof moveTransform?.y === 'number' ? moveTransform.y : 0; + const boxes = inventoryData ? Array.from(inventoryData.values()) : []; const viewBox = inventoryBounds?.viewBox @@ -271,6 +410,14 @@ const AdminMap = forwardRef((props, ref) => { ry: 18 }; + // Compute cursor style + const getCursor = () => { + if (drawMode) return 'crosshair'; + if (moveMode === 'selecting') return 'crosshair'; + if (moveMode === 'moving') return isDraggingMove ? 'grabbing' : 'default'; + return 'default'; + }; + return ( { style={{ touchAction: 'none', userSelect: 'none', - cursor: drawMode ? 'crosshair' : 'default' + cursor: getCursor() + }} + onMouseDown={(e) => { + if (drawMode) { handleMouseDown(e); return; } + if (moveMode === 'selecting') { handleSelectionMouseDown(e); return; } + if (moveMode === 'moving') { handleMoveDragMouseDown(e); return; } }} - onMouseDown={drawMode ? handleMouseDown : undefined} onMouseMove={(e) => { if (drawMode) { handleMouseMove(e); return; } if (isDraggingEdgeRef.current) { handleEdgeMouseMove(e); return; } + if (isSelecting) { handleSelectionMouseMove(e); return; } + if (isDraggingMoveRef.current) { handleMoveDragMouseMove(e); return; } }} onMouseUp={(e) => { if (isDraggingEdgeRef.current) { handleMouseUp(e); return; } if (drawMode) { handleMouseUp(e); return; } + if (isSelecting) { handleSelectionMouseUp(e); return; } + if (isDraggingMoveRef.current) { handleMoveDragMouseUp(e); return; } }} onMouseLeave={() => { if (isDrawing) { @@ -304,6 +459,17 @@ const AdminMap = forwardRef((props, ref) => { setDraggingEdge(null); edgeDragStartRef.current = null; } + if (isSelecting) { + setIsSelecting(false); + setSelectionRect(null); + selectionStartRef.current = null; + currentSelectionRectRef.current = null; + } + if (isDraggingMoveRef.current) { + isDraggingMoveRef.current = false; + setIsDraggingMove(false); + moveDragStartRef.current = null; + } }} > @@ -319,35 +485,50 @@ const AdminMap = forwardRef((props, ref) => { {boxes.map((box, idx) => { const isSelected = selectedLocation && selectedLocation.name === box.title; + const isMoveSelected = moveSelectedNames.has(box.title); + const isMoving = moveMode === 'moving' && isMoveSelected; + + // When in moving state, offset selected boxes by the transform + const displayX = isMoving ? box.x + moveDx : box.x; + const displayY = isMoving ? box.y + moveDy : box.y; + return ( { + if (moveMode === 'moving' && isMoveSelected) { + handleMoveDragMouseDown(e); + } }} onClick={async (e) => { + if (isInMoveInteraction) return; if (!drawMode && onLocationSelect) { e.stopPropagation(); try { - // Fetch the full location data from the API const location = await admin.getLocations().then(locations => locations.find(loc => loc.name === box.title) ); if (location) { onLocationSelect(location); } else { - // Fallback: construct from box data if API doesn't have it const fallbackLocation = { name: box.title, x: box.x, @@ -360,7 +541,6 @@ const AdminMap = forwardRef((props, ref) => { } } catch (err) { console.error('Error fetching location:', err); - // Fallback: construct from box data const fallbackLocation = { name: box.title, x: box.x, @@ -377,6 +557,22 @@ const AdminMap = forwardRef((props, ref) => { ); })} + {/* Ghost outlines for original positions during move */} + {moveMode === 'moving' && (moveDx !== 0 || moveDy !== 0) && moveSelectedBoxes.map(box => ( + + ))} + {/* Drawing preview box */} {drawingBox && ( { /> )} + {/* Selection rectangle for move mode */} + {selectionRect && ( + + )} + {/* Preview box from AddLocationModal with draggable edges */} {previewBox && !drawingBox && ( <> diff --git a/milventory/src/components/MoveLocationsModal.js b/milventory/src/components/MoveLocationsModal.js new file mode 100644 index 0000000..af016ff --- /dev/null +++ b/milventory/src/components/MoveLocationsModal.js @@ -0,0 +1,193 @@ +import React, { useState } from 'react'; +import { admin } from '../api'; + +const MoveLocationsModal = ({ + moveMode, + selectedBoxes, + transform, + onTransformChange, + onSelectBoxes, + onCancel, + onApply +}) => { + const [applying, setApplying] = useState(false); + const [error, setError] = useState(null); + + if (moveMode === 'idle') return null; + + const handleTransformXChange = (e) => { + const val = e.target.value; + // Allow empty, minus sign, or valid number + if (val === '' || val === '-') { + onTransformChange({ ...transform, x: val }); + return; + } + const num = parseInt(val, 10); + if (!isNaN(num)) { + onTransformChange({ ...transform, x: num }); + } + }; + + const handleTransformYChange = (e) => { + const val = e.target.value; + if (val === '' || val === '-') { + onTransformChange({ ...transform, y: val }); + return; + } + const num = parseInt(val, 10); + if (!isNaN(num)) { + onTransformChange({ ...transform, y: num }); + } + }; + + const handleApply = async () => { + const dx = typeof transform.x === 'number' ? transform.x : 0; + const dy = typeof transform.y === 'number' ? transform.y : 0; + if (dx === 0 && dy === 0) { + setError('No transform to apply'); + return; + } + + setApplying(true); + setError(null); + + try { + // Update each selected box's position via API + for (const box of selectedBoxes) { + await admin.updateLocation(box.name, { + x: Math.round(box.x + dx), + y: Math.round(box.y + dy) + }); + } + onApply(); + } catch (err) { + setError(err.message || 'Failed to apply move'); + } finally { + setApplying(false); + } + }; + + const dx = typeof transform.x === 'number' ? transform.x : 0; + const dy = typeof transform.y === 'number' ? transform.y : 0; + + return ( +
+
+

Move Locations

+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* State 1: Only select button */} + {moveMode === 'selecting' && selectedBoxes.length === 0 && ( +
+

+ Drag on the map to select boxes to move. +

+ +
+ )} + + {/* State 2: Boxes selected - show names + transform fields */} + {moveMode === 'moving' && selectedBoxes.length > 0 && ( +
+
+ +
+ {selectedBoxes.map(box => ( + + {box.name} + + ))} +
+
+ +
+ +
+
+ X + +
+
+ Y + +
+
+
+ {selectedBoxes.slice(0, 3).map(box => ( +
+ {box.name} + + ({box.x}, {box.y}) → ({Math.round(box.x + dx)}, {Math.round(box.y + dy)}) + +
+ ))} + {selectedBoxes.length > 3 && ( +
+ ...and {selectedBoxes.length - 3} more +
+ )} +
+
+ +
+ + +
+
+ )} + + {/* Selecting state but back from map with 0 boxes (shouldn't happen often) */} + {moveMode === 'selecting' && selectedBoxes.length > 0 && ( +
+

Boxes selected, preparing...

+
+ )} +
+ ); +}; + +export default MoveLocationsModal; + diff --git a/milventory/src/index.css b/milventory/src/index.css index 9dd76c5..38c70a5 100644 --- a/milventory/src/index.css +++ b/milventory/src/index.css @@ -1391,3 +1391,236 @@ svg.overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-e opacity: 0.8; } +/* Move Locations Modal */ +.move-locations-modal { + position: absolute; + top: 20px; + right: 20px; + background: var(--panel); + border: 1px solid rgba(255, 193, 7, 0.3); + border-radius: 12px; + box-shadow: 0 4px 24px rgba(0,0,0,.5); + padding: 1rem; + min-width: 280px; + max-width: 340px; + z-index: 200; +} + +.move-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid rgba(255, 193, 7, 0.2); +} + +.move-modal-header h3 { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: #ffc107; +} + +.move-modal-close { + background: none; + border: none; + color: var(--muted); + font-size: 1.1rem; + cursor: pointer; + padding: 0.2rem 0.4rem; + border-radius: 4px; + line-height: 1; +} + +.move-modal-close:hover { + color: var(--text); + background: rgba(255,255,255,.1); +} + +.move-modal-body { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.move-modal-hint { + color: var(--muted); + font-size: 0.85rem; + margin: 0; + line-height: 1.4; +} + +.move-modal-select-btn { + background: #ffc107; + color: #1a1a2e; + border: none; + padding: 0.75rem 1rem; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + font-size: 0.9rem; + transition: opacity 0.2s; + width: 100%; +} + +.move-modal-select-btn:hover { + opacity: 0.9; +} + +.move-modal-selected { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.move-modal-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.move-modal-box-list { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + max-height: 80px; + overflow-y: auto; +} + +.move-modal-box-tag { + background: rgba(255, 193, 7, 0.15); + color: #ffc107; + padding: 0.2rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + border: 1px solid rgba(255, 193, 7, 0.25); +} + +.move-modal-transform { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.move-modal-transform-fields { + display: flex; + gap: 0.5rem; +} + +.move-modal-field { + flex: 1; + display: flex; + align-items: center; + gap: 0.4rem; +} + +.move-modal-field-label { + font-size: 0.8rem; + font-weight: 600; + color: var(--muted); + min-width: 14px; +} + +.move-modal-input { + flex: 1; + background: rgba(255,255,255,.06); + border: 1px solid rgba(255,255,255,.15); + border-radius: 6px; + color: var(--text); + padding: 0.4rem 0.6rem; + font-size: 0.85rem; + font-family: 'SF Mono', 'Fira Code', monospace; + outline: none; + width: 0; +} + +.move-modal-input:focus { + border-color: #ffc107; + box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.15); +} + +.move-modal-preview-coords { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.4rem 0.5rem; + background: rgba(0,0,0,.2); + border-radius: 6px; + max-height: 65px; + overflow-y: auto; +} + +.move-modal-coord-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; +} + +.move-modal-coord-name { + font-size: 0.75rem; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 90px; +} + +.move-modal-coord-val { + font-size: 0.72rem; + color: var(--text); + font-family: 'SF Mono', 'Fira Code', monospace; + white-space: nowrap; +} + +.move-modal-actions { + display: flex; + gap: 0.5rem; +} + +.move-modal-apply-btn { + flex: 1; + background: #ffc107; + color: #1a1a2e; + border: none; + padding: 0.6rem 1rem; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + font-size: 0.85rem; + transition: opacity 0.2s; +} + +.move-modal-apply-btn:hover:not(:disabled) { + opacity: 0.9; +} + +.move-modal-apply-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.move-modal-cancel-btn { + padding: 0.6rem 1rem; + background: rgba(255,255,255,.1); + color: var(--text); + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + transition: all 0.2s; +} + +.move-modal-cancel-btn:hover:not(:disabled) { + background: rgba(255,255,255,.15); +} + +.move-modal-cancel-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + diff --git a/src/api/routes/categories.py b/src/api/routes/categories.py index e7fe3c2..61e78fd 100644 --- a/src/api/routes/categories.py +++ b/src/api/routes/categories.py @@ -1,25 +1,17 @@ """Categories API routes.""" from flask import Blueprint, request, jsonify import mysql.connector -import os -from src.scripts.helpers import parse_database_url +from src.api.db import get_db from src.api.middleware.auth import require_leader categories_bp = Blueprint('categories', __name__) -def get_db_connection(): - """Get database connection.""" - database_url = os.getenv("DATABASE_URL", "mysql://mysqluser:mysqlpassword@db:3306/mydb") - db_params = parse_database_url(database_url) - return mysql.connector.connect(**db_params) - - @categories_bp.route('/categories', methods=['GET']) def get_categories(): """Get all categories with IDs.""" try: - conn = get_db_connection() + conn = get_db() cur = conn.cursor() cur.execute("SELECT id, name FROM categories ORDER BY name") @@ -39,7 +31,7 @@ def get_categories(): def get_category(category_id): """Get a single category by ID.""" try: - conn = get_db_connection() + conn = get_db() cur = conn.cursor() cur.execute("SELECT id, name, created_at FROM categories WHERE id = %s", (category_id,)) @@ -62,7 +54,7 @@ def get_category(category_id): @categories_bp.route('/categories', methods=['POST']) @require_leader -def create_category(): +def create_category(current_user_id=None): """Create a new category. Requires leader/admin access.""" try: data = request.json @@ -73,7 +65,7 @@ def create_category(): if not name: return jsonify({'error': 'Category name cannot be empty'}), 400 - conn = get_db_connection() + conn = get_db() cur = conn.cursor() try: From 08ae476f321e73f27c73bb1247626c335d714eed Mon Sep 17 00:00:00 2001 From: willzoo Date: Mon, 2 Mar 2026 13:58:01 -0500 Subject: [PATCH 18/73] Edit mode for locations --- milventory/src/components/AddLocationModal.js | 4 +- milventory/src/components/AdminDashboard.js | 30 +- milventory/src/components/LocationPreview.js | 429 ++++++++++++++++-- milventory/src/context/InventoryContext.js | 2 + src/api/routes/locations.py | 45 +- 5 files changed, 446 insertions(+), 64 deletions(-) diff --git a/milventory/src/components/AddLocationModal.js b/milventory/src/components/AddLocationModal.js index 997be3b..868b5af 100644 --- a/milventory/src/components/AddLocationModal.js +++ b/milventory/src/components/AddLocationModal.js @@ -6,7 +6,9 @@ const LOCATION_TYPES = [ { value: 'cabinet', label: 'Cabinet' }, { value: 'tall_cabinet', label: 'Tall Cabinet' }, { value: 'table', label: 'Table' }, - { value: 'other', label: 'Other' } + { value: 'other', label: 'Other' }, + { value: 'special', label: 'Special' }, + { value: 'external', label: 'External' } ]; const AddLocationModal = ({ isOpen, onClose, onSuccess, initialBox, leftPaneWidth, leftPaneCollapsed, onPreviewUpdate, previewBox, onEdgeDrag }) => { diff --git a/milventory/src/components/AdminDashboard.js b/milventory/src/components/AdminDashboard.js index 01cef72..35ae03b 100644 --- a/milventory/src/components/AdminDashboard.js +++ b/milventory/src/components/AdminDashboard.js @@ -17,6 +17,7 @@ const AdminDashboard = () => { const [refreshTrigger, setRefreshTrigger] = useState(0); const [selectedLocation, setSelectedLocation] = useState(null); const edgeDragHandlerRef = useRef(null); + const [isEditingLocation, setIsEditingLocation] = useState(false); // Move mode state // 'idle' | 'selecting' | 'moving' @@ -53,6 +54,11 @@ const AdminDashboard = () => { }; const handleLocationDeselect = () => { + if (isEditingLocation) { + // If editing, cancel edit first + setIsEditingLocation(false); + setPreviewBox(null); + } setSelectedLocation(null); }; @@ -61,6 +67,16 @@ const AdminDashboard = () => { setRefreshTrigger(prev => prev + 1); }; + // Edit mode handlers for LocationPreview + const handleEditStart = useCallback(() => { + setIsEditingLocation(true); + }, []); + + const handleEditEnd = useCallback(() => { + setIsEditingLocation(false); + setPreviewBox(null); + }, []); + // Move mode handlers const handleStartMove = useCallback(() => { setMoveMode('selecting'); @@ -122,6 +138,15 @@ const AdminDashboard = () => { Draw Mode: Drag on map to create a box )} + {isEditingLocation && ( + + Edit Mode: Drag edges to resize + + )} {moveMode === 'selecting' && ( { onDelete={handleLocationDeleted} leftPaneWidth={leftPaneWidth} leftPaneCollapsed={leftPaneCollapsed} + onEditStart={handleEditStart} + onEditEnd={handleEditEnd} + onPreviewUpdate={setPreviewBox} + onEdgeDrag={edgeDragHandlerRef} /> @@ -206,4 +235,3 @@ const AdminDashboard = () => { }; export default AdminDashboard; - diff --git a/milventory/src/components/LocationPreview.js b/milventory/src/components/LocationPreview.js index 85e9626..1787772 100644 --- a/milventory/src/components/LocationPreview.js +++ b/milventory/src/components/LocationPreview.js @@ -1,9 +1,30 @@ -import React, { useRef, useState } from 'react'; +import React, { useRef, useState, useEffect, useCallback } from 'react'; import { admin } from '../api'; -const LocationPreview = ({ location, onClose, onDelete, leftPaneWidth, leftPaneCollapsed }) => { +const LOCATION_TYPES = [ + { value: 'drawer', label: 'Drawer' }, + { value: 'cabinet', label: 'Cabinet' }, + { value: 'tall_cabinet', label: 'Tall Cabinet' }, + { value: 'table', label: 'Table' }, + { value: 'other', label: 'Other' }, + { value: 'special', label: 'Special' }, + { value: 'external', label: 'External' } +]; + +const LocationPreview = ({ location, onClose, onDelete, leftPaneWidth, leftPaneCollapsed, onEditStart, onEditEnd, onPreviewUpdate, onEdgeDrag }) => { const previewRef = useRef(null); const [deleting, setDeleting] = useState(false); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Edit form state + const [editName, setEditName] = useState(''); + const [editType, setEditType] = useState(''); + const [editTopY, setEditTopY] = useState(''); + const [editBottomY, setEditBottomY] = useState(''); + const [editLeftX, setEditLeftX] = useState(''); + const [editRightX, setEditRightX] = useState(''); // Calculate position to the right of left pane const leftPaneActualWidth = leftPaneCollapsed ? 40 : leftPaneWidth; @@ -12,10 +33,159 @@ const LocationPreview = ({ location, onClose, onDelete, leftPaneWidth, leftPaneC const isProtected = location?.protected || false; + // Reset edit mode when location changes + useEffect(() => { + if (editing) { + handleCancelEdit(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location?.name]); + + // Expose edge drag handler to parent so map can update our form fields + useEffect(() => { + if (onEdgeDrag && editing) { + onEdgeDrag.current = (edges) => { + setEditTopY(String(edges.topY)); + setEditBottomY(String(edges.bottomY)); + setEditLeftX(String(edges.leftX)); + setEditRightX(String(edges.rightX)); + }; + } + return () => { + if (onEdgeDrag) { + onEdgeDrag.current = null; + } + }; + }, [onEdgeDrag, editing]); + + // Update preview box on map when edge values change during editing + useEffect(() => { + if (!editing || !onPreviewUpdate) return; + const topY = parseFloat(editTopY); + const bottomY = parseFloat(editBottomY); + const leftX = parseFloat(editLeftX); + const rightX = parseFloat(editRightX); + + if (!isNaN(topY) && !isNaN(bottomY) && !isNaN(leftX) && !isNaN(rightX) && + bottomY > topY && rightX > leftX) { + onPreviewUpdate({ + x: leftX, + y: topY, + width: rightX - leftX, + height: bottomY - topY + }); + } + }, [editTopY, editBottomY, editLeftX, editRightX, editing, onPreviewUpdate]); + + const handleStartEdit = useCallback(() => { + if (!location) return; + setEditing(true); + setError(null); + setEditName(location.name); + setEditType(location.type || 'other'); + setEditTopY(String(location.y)); + setEditBottomY(String(location.y + location.height)); + setEditLeftX(String(location.x)); + setEditRightX(String(location.x + location.width)); + + // Show preview box on map at current location + if (onPreviewUpdate) { + onPreviewUpdate({ + x: location.x, + y: location.y, + width: location.width, + height: location.height + }); + } + if (onEditStart) { + onEditStart(); + } + }, [location, onPreviewUpdate, onEditStart]); + + const handleCancelEdit = useCallback(() => { + setEditing(false); + setError(null); + if (onPreviewUpdate) { + onPreviewUpdate(null); + } + if (onEditEnd) { + onEditEnd(); + } + }, [onPreviewUpdate, onEditEnd]); + + const handleSave = useCallback(async () => { + if (!location) return; + setError(null); + setSaving(true); + + try { + if (!editName.trim()) { + setError('Location name is required'); + setSaving(false); + return; + } + + const topY = parseFloat(editTopY); + const bottomY = parseFloat(editBottomY); + const leftX = parseFloat(editLeftX); + const rightX = parseFloat(editRightX); + + if (isNaN(topY) || isNaN(bottomY) || isNaN(leftX) || isNaN(rightX)) { + setError('All edge values must be valid numbers'); + setSaving(false); + return; + } + if (bottomY <= topY) { + setError('Bottom Y must be greater than Top Y'); + setSaving(false); + return; + } + if (rightX <= leftX) { + setError('Right X must be greater than Left X'); + setSaving(false); + return; + } + + const x = leftX; + const y = topY; + const width = rightX - leftX; + const height = bottomY - topY; + + const updateData = { + x, y, width, height, + type: editType + }; + + // Include name if it changed + if (editName.trim() !== location.name) { + updateData.name = editName.trim(); + } + + await admin.updateLocation(location.name, updateData); + + // Clear preview and exit edit mode + if (onPreviewUpdate) { + onPreviewUpdate(null); + } + if (onEditEnd) { + onEditEnd(); + } + setEditing(false); + + // Reload to reflect changes + setTimeout(() => { + window.location.reload(); + }, 300); + } catch (err) { + setError(err.message || 'Failed to update location'); + } finally { + setSaving(false); + } + }, [location, editName, editType, editTopY, editBottomY, editLeftX, editRightX, onPreviewUpdate, onEditEnd]); + const handleDelete = async () => { if (!location) return; - // Check if location is protected if (isProtected) { alert( `This location is protected and is a permanent inventory location. ` + @@ -39,7 +209,6 @@ const LocationPreview = ({ location, onClose, onDelete, leftPaneWidth, leftPaneC if (onClose) { onClose(); } - // Reload page to refresh the map setTimeout(() => { window.location.reload(); }, 300); @@ -49,10 +218,27 @@ const LocationPreview = ({ location, onClose, onDelete, leftPaneWidth, leftPaneC } }; + const handleClose = () => { + if (editing) { + handleCancelEdit(); + } + onClose(); + }; + if (!location) return null; const typeDisplay = location.type.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()); + const inputStyle = { + width: '100%', + fontSize: '0.85rem', + padding: '0.35rem', + background: '#27292E', + border: '1px solid var(--border)', + borderRadius: '4px', + color: 'var(--text)' + }; + return (
-

{location.name}

+

{editing ? 'Edit Location' : location.name}

- -
- Type: -

{typeDisplay}

-
-
- Position: -
- X: {location.x}, Y: {location.y} -
-
- -
- Size: -
- Width: {location.width}, Height: {location.height} -
-
- - {isProtected && ( + {error && (
- ⚠ Protected Location -

- This location is protected and cannot be deleted. Edit the database to change protection status. -

+ {error}
)} -
- Actions: -
- + {editing ? ( + /* ========== EDIT MODE ========== */ +
+
+
+ Name +
+ setEditName(e.target.value)} + disabled={saving} + style={{ ...inputStyle, width: '90%' }} + /> +
+ +
+
+ Type +
+ +
+ +
+ Position & Size (drag edges on map) +
+
+
+
+ Top Y +
+ setEditTopY(e.target.value)} + disabled={saving} + style={inputStyle} + /> +
+
+
+ Bottom Y +
+ setEditBottomY(e.target.value)} + disabled={saving} + style={inputStyle} + /> +
+
+
+ Left X +
+ setEditLeftX(e.target.value)} + disabled={saving} + style={inputStyle} + /> +
+
+
+ Right X +
+ setEditRightX(e.target.value)} + disabled={saving} + style={inputStyle} + /> +
+
+ +
+
+ + +
+
-
+ ) : ( + /* ========== VIEW MODE ========== */ + <> +
+ Type: +

{typeDisplay}

+
+ +
+ Position: +
+ X: {location.x}, Y: {location.y} +
+
+ +
+ Size: +
+ Width: {location.width}, Height: {location.height} +
+
+ + {isProtected && ( +
+ ⚠ Protected Location +

+ This location is protected and cannot be deleted. Edit the database to change protection status. +

+
+ )} + +
+ Actions: +
+ + +
+
+ + )}
); }; export default LocationPreview; - diff --git a/milventory/src/context/InventoryContext.js b/milventory/src/context/InventoryContext.js index 5a94e9f..842f98f 100644 --- a/milventory/src/context/InventoryContext.js +++ b/milventory/src/context/InventoryContext.js @@ -106,6 +106,8 @@ export const InventoryProvider = ({ children }) => { 'tall_cabinet': 'var(--files)', // Tall cabinets use files color 'table': 'var(--table)', 'other': '#e7ebf3', // Other category (includes workbench) has special color + 'special': '#ff69b4', // Special category - pink + 'external': '#ff9800', // External category - orange }; return typeFills[type] || 'var(--table)'; }; diff --git a/src/api/routes/locations.py b/src/api/routes/locations.py index f77c514..ed90ada 100644 --- a/src/api/routes/locations.py +++ b/src/api/routes/locations.py @@ -25,6 +25,8 @@ def get_fill_for_type(location_type): 'tall_cabinet': 'var(--table)', 'table': 'var(--table)', 'other': 'var(--table)', + 'special': '#ff69b4', # Special category - pink + 'external': '#ff9800', # External category - orange } return type_fills.get(location_type, 'var(--table)') @@ -251,8 +253,8 @@ def update_location(name): if not data: return jsonify({'error': 'Request body is required'}), 400 - # Validate fields (name is not updatable via PUT) - updatable_fields = ['x', 'y', 'width', 'height', 'type'] + # Validate fields + updatable_fields = ['x', 'y', 'width', 'height', 'type', 'name'] update_data = {k: v for k, v in data.items() if k in updatable_fields} if not update_data: @@ -268,20 +270,37 @@ def update_location(name): conn.close() return jsonify({'error': 'Location not found'}), 404 - # Build update query dynamically - set_clauses = [] - values = [] - for field, value in update_data.items(): - set_clauses.append(f"{field} = %s") - values.append(value) - values.append(name) + # Handle name rename separately (FK has ON UPDATE CASCADE) + new_name = update_data.pop('name', None) + + # Build update query dynamically for non-name fields + if update_data: + set_clauses = [] + values = [] + for field, value in update_data.items(): + set_clauses.append(f"{field} = %s") + values.append(value) + values.append(name) + + query = f"UPDATE locations SET {', '.join(set_clauses)} WHERE name = %s" + cur.execute(query, values) + + # Apply name rename if requested (cascades to supply_locations via FK) + final_name = name + if new_name and new_name != name: + # Check new name doesn't already exist + cur.execute("SELECT name FROM locations WHERE name = %s", (new_name,)) + if cur.fetchone(): + cur.close() + conn.close() + return jsonify({'error': f'Location "{new_name}" already exists'}), 409 + cur.execute("UPDATE locations SET name = %s WHERE name = %s", (new_name, name)) + final_name = new_name - query = f"UPDATE locations SET {', '.join(set_clauses)} WHERE name = %s" - cur.execute(query, values) conn.commit() - # Fetch updated location - cur.execute("SELECT name, x, y, width, height, type, protected FROM locations WHERE name = %s", (name,)) + # Fetch updated location using final name + cur.execute("SELECT name, x, y, width, height, type, protected FROM locations WHERE name = %s", (final_name,)) row = cur.fetchone() location = Location.from_db_row(row).to_dict() From 29cef13e5190abe818c78680ce863175071d057d Mon Sep 17 00:00:00 2001 From: willzoo Date: Mon, 2 Mar 2026 19:39:21 -0500 Subject: [PATCH 19/73] WIP: Change history --- milventory/package-lock.json | 157 +++++- milventory/package.json | 8 +- milventory/src/App.js | 35 +- milventory/src/api.js | 89 ++++ .../src/components/ConflictErrorModal.css | 100 ++++ .../src/components/ConflictErrorModal.js | 52 ++ milventory/src/components/HistoryModal.css | 195 ++++++++ milventory/src/components/HistoryModal.js | 171 +++++++ milventory/src/components/HistoryTableRow.css | 135 ++++++ milventory/src/components/HistoryTableRow.js | 129 +++++ milventory/src/context/InventoryContext.js | 33 +- src/api/helpers/__init__.py | 3 + src/api/helpers/history.py | 177 +++++++ src/api/routes/supplies.py | 450 +++++++++++++++++- src/api/routes/supplies_location.py | 17 +- .../table_supplies_history.sql | 32 ++ .../table_supplies_history_categories.sql | 9 + .../table_supplies_history_teams.sql | 9 + 18 files changed, 1768 insertions(+), 33 deletions(-) create mode 100644 milventory/src/components/ConflictErrorModal.css create mode 100644 milventory/src/components/ConflictErrorModal.js create mode 100644 milventory/src/components/HistoryModal.css create mode 100644 milventory/src/components/HistoryModal.js create mode 100644 milventory/src/components/HistoryTableRow.css create mode 100644 milventory/src/components/HistoryTableRow.js create mode 100644 src/api/helpers/__init__.py create mode 100644 src/api/helpers/history.py create mode 100644 src/sql/supplies_history/table_supplies_history.sql create mode 100644 src/sql/supplies_history/table_supplies_history_categories.sql create mode 100644 src/sql/supplies_history/table_supplies_history_teams.sql diff --git a/milventory/package-lock.json b/milventory/package-lock.json index 7db8ff2..70cb302 100644 --- a/milventory/package-lock.json +++ b/milventory/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "d3": "^7.8.5", "http-proxy-middleware": "^2.0.6", + "mysql2": "^3.18.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.8.0", @@ -52,6 +53,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -655,6 +657,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.27.1.tgz", "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1480,6 +1483,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", + "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-module-imports": "^7.27.1", @@ -3404,6 +3408,7 @@ "version": "24.10.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3537,6 +3542,7 @@ "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.62.0", @@ -3588,6 +3594,7 @@ "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -3927,6 +3934,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4016,6 +4024,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4442,6 +4451,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/axe-core": { "version": "4.11.0", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", @@ -4857,6 +4875,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6094,6 +6113,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "peer": true, "engines": { "node": ">=12" } @@ -6344,6 +6364,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6899,6 +6928,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8097,6 +8127,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -9152,6 +9191,12 @@ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -9473,6 +9518,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", + "peer": true, "dependencies": { "@jest/core": "^27.5.1", "import-local": "^3.0.2", @@ -10318,6 +10364,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -10642,6 +10689,12 @@ "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -10669,6 +10722,21 @@ "yallist": "^3.0.2" } }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -10897,6 +10965,44 @@ "multicast-dns": "cli.js" } }, + "node_modules/mysql2": { + "version": "3.18.2", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.18.2.tgz", + "integrity": "sha512-UfEShBFAZZEAKjySnTUuE7BgqkYT4mx+RjoJ5aqtmwSSvNcJ/QxQPXz/y3jSxNiVRedPfgccmuBtiPCSiEEytw==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.3.3" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -10907,6 +11013,18 @@ "thenify-all": "^1.0.0" } }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -11555,6 +11673,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12621,6 +12740,7 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -12940,6 +13060,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -13066,6 +13187,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -13088,6 +13210,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13524,6 +13647,7 @@ "version": "2.79.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -13758,6 +13882,7 @@ "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14220,6 +14345,21 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, + "node_modules/sql-escaper": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz", + "integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, "node_modules/stable": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", @@ -14973,19 +15113,6 @@ } } }, - "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -15295,6 +15422,7 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "peer": true, "engines": { "node": ">=10" }, @@ -15681,6 +15809,7 @@ "version": "5.102.1", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -15750,6 +15879,7 @@ "version": "4.15.2", "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz", "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", + "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -16137,6 +16267,7 @@ "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/milventory/package.json b/milventory/package.json index b50131f..6ebb753 100644 --- a/milventory/package.json +++ b/milventory/package.json @@ -3,12 +3,13 @@ "version": "1.0.0", "private": true, "dependencies": { + "d3": "^7.8.5", + "http-proxy-middleware": "^2.0.6", + "mysql2": "^3.18.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.8.0", - "react-scripts": "5.0.1", - "d3": "^7.8.5", - "http-proxy-middleware": "^2.0.6" + "react-scripts": "5.0.1" }, "scripts": { "start": "react-scripts start", @@ -34,4 +35,3 @@ ] } } - diff --git a/milventory/src/App.js b/milventory/src/App.js index 55c5340..707f290 100644 --- a/milventory/src/App.js +++ b/milventory/src/App.js @@ -9,6 +9,8 @@ import EditModal from './components/EditForm'; import AddModePreview from './components/AddModePreview'; import Login from './components/Login'; import ErrorToast from './components/ErrorToast'; +import ConflictErrorModal from './components/ConflictErrorModal'; +import HistoryModal from './components/HistoryModal'; import AdminDashboard from './components/AdminDashboard'; import { auth } from './api'; @@ -124,8 +126,9 @@ function ProtectedRoute({ children, requireLeader = false }) { } function AppContent({ user, onLogout }) { - const { wrapRef, svgRef, isLoading, error, setError } = useInventory(); + const { wrapRef, svgRef, isLoading, error, setError, conflictError, setConflictError } = useInventory(); const navigate = useNavigate(); + const [showHistoryModal, setShowHistoryModal] = useState(false); // Handle 401 errors by logging out useEffect(() => { @@ -177,6 +180,27 @@ function AppContent({ user, onLogout }) { Admin Dashboard )} + +

⚠️ Action Failed

+

{message}

+ {supplyName && ( +

Item: {supplyName}

+ )} +
+ {onRefresh && ( + + )} + +
+
+ + ); +}; + +export default ConflictErrorModal; diff --git a/milventory/src/components/HistoryModal.css b/milventory/src/components/HistoryModal.css new file mode 100644 index 0000000..2f9ea93 --- /dev/null +++ b/milventory/src/components/HistoryModal.css @@ -0,0 +1,195 @@ +.history-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + justify-content: center; + align-items: center; + z-index: 10000; +} + +.history-modal { + background: var(--panel, #0e1116); + border-radius: 8px; + width: 90%; + max-width: 1400px; + height: 85%; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + color: var(--text, #e6ebf4); + border: 1px solid rgba(255,255,255,.1); +} + +.history-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid rgba(255,255,255,.1); +} + +.history-modal-header h2 { + margin: 0; + font-size: 1.5rem; + color: var(--text, #e6ebf4); +} + +.history-modal-close { + background: none; + border: none; + font-size: 2rem; + cursor: pointer; + color: var(--text, #e6ebf4); + line-height: 1; + padding: 0; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; +} + +.history-modal-close:hover { + color: var(--accent, #9bb7ff); +} + +.history-modal-filters { + display: flex; + gap: 1rem; + padding: 1rem 1.5rem; + border-bottom: 1px solid rgba(255,255,255,.1); +} + +.history-filter-select { + padding: 0.5rem; + border: 1px solid rgba(255,255,255,.15); + border-radius: 4px; + background: rgba(0,0,0,.3); + color: var(--text, #e6ebf4); + font-size: 0.9rem; +} + +.history-filter-select:focus { + outline: none; + border-color: var(--accent, #9bb7ff); +} + +.history-filter-search { + flex: 1; + padding: 0.5rem; + border: 1px solid rgba(255,255,255,.15); + border-radius: 4px; + background: rgba(0,0,0,.3); + color: var(--text, #e6ebf4); + font-size: 0.9rem; +} + +.history-filter-search:focus { + outline: none; + border-color: var(--accent, #9bb7ff); +} + +.history-error { + padding: 1rem 1.5rem; + background: rgba(183, 42, 42, 0.2); + color: #ff6b6b; + border-bottom: 1px solid rgba(255,255,255,.1); +} + +.history-modal-content { + flex: 1; + overflow-y: auto; + padding: 0; +} + +.history-loading, +.history-empty { + padding: 2rem; + text-align: center; + color: var(--muted, #9aa8c2); +} + +.history-table { + width: 100%; + border-collapse: collapse; +} + +.history-table thead { + position: sticky; + top: 0; + background: var(--panel, #0e1116); + z-index: 10; +} + +.history-table th { + padding: 1rem 0.75rem; + text-align: left; + font-weight: 600; + border-bottom: 2px solid rgba(255,255,255,.1); + color: var(--text, #e6ebf4); + background: var(--panel, #0e1116); +} + +.history-th-timestamp { + width: 150px; +} + +.history-th-action { + width: 100px; +} + +.history-th-item { + width: 200px; +} + +.history-th-changes { + /* flex column */ +} + +.history-th-user { + width: 200px; +} + +.history-th-actions { + width: 100px; + text-align: center; +} + +.history-modal-pagination { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-top: 1px solid rgba(255,255,255,.1); +} + +.history-pagination-btn { + padding: 0.5rem 1rem; + background: rgba(0,0,0,.3); + color: var(--text, #e6ebf4); + border: 1px solid rgba(255,255,255,.15); + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; +} + +.history-pagination-btn:hover:not(:disabled) { + background: rgba(255,255,255,.1); +} + +.history-pagination-btn:disabled { + background: rgba(0,0,0,.2); + color: var(--muted, #9aa8c2); + border-color: rgba(255,255,255,.05); + cursor: not-allowed; +} + +.history-pagination-info { + color: var(--text, #e6ebf4); + font-size: 0.9rem; +} diff --git a/milventory/src/components/HistoryModal.js b/milventory/src/components/HistoryModal.js new file mode 100644 index 0000000..64a3be2 --- /dev/null +++ b/milventory/src/components/HistoryModal.js @@ -0,0 +1,171 @@ +import React, { useState, useEffect } from 'react'; +import { api } from '../api'; +import HistoryTableRow from './HistoryTableRow'; +import './HistoryModal.css'; + +const HistoryModal = ({ isOpen, onClose }) => { + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [filters, setFilters] = useState({ + action_type: '', + search: '', + limit: 100, + offset: 0 + }); + const [total, setTotal] = useState(0); + + useEffect(() => { + if (isOpen) { + loadHistory(); + } + }, [isOpen, filters]); + + const loadHistory = async () => { + setLoading(true); + setError(null); + try { + const response = await api.getSupplyHistory({ + action_type: filters.action_type || undefined, + limit: filters.limit, + offset: filters.offset + }); + + let filtered = response.history; + + // Client-side search filter + if (filters.search) { + const searchLower = filters.search.toLowerCase(); + filtered = filtered.filter(entry => + entry.supply_name?.toLowerCase().includes(searchLower) + ); + } + + setHistory(filtered); + setTotal(response.total); + } catch (err) { + setError(err.message || 'Failed to load history'); + } finally { + setLoading(false); + } + }; + + const handleUndo = async (historyId) => { + try { + await api.undoSupplyHistory(historyId); + // Reload history after undo + await loadHistory(); + } catch (err) { + setError(err.message || 'Failed to undo action'); + } + }; + + const handleKeyDown = (e) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + useEffect(() => { + if (isOpen) { + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + } + }, [isOpen]); + + if (!isOpen) return null; + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+

Item History

+ +
+ +
+ + + setFilters({ ...filters, search: e.target.value, offset: 0 })} + className="history-filter-search" + /> +
+ + {error && ( +
+ {error} +
+ )} + +
+ {loading ? ( +
Loading history...
+ ) : history.length === 0 ? ( +
No history entries found
+ ) : ( + + + + + + + + + + + + + {history.map(entry => ( + + ))} + +
TimestampActionItem NameChangesChanged ByActions
+ )} +
+ + {total > filters.limit && ( +
+ + + Showing {filters.offset + 1} - {Math.min(filters.offset + filters.limit, total)} of {total} + + +
+ )} +
+
+ ); +}; + +export default HistoryModal; diff --git a/milventory/src/components/HistoryTableRow.css b/milventory/src/components/HistoryTableRow.css new file mode 100644 index 0000000..4ec0ec4 --- /dev/null +++ b/milventory/src/components/HistoryTableRow.css @@ -0,0 +1,135 @@ +.history-row { + border-bottom: 1px solid rgba(255,255,255,.1); +} + +.history-row:hover { + background-color: rgba(255,255,255,.05); +} + +.history-row-undone { + opacity: 0.6; + background-color: rgba(0,0,0,.2); +} + +.history-timestamp { + padding: 0.75rem; + font-size: 0.9rem; + color: var(--muted, #9aa8c2); + white-space: nowrap; + width: 150px; +} + +.history-action { + padding: 0.75rem; + width: 100px; +} + +.history-badge { + display: inline-block; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.85rem; + font-weight: 600; + text-transform: uppercase; +} + +.history-badge-create { + background-color: #4caf50; + color: white; +} + +.history-badge-update { + background-color: #2196f3; + color: white; +} + +.history-badge-delete { + background-color: #f44336; + color: white; +} + +.history-badge-default { + background-color: var(--table, #e0e0e0); + color: var(--text, #333); +} + +.history-item-name { + padding: 0.75rem; + font-weight: 500; + width: 200px; + color: var(--text, #e6ebf4); +} + +.history-changes { + padding: 0.75rem; + flex: 1; + color: var(--muted, #9aa8c2); + font-size: 0.9rem; +} + +.history-changed-by { + padding: 0.75rem; + width: 200px; + color: var(--muted, #9aa8c2); +} + +.history-actions { + padding: 0.75rem; + width: 100px; + text-align: center; +} + +.history-undo-btn { + padding: 0.4rem 0.8rem; + background: rgba(0,0,0,.3); + color: var(--text, #e6ebf4); + border: 1px solid rgba(255,255,255,.15); + border-radius: 4px; + cursor: pointer; + font-size: 0.85rem; + transition: background-color 0.2s; +} + +.history-undo-btn:hover:not(:disabled) { + background: rgba(255,255,255,.1); +} + +.history-undo-btn:disabled { + background: rgba(0,0,0,.2); + color: var(--muted, #9aa8c2); + border-color: rgba(255,255,255,.05); + cursor: not-allowed; +} + +.history-undo-confirm { + display: flex; + gap: 0.5rem; +} + +.history-undo-confirm-btn { + padding: 0.4rem 0.8rem; + background: #1e8a4a; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.85rem; +} + +.history-undo-confirm-btn:hover { + background: #1a7a3f; +} + +.history-undo-cancel-btn { + padding: 0.4rem 0.8rem; + background: rgba(0,0,0,.3); + color: var(--text, #e6ebf4); + border: 1px solid rgba(255,255,255,.15); + border-radius: 4px; + cursor: pointer; + font-size: 0.85rem; +} + +.history-undo-cancel-btn:hover { + background: rgba(255,255,255,.1); +} diff --git a/milventory/src/components/HistoryTableRow.js b/milventory/src/components/HistoryTableRow.js new file mode 100644 index 0000000..a56e319 --- /dev/null +++ b/milventory/src/components/HistoryTableRow.js @@ -0,0 +1,129 @@ +import React, { useState } from 'react'; +import './HistoryTableRow.css'; + +const HistoryTableRow = ({ entry, onUndo }) => { + const [showConfirm, setShowConfirm] = useState(false); + + const formatDate = (dateString) => { + if (!dateString) return 'N/A'; + const date = new Date(dateString); + return date.toLocaleString(); + }; + + const getActionBadgeClass = (actionType) => { + switch (actionType) { + case 'CREATE': + return 'history-badge-create'; + case 'UPDATE': + return 'history-badge-update'; + case 'DELETE': + return 'history-badge-delete'; + default: + return 'history-badge-default'; + } + }; + + const formatChangesSummary = () => { + const changes = []; + + if (entry.old_name !== entry.new_name) { + changes.push(`Name: "${entry.old_name || 'N/A'}" → "${entry.new_name || 'N/A'}"`); + } + + if (entry.old_description !== entry.new_description) { + changes.push('Description changed'); + } + + if (entry.old_image !== entry.new_image) { + changes.push('Image changed'); + } + + if (entry.old_last_order_date !== entry.new_last_order_date) { + changes.push('Last order date changed'); + } + + if (entry.team_changes && entry.team_changes.length > 0) { + const added = entry.team_changes.filter(t => t.action === 'ADDED').map(t => t.team_name); + const removed = entry.team_changes.filter(t => t.action === 'REMOVED').map(t => t.team_name); + const teamChanges = []; + if (added.length > 0) teamChanges.push(`${added.join(', ')} added`); + if (removed.length > 0) teamChanges.push(`${removed.join(', ')} removed`); + if (teamChanges.length > 0) { + changes.push(`Teams: ${teamChanges.join(', ')}`); + } + } + + if (entry.category_changes && entry.category_changes.length > 0) { + const added = entry.category_changes.filter(c => c.action === 'ADDED').map(c => c.category_id); + const removed = entry.category_changes.filter(c => c.action === 'REMOVED').map(c => c.category_id); + const catChanges = []; + if (added.length > 0) catChanges.push(`Categories ${added.join(', ')} added`); + if (removed.length > 0) catChanges.push(`Categories ${removed.join(', ')} removed`); + if (catChanges.length > 0) { + changes.push(catChanges.join(', ')); + } + } + + if (changes.length === 0) { + return entry.action_type === 'CREATE' ? 'Item created' : 'No changes detected'; + } + + return changes.join('; '); + }; + + const handleUndoClick = () => { + if (showConfirm) { + onUndo(entry.id); + setShowConfirm(false); + } else { + setShowConfirm(true); + } + }; + + return ( + + {formatDate(entry.changed_at)} + + + {entry.action_type} + + + {entry.supply_name || 'N/A'} + {formatChangesSummary()} + + + {entry.changed_by_name || entry.changed_by || 'Unknown'} + + + + {showConfirm ? ( +
+ + +
+ ) : ( + + )} + + + ); +}; + +export default HistoryTableRow; diff --git a/milventory/src/context/InventoryContext.js b/milventory/src/context/InventoryContext.js index 842f98f..bbe1894 100644 --- a/milventory/src/context/InventoryContext.js +++ b/milventory/src/context/InventoryContext.js @@ -1,6 +1,6 @@ import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react'; import * as d3 from 'd3'; -import { api, admin } from '../api'; +import { api, admin, handleApiError } from '../api'; const InventoryContext = createContext(null); @@ -35,6 +35,7 @@ export const InventoryProvider = ({ children }) => { // Loading and error states const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [conflictError, setConflictError] = useState(null); // Supply name to ID mapping (for API calls) const [supplyNameToId, setSupplyNameToId] = useState(new Map()); @@ -544,7 +545,12 @@ export const InventoryProvider = ({ children }) => { console.error('Error finishing add mode:', error); // Only set error if not panning (to avoid breaking pan) if (!isPanningRef.current) { - setError(error.message || 'Failed to add items'); + const errorInfo = await handleApiError(error); + if (errorInfo.isConflict) { + setConflictError(errorInfo); + } else { + setError(errorInfo.message); + } } } }, [inventoryData, supplyNameToId, reloadSupplyLocations]); @@ -1054,7 +1060,12 @@ export const InventoryProvider = ({ children }) => { console.error('Error adding Master item:', error); // Only set error if not panning (to avoid breaking pan) if (!isPanningRef.current) { - setError(error.message || 'Failed to add item'); + const errorInfo = await handleApiError(error); + if (errorInfo.isConflict) { + setConflictError(errorInfo); + } else { + setError(errorInfo.message); + } } throw error; } @@ -1120,7 +1131,12 @@ export const InventoryProvider = ({ children }) => { console.error('Error updating Master item:', error); // Only set error if not panning (to avoid breaking pan) if (!isPanningRef.current) { - setError(error.message || 'Failed to update item'); + const errorInfo = await handleApiError(error); + if (errorInfo.isConflict) { + setConflictError(errorInfo); + } else { + setError(errorInfo.message); + } } throw error; } @@ -1167,7 +1183,12 @@ export const InventoryProvider = ({ children }) => { console.error('Error deleting Master item:', error); // Only set error if not panning (to avoid breaking pan) if (!isPanningRef.current) { - setError(error.message || 'Failed to delete item'); + const errorInfo = await handleApiError(error); + if (errorInfo.isConflict) { + setConflictError(errorInfo); + } else { + setError(errorInfo.message); + } } throw error; } @@ -1199,6 +1220,8 @@ export const InventoryProvider = ({ children }) => { isLoading, error, setError, + conflictError, + setConflictError, // Setters setInventoryData, setSelectedBox, diff --git a/src/api/helpers/__init__.py b/src/api/helpers/__init__.py new file mode 100644 index 0000000..4e9b250 --- /dev/null +++ b/src/api/helpers/__init__.py @@ -0,0 +1,3 @@ +""" +Helper modules for the API. +""" diff --git a/src/api/helpers/history.py b/src/api/helpers/history.py new file mode 100644 index 0000000..3573bd1 --- /dev/null +++ b/src/api/helpers/history.py @@ -0,0 +1,177 @@ +""" +History tracking helper functions for supplies. +""" +import sys +from pathlib import Path + +# Add src to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from src.api.db import get_db + + +def log_supply_history(conn, supply_id, action_type, old_values, new_values, changed_by, undo_action_id=None): + """ + Log a history entry for a supply change. + + Args: + conn: Database connection + supply_id: Supply ID (can be None for CREATE before insert) + action_type: 'CREATE', 'UPDATE', or 'DELETE' + old_values: Dict with old_name, old_description, old_image, old_last_order_date + new_values: Dict with new_name, new_description, new_image, new_last_order_date + changed_by: UF ID of user making the change + undo_action_id: Optional ID of history entry that undid this action + + Returns: + History entry ID + """ + cur = conn.cursor() + + try: + cur.execute(""" + INSERT INTO supplies_history ( + supply_id, action_type, + old_name, new_name, + old_description, new_description, + old_image, new_image, + old_last_order_date, new_last_order_date, + changed_by, undo_action_id + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, ( + supply_id, + action_type, + old_values.get('name'), + new_values.get('name'), + old_values.get('description'), + new_values.get('description'), + old_values.get('image'), + new_values.get('image'), + old_values.get('last_order_date'), + new_values.get('last_order_date'), + changed_by, + undo_action_id + )) + + history_id = cur.lastrowid + return history_id + finally: + cur.close() + + +def log_team_changes(conn, history_id, old_teams, new_teams): + """ + Log team changes for a history entry. + + Args: + conn: Database connection + history_id: History entry ID + old_teams: List of old team names + new_teams: List of new team names + """ + cur = conn.cursor() + + try: + old_set = set(old_teams or []) + new_set = set(new_teams or []) + + # Teams that were removed + removed = old_set - new_set + for team_name in removed: + cur.execute(""" + INSERT INTO supplies_history_teams (history_id, team_name, action) + VALUES (%s, %s, 'REMOVED') + """, (history_id, team_name)) + + # Teams that were added + added = new_set - old_set + for team_name in added: + cur.execute(""" + INSERT INTO supplies_history_teams (history_id, team_name, action) + VALUES (%s, %s, 'ADDED') + """, (history_id, team_name)) + finally: + cur.close() + + +def log_category_changes(conn, history_id, old_categories, new_categories): + """ + Log category changes for a history entry. + + Args: + conn: Database connection + history_id: History entry ID + old_categories: List of old category IDs + new_categories: List of new category IDs + """ + cur = conn.cursor() + + try: + old_set = set(old_categories or []) + new_set = set(new_categories or []) + + # Categories that were removed + removed = old_set - new_set + for category_id in removed: + cur.execute(""" + INSERT INTO supplies_history_categories (history_id, category_id, action) + VALUES (%s, %s, 'REMOVED') + """, (history_id, category_id)) + + # Categories that were added + added = new_set - old_set + for category_id in added: + cur.execute(""" + INSERT INTO supplies_history_categories (history_id, category_id, action) + VALUES (%s, %s, 'ADDED') + """, (history_id, category_id)) + finally: + cur.close() + + +def get_supply_current_state(conn, supply_id): + """ + Get current state of a supply including teams and categories. + + Args: + conn: Database connection + supply_id: Supply ID + + Returns: + Dict with supply data, teams, and categories + """ + cur = conn.cursor(dictionary=True) + + try: + # Get supply + cur.execute(""" + SELECT id, name, description, image, last_order_date + FROM supplies WHERE id = %s + """, (supply_id,)) + supply = cur.fetchone() + + if not supply: + return None + + # Get teams + cur.execute(""" + SELECT team_name FROM supplies_teams WHERE supply_id = %s + """, (supply_id,)) + teams = [row['team_name'] for row in cur.fetchall()] + + # Get categories + cur.execute(""" + SELECT category_id FROM supplies_categories WHERE supply_id = %s + """, (supply_id,)) + categories = [row['category_id'] for row in cur.fetchall()] + + return { + 'name': supply['name'], + 'description': supply['description'], + 'image': supply['image'], + 'last_order_date': supply['last_order_date'], + 'teams': teams, + 'categories': categories + } + finally: + cur.close() diff --git a/src/api/routes/supplies.py b/src/api/routes/supplies.py index 5a296e6..8b520b7 100644 --- a/src/api/routes/supplies.py +++ b/src/api/routes/supplies.py @@ -12,6 +12,12 @@ from src.api.db import get_db from src.api.models.supply import Supply from src.api.middleware.auth import require_auth +from src.api.helpers.history import ( + log_supply_history, + log_team_changes, + log_category_changes, + get_supply_current_state +) supplies_bp = Blueprint('supplies', __name__) @@ -321,6 +327,37 @@ def create_supply(current_user_id=None): # Skip invalid category IDs continue + # Log history for CREATE action + old_values = {} + new_values = { + 'name': data['name'].strip(), + 'description': data.get('description', '').strip() or None, + 'image': data.get('image') or None, + 'last_order_date': data.get('last_order_date') or None + } + history_id = log_supply_history( + conn, supply_id, 'CREATE', old_values, new_values, current_user_id + ) + + # Log team and category changes + old_teams = [] + new_teams = [t.capitalize() if t.lower() in ['software', 'electrical', 'mechanical'] else t.capitalize() + for t in (data.get('teams') or [])] + # Normalize team names properly + normalized_teams = [] + for team in (data.get('teams') or []): + if team.lower() == 'software': + normalized_teams.append('Software') + elif team.lower() == 'electrical': + normalized_teams.append('Electrical') + elif team.lower() == 'mechanical': + normalized_teams.append('Mechanical') + else: + normalized_teams.append(team.capitalize()) + + log_team_changes(conn, history_id, old_teams, normalized_teams) + log_category_changes(conn, history_id, [], data.get('categories') or []) + conn.commit() # Fetch the created supply with teams and categories @@ -398,12 +435,29 @@ def update_supply(supply_id, current_user_id=None): conn = get_db() cur = conn.cursor(dictionary=True) - # Check if supply exists - cur.execute("SELECT id FROM supplies WHERE id = %s", (supply_id,)) - if not cur.fetchone(): + # Check if supply exists (with conflict detection) + cur.execute("SELECT id, name FROM supplies WHERE id = %s", (supply_id,)) + supply_check = cur.fetchone() + if not supply_check: cur.close() conn.close() - return jsonify({'error': 'Supply not found'}), 404 + return jsonify({ + 'error': 'Supply not found', + 'error_type': 'SUPPLY_DELETED', + 'supply_id': supply_id, + 'message': 'This item was deleted by another user. Please refresh the page to see the latest data.' + }), 404 + + # Get current state before update for history + current_state = get_supply_current_state(conn, supply_id) + old_values = { + 'name': current_state['name'], + 'description': current_state['description'], + 'image': current_state['image'], + 'last_order_date': current_state['last_order_date'] + } + old_teams = current_state['teams'] + old_categories = current_state['categories'] # Check if name is being changed and new name already exists if 'name' in data and data['name']: @@ -478,6 +532,37 @@ def update_supply(supply_id, current_user_id=None): except (ValueError, TypeError): continue + # Log history for UPDATE action + new_values = { + 'name': data.get('name', old_values['name']).strip() if 'name' in data else old_values['name'], + 'description': (data.get('description', '').strip() or None) if 'description' in data else old_values['description'], + 'image': data.get('image') if 'image' in data else old_values['image'], + 'last_order_date': data.get('last_order_date') if 'last_order_date' in data else old_values['last_order_date'] + } + history_id = log_supply_history( + conn, supply_id, 'UPDATE', old_values, new_values, current_user_id + ) + + # Log team and category changes + new_teams = [] + if 'teams' in data: + for team in (data.get('teams') or []): + if team.lower() == 'software': + new_teams.append('Software') + elif team.lower() == 'electrical': + new_teams.append('Electrical') + elif team.lower() == 'mechanical': + new_teams.append('Mechanical') + else: + new_teams.append(team.capitalize()) + else: + new_teams = old_teams + + new_categories = data.get('categories', old_categories) if 'categories' in data else old_categories + + log_team_changes(conn, history_id, old_teams, new_teams) + log_category_changes(conn, history_id, old_categories, new_categories) + conn.commit() # Fetch updated supply @@ -570,15 +655,42 @@ def delete_supply(supply_id, current_user_id=None): """ try: conn = get_db() - cur = conn.cursor() + cur = conn.cursor(dictionary=True) - # Check if supply exists - cur.execute("SELECT id FROM supplies WHERE id = %s", (supply_id,)) - if not cur.fetchone(): + # Check if supply exists (with conflict detection) + cur.execute("SELECT id, name FROM supplies WHERE id = %s", (supply_id,)) + supply_check = cur.fetchone() + if not supply_check: cur.close() conn.close() - return jsonify({'error': 'Supply not found'}), 404 + return jsonify({ + 'error': 'Supply not found', + 'error_type': 'SUPPLY_DELETED', + 'supply_id': supply_id, + 'message': 'This item was already deleted by another user. Please refresh the page to see the latest data.' + }), 404 + + # Get current state before delete for history + current_state = get_supply_current_state(conn, supply_id) + old_values = { + 'name': current_state['name'], + 'description': current_state['description'], + 'image': current_state['image'], + 'last_order_date': current_state['last_order_date'] + } + old_teams = current_state['teams'] + old_categories = current_state['categories'] + + # Log history for DELETE action (before actual delete) + history_id = log_supply_history( + conn, supply_id, 'DELETE', old_values, {}, current_user_id + ) + + # Log all teams and categories as REMOVED + log_team_changes(conn, history_id, old_teams, []) + log_category_changes(conn, history_id, old_categories, []) + # Now delete the supply (CASCADE will handle related tables) cur.execute("DELETE FROM supplies WHERE id = %s", (supply_id,)) conn.commit() cur.close() @@ -587,3 +699,323 @@ def delete_supply(supply_id, current_user_id=None): return '', 204 except Exception as e: return jsonify({'error': str(e)}), 500 + + +@supplies_bp.route('/history', methods=['GET']) +@require_auth +def get_supply_history(current_user_id=None): + """ + GET /api/supplies/history + Get history of all supply changes. + + Query Parameters: + supply_id (optional): Filter by specific supply + action_type (optional): Filter by action type (CREATE, UPDATE, DELETE) + limit (optional): Limit results (default: 100) + offset (optional): Pagination offset + + Returns: + JSON object with history array and total count + """ + try: + supply_id_filter = request.args.get('supply_id', type=int) + action_type_filter = request.args.get('action_type') + limit = request.args.get('limit', 100, type=int) + offset = request.args.get('offset', 0, type=int) + + conn = get_db() + cur = conn.cursor(dictionary=True) + + # Build query + query = """ + SELECT + h.id, + h.supply_id, + h.action_type, + h.old_name, + h.new_name, + h.old_description, + h.new_description, + h.old_image, + h.new_image, + h.old_last_order_date, + h.new_last_order_date, + h.changed_by, + h.changed_at, + h.undo_action_id, + COALESCE(s.name, h.old_name, h.new_name) as supply_name + FROM supplies_history h + LEFT JOIN supplies s ON h.supply_id = s.id + WHERE 1=1 + """ + params = [] + + if supply_id_filter: + query += " AND h.supply_id = %s" + params.append(supply_id_filter) + + if action_type_filter: + query += " AND h.action_type = %s" + params.append(action_type_filter) + + # Get total count + count_query = f"SELECT COUNT(*) as total FROM ({query}) as filtered" + cur.execute(count_query, params) + total = cur.fetchone()['total'] + + # Get paginated results + query += " ORDER BY h.changed_at DESC LIMIT %s OFFSET %s" + params.extend([limit, offset]) + cur.execute(query, params) + + history_entries = [] + for row in cur.fetchall(): + # Get user info + cur.execute(""" + SELECT first_name, last_name, uf_email + FROM members WHERE uf_id = %s + """, (row['changed_by'],)) + user = cur.fetchone() + + # Get team changes + cur.execute(""" + SELECT team_name, action + FROM supplies_history_teams + WHERE history_id = %s + """, (row['id'],)) + team_changes = [{'team_name': t['team_name'], 'action': t['action']} + for t in cur.fetchall()] + + # Get category changes + cur.execute(""" + SELECT category_id, action + FROM supplies_history_categories + WHERE history_id = %s + """, (row['id'],)) + category_changes = [{'category_id': c['category_id'], 'action': c['action']} + for c in cur.fetchall()] + + # Check if can be undone (not already undone and supply still exists or was deleted) + can_undo = row['undo_action_id'] is None + if can_undo and row['action_type'] == 'DELETE': + # DELETE can always be undone (recreate) + can_undo = True + elif can_undo and row['action_type'] == 'CREATE': + # CREATE can be undone if supply still exists + can_undo = row['supply_id'] is not None + elif can_undo and row['action_type'] == 'UPDATE': + # UPDATE can be undone if supply still exists + can_undo = row['supply_id'] is not None + + history_entry = { + 'id': row['id'], + 'supply_id': row['supply_id'], + 'supply_name': row['supply_name'], + 'action_type': row['action_type'], + 'old_name': row['old_name'], + 'new_name': row['new_name'], + 'old_description': row['old_description'], + 'new_description': row['new_description'], + 'old_image': row['old_image'], + 'new_image': row['new_image'], + 'old_last_order_date': row['old_last_order_date'].isoformat() if row['old_last_order_date'] else None, + 'new_last_order_date': row['new_last_order_date'].isoformat() if row['new_last_order_date'] else None, + 'changed_by': row['changed_by'], + 'changed_by_name': f"{user['first_name']} {user['last_name']}" if user else None, + 'changed_by_email': user['uf_email'] if user else None, + 'changed_at': row['changed_at'].isoformat() if row['changed_at'] else None, + 'can_undo': can_undo, + 'is_undone': row['undo_action_id'] is not None, + 'team_changes': team_changes, + 'category_changes': category_changes + } + history_entries.append(history_entry) + + cur.close() + conn.close() + + return jsonify({ + 'history': history_entries, + 'total': total + }), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@supplies_bp.route('/history//undo', methods=['POST']) +@require_auth +def undo_supply_history(history_id, current_user_id=None): + """ + POST /api/supplies/history//undo + Undo a specific history entry. + + Args: + history_id: History entry ID to undo + + Returns: + JSON object with success message and updated history entry + """ + try: + conn = get_db() + cur = conn.cursor(dictionary=True) + + # Get history entry + cur.execute(""" + SELECT * FROM supplies_history WHERE id = %s + """, (history_id,)) + history = cur.fetchone() + + if not history: + cur.close() + conn.close() + return jsonify({'error': 'History entry not found'}), 404 + + if history['undo_action_id'] is not None: + cur.close() + conn.close() + return jsonify({'error': 'This action has already been undone'}), 400 + + # Get team and category changes + cur.execute(""" + SELECT team_name, action FROM supplies_history_teams WHERE history_id = %s + """, (history_id,)) + team_changes = cur.fetchall() + + cur.execute(""" + SELECT category_id, action FROM supplies_history_categories WHERE history_id = %s + """, (history_id,)) + category_changes = cur.fetchall() + + # Perform undo based on action type + if history['action_type'] == 'CREATE': + # Undo CREATE: Delete the supply + if history['supply_id']: + cur.execute("DELETE FROM supplies WHERE id = %s", (history['supply_id'],)) + + elif history['action_type'] == 'UPDATE': + # Undo UPDATE: Restore old values + if not history['supply_id']: + cur.close() + conn.close() + return jsonify({'error': 'Cannot undo: supply no longer exists'}), 400 + + # Check supply still exists + cur.execute("SELECT id FROM supplies WHERE id = %s", (history['supply_id'],)) + if not cur.fetchone(): + cur.close() + conn.close() + return jsonify({'error': 'Cannot undo: supply no longer exists'}), 400 + + # Restore old values + updates = [] + values = [] + + if history['old_name']: + updates.append("name = %s") + values.append(history['old_name']) + if history['old_description'] is not None: + updates.append("description = %s") + values.append(history['old_description']) + if history['old_image'] is not None: + updates.append("image = %s") + values.append(history['old_image']) + if history['old_last_order_date'] is not None: + updates.append("last_order_date = %s") + values.append(history['old_last_order_date']) + + updates.append("last_modified_by = %s") + values.append(current_user_id) + values.append(history['supply_id']) + + if updates: + query = f"UPDATE supplies SET {', '.join(updates)} WHERE id = %s" + cur.execute(query, values) + + # Restore teams: Remove current, add back old teams + cur.execute("DELETE FROM supplies_teams WHERE supply_id = %s", (history['supply_id'],)) + for team_change in team_changes: + if team_change['action'] == 'REMOVED': + # This team was removed in the update, so restore it + cur.execute(""" + INSERT IGNORE INTO supplies_teams (supply_id, team_name) + VALUES (%s, %s) + """, (history['supply_id'], team_change['team_name'])) + + # Restore categories: Remove current, add back old categories + cur.execute("DELETE FROM supplies_categories WHERE supply_id = %s", (history['supply_id'],)) + for cat_change in category_changes: + if cat_change['action'] == 'REMOVED': + # This category was removed in the update, so restore it + cur.execute(""" + INSERT IGNORE INTO supplies_categories (supply_id, category_id) + VALUES (%s, %s) + """, (history['supply_id'], cat_change['category_id'])) + + elif history['action_type'] == 'DELETE': + # Undo DELETE: Recreate the supply + if not history['supply_id']: + cur.close() + conn.close() + return jsonify({'error': 'Cannot undo: supply ID not available'}), 400 + + # Recreate supply + cur.execute(""" + INSERT INTO supplies (id, name, description, image, last_order_date, last_modified_by) + VALUES (%s, %s, %s, %s, %s, %s) + """, ( + history['supply_id'], + history['old_name'], + history['old_description'], + history['old_image'], + history['old_last_order_date'], + current_user_id + )) + + # Recreate teams (all that were REMOVED in delete) + for team_change in team_changes: + if team_change['action'] == 'REMOVED': + cur.execute(""" + INSERT IGNORE INTO supplies_teams (supply_id, team_name) + VALUES (%s, %s) + """, (history['supply_id'], team_change['team_name'])) + + # Recreate categories (all that were REMOVED in delete) + for cat_change in category_changes: + if cat_change['action'] == 'REMOVED': + cur.execute(""" + INSERT IGNORE INTO supplies_categories (supply_id, category_id) + VALUES (%s, %s) + """, (history['supply_id'], cat_change['category_id'])) + + # Create undo history entry + undo_history_id = log_supply_history( + conn, + history['supply_id'], + history['action_type'], # Same action type for undo + history['new_name'] and {'name': history['new_name'], 'description': history['new_description'], + 'image': history['new_image'], 'last_order_date': history['new_last_order_date']} or {}, + history['old_name'] and {'name': history['old_name'], 'description': history['old_description'], + 'image': history['old_image'], 'last_order_date': history['old_last_order_date']} or {}, + current_user_id + ) + + # Mark original history as undone + cur.execute(""" + UPDATE supplies_history SET undo_action_id = %s WHERE id = %s + """, (undo_history_id, history_id)) + + conn.commit() + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'message': f'Successfully undid {history["action_type"]} action', + 'undo_history_id': undo_history_id + }), 200 + except mysql.connector.IntegrityError as e: + conn.rollback() + return jsonify({'error': str(e)}), 400 + except Exception as e: + conn.rollback() + return jsonify({'error': str(e)}), 500 diff --git a/src/api/routes/supplies_location.py b/src/api/routes/supplies_location.py index adbd19c..fff6a63 100644 --- a/src/api/routes/supplies_location.py +++ b/src/api/routes/supplies_location.py @@ -205,8 +205,23 @@ def add_supply_location(current_user_id=None): return jsonify({'error': 'Amount must be positive'}), 400 conn = get_db() - cur = conn.cursor() + cur = conn.cursor(dictionary=True) + # Check if supply exists (conflict detection) + cur.execute("SELECT id, name FROM supplies WHERE id = %s", (data['supply_id'],)) + supply = cur.fetchone() + if not supply: + cur.close() + conn.close() + return jsonify({ + 'error': 'Supply not found', + 'error_type': 'SUPPLY_DELETED', + 'supply_id': data['supply_id'], + 'supply_name': data.get('supply_name', 'Unknown'), + 'message': 'This item was deleted by another user. Please refresh the page to see the latest data.' + }), 404 + + cur = conn.cursor() # Switch back to regular cursor for rest of function shelf = data.get('shelf') location_name = data['location'] supply_id = data['supply_id'] diff --git a/src/sql/supplies_history/table_supplies_history.sql b/src/sql/supplies_history/table_supplies_history.sql new file mode 100644 index 0000000..671b5f8 --- /dev/null +++ b/src/sql/supplies_history/table_supplies_history.sql @@ -0,0 +1,32 @@ +CREATE TABLE supplies_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + supply_id BIGINT NOT NULL, + action_type ENUM('CREATE', 'UPDATE', 'DELETE') NOT NULL, + + -- Snapshot of data before/after change + old_name VARCHAR(200), + new_name VARCHAR(200), + old_description TEXT, + new_description TEXT, + old_image LONGTEXT, + new_image LONGTEXT, + old_last_order_date DATE, + new_last_order_date DATE, + + -- Metadata + changed_by CHAR(8), -- UF ID (nullable to allow member deletion) + changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + undo_action_id BIGINT NULL, -- Points to history entry that undid this action + + -- Foreign keys + CONSTRAINT fk_history_supply FOREIGN KEY (supply_id) REFERENCES supplies(id) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_history_changed_by FOREIGN KEY (changed_by) REFERENCES members(uf_id) + ON UPDATE CASCADE ON DELETE SET NULL, + CONSTRAINT fk_history_undo FOREIGN KEY (undo_action_id) REFERENCES supplies_history(id) + ON UPDATE CASCADE ON DELETE SET NULL, + + INDEX idx_supply_id (supply_id), + INDEX idx_changed_at (changed_at DESC), + INDEX idx_action_type (action_type) +); diff --git a/src/sql/supplies_history/table_supplies_history_categories.sql b/src/sql/supplies_history/table_supplies_history_categories.sql new file mode 100644 index 0000000..e5ce6ae --- /dev/null +++ b/src/sql/supplies_history/table_supplies_history_categories.sql @@ -0,0 +1,9 @@ +CREATE TABLE supplies_history_categories ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + history_id BIGINT NOT NULL, + category_id INT NOT NULL, + action ENUM('ADDED', 'REMOVED') NOT NULL, + + CONSTRAINT fk_history_categories_history FOREIGN KEY (history_id) REFERENCES supplies_history(id) + ON UPDATE CASCADE ON DELETE CASCADE +); diff --git a/src/sql/supplies_history/table_supplies_history_teams.sql b/src/sql/supplies_history/table_supplies_history_teams.sql new file mode 100644 index 0000000..7da585b --- /dev/null +++ b/src/sql/supplies_history/table_supplies_history_teams.sql @@ -0,0 +1,9 @@ +CREATE TABLE supplies_history_teams ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + history_id BIGINT NOT NULL, + team_name VARCHAR(50) NOT NULL, + action ENUM('ADDED', 'REMOVED') NOT NULL, + + CONSTRAINT fk_history_teams_history FOREIGN KEY (history_id) REFERENCES supplies_history(id) + ON UPDATE CASCADE ON DELETE CASCADE +); From 367f6bb9b6ff603e27c274fbfc883f89fc431fe3 Mon Sep 17 00:00:00 2001 From: willzoo Date: Mon, 2 Mar 2026 20:23:52 -0500 Subject: [PATCH 20/73] Changes history and undo feature - currently testing this feature --- milventory/src/components/HistoryModal.js | 6 ++- milventory/src/components/HistoryTableRow.css | 5 -- milventory/src/components/HistoryTableRow.js | 6 +-- milventory/src/components/MasterAddModal.js | 50 ------------------- milventory/src/context/InventoryContext.js | 41 +++++++++++++++ src/api/helpers/history.py | 10 ++-- src/api/routes/supplies.py | 38 +++----------- .../table_supplies_history.sql | 5 +- 8 files changed, 62 insertions(+), 99 deletions(-) diff --git a/milventory/src/components/HistoryModal.js b/milventory/src/components/HistoryModal.js index 64a3be2..4b7528b 100644 --- a/milventory/src/components/HistoryModal.js +++ b/milventory/src/components/HistoryModal.js @@ -1,9 +1,11 @@ import React, { useState, useEffect } from 'react'; import { api } from '../api'; +import { useInventory } from '../context/InventoryContext'; import HistoryTableRow from './HistoryTableRow'; import './HistoryModal.css'; const HistoryModal = ({ isOpen, onClose }) => { + const { reloadMasterItems } = useInventory(); const [history, setHistory] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -53,8 +55,10 @@ const HistoryModal = ({ isOpen, onClose }) => { const handleUndo = async (historyId) => { try { await api.undoSupplyHistory(historyId); - // Reload history after undo + // Reload history after undo (the undone entry will disappear) await loadHistory(); + // Reload master inventory to reflect changes + await reloadMasterItems(); } catch (err) { setError(err.message || 'Failed to undo action'); } diff --git a/milventory/src/components/HistoryTableRow.css b/milventory/src/components/HistoryTableRow.css index 4ec0ec4..9c4b918 100644 --- a/milventory/src/components/HistoryTableRow.css +++ b/milventory/src/components/HistoryTableRow.css @@ -6,11 +6,6 @@ background-color: rgba(255,255,255,.05); } -.history-row-undone { - opacity: 0.6; - background-color: rgba(0,0,0,.2); -} - .history-timestamp { padding: 0.75rem; font-size: 0.9rem; diff --git a/milventory/src/components/HistoryTableRow.js b/milventory/src/components/HistoryTableRow.js index a56e319..ff4541d 100644 --- a/milventory/src/components/HistoryTableRow.js +++ b/milventory/src/components/HistoryTableRow.js @@ -81,7 +81,7 @@ const HistoryTableRow = ({ entry, onUndo }) => { }; return ( - + {formatDate(entry.changed_at)} @@ -115,8 +115,8 @@ const HistoryTableRow = ({ entry, onUndo }) => { diff --git a/milventory/src/components/MasterAddModal.js b/milventory/src/components/MasterAddModal.js index 731e443..5588a9b 100644 --- a/milventory/src/components/MasterAddModal.js +++ b/milventory/src/components/MasterAddModal.js @@ -166,7 +166,6 @@ const MasterAddModal = ({ isOpen, onClose }) => { const [selectedTeams, setSelectedTeams] = useState([]); const [selectedCategories, setSelectedCategories] = useState([]); const [categorySearchQuery, setCategorySearchQuery] = useState(''); - const [teamSearchQuery, setTeamSearchQuery] = useState(''); const [availableCategories, setAvailableCategories] = useState([]); const [availableTeams, setAvailableTeams] = useState([]); const [categoryNameToId, setCategoryNameToId] = useState(new Map()); @@ -197,10 +196,8 @@ const MasterAddModal = ({ isOpen, onClose }) => { }); getTeams() .then(teams => { - console.log('Fetched teams from API:', teams); // Normalize to lowercase for frontend consistency const normalized = teams.map(t => t.toLowerCase()); - console.log('Normalized teams:', normalized); setAvailableTeams(normalized); }) .catch(err => { @@ -316,52 +313,6 @@ const MasterAddModal = ({ isOpen, onClose }) => { >

Add Master Item

- {teamSearchQuery.trim() && (() => { - const query = teamSearchQuery.toLowerCase(); - const unselected = availableTeams.filter(t => !selectedTeams.includes(t)); - const scored = unselected.map(item => { - const itemLower = item.toLowerCase(); - const distance = levenshteinDistance(query, itemLower); - const isSubstring = itemLower.includes(query); - return { item, score: isSubstring ? distance - 10 : distance, distance }; - }); - scored.sort((a, b) => a.score !== b.score ? a.score - b.score : a.distance - b.distance); - const matches = scored.slice(0, 5); - - return ( -
-
- Team Search Debug: -
-
- Query: "{teamSearchQuery}" -
-
- Selected: {selectedTeams.length > 0 ? selectedTeams.join(', ') : 'None'} -
-
- Available: {unselected.length} team{unselected.length !== 1 ? 's' : ''} remaining -
- {matches.length > 0 && ( -
- Top Matches: - {matches.map(({ item, distance }, index) => ( -
- {index + 1}. {item} (distance: {distance}) -
- ))} -
- )} - {matches.length === 0 && unselected.length > 0 && ( -
No fuzzy matches found.
- )} -
- ); - })()} { onSelect={(team) => setSelectedTeams(prev => [...prev, team])} onRemove={(team) => setSelectedTeams(prev => prev.filter(t => t !== team))} capitalize - onSearchChange={setTeamSearchQuery} /> { setSelectedMasterItem(null); }, []); + const reloadMasterItems = useCallback(async () => { + try { + const supplies = await api.getSupplies(); + + const newMasterItems = new Map(); + const nameToIdMap = new Map(); + + supplies.forEach(supply => { + // Build name to ID mapping + nameToIdMap.set(supply.name, supply.id); + + // Convert API response to Master item format + const locations = (supply.locations || []).map(loc => { + if (loc.shelf !== null && loc.shelf !== undefined) { + return `${loc.location} (Shelf ${loc.shelf})`; + } + return loc.location; + }); + + newMasterItems.set(supply.name, { + name: supply.name, + description: supply.description || '', + image: supply.image || null, + locations: locations, + teams: supply.teams || [], + categories: supply.categories || [], + lastModified: supply.lastModified || null, + last_modified_by: supply.last_modified_by || null, + last_modified_by_name: supply.last_modified_by_name || null, + id: supply.id + }); + }); + + setMasterInventoryItems(newMasterItems); + setSupplyNameToId(nameToIdMap); + } catch (error) { + console.error('Error reloading Master inventory items:', error); + } + }, []); + const value = { // State inventoryData, @@ -1256,6 +1296,7 @@ export const InventoryProvider = ({ children }) => { updateMasterItem, deleteMasterItem, clearSelectedMasterItem, + reloadMasterItems, // Add Mode addModeItem, addModeQtyPerClick, diff --git a/src/api/helpers/history.py b/src/api/helpers/history.py index 3573bd1..6d67ab0 100644 --- a/src/api/helpers/history.py +++ b/src/api/helpers/history.py @@ -10,7 +10,7 @@ from src.api.db import get_db -def log_supply_history(conn, supply_id, action_type, old_values, new_values, changed_by, undo_action_id=None): +def log_supply_history(conn, supply_id, action_type, old_values, new_values, changed_by): """ Log a history entry for a supply change. @@ -21,7 +21,6 @@ def log_supply_history(conn, supply_id, action_type, old_values, new_values, cha old_values: Dict with old_name, old_description, old_image, old_last_order_date new_values: Dict with new_name, new_description, new_image, new_last_order_date changed_by: UF ID of user making the change - undo_action_id: Optional ID of history entry that undid this action Returns: History entry ID @@ -36,8 +35,8 @@ def log_supply_history(conn, supply_id, action_type, old_values, new_values, cha old_description, new_description, old_image, new_image, old_last_order_date, new_last_order_date, - changed_by, undo_action_id - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + changed_by + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( supply_id, action_type, @@ -49,8 +48,7 @@ def log_supply_history(conn, supply_id, action_type, old_values, new_values, cha new_values.get('image'), old_values.get('last_order_date'), new_values.get('last_order_date'), - changed_by, - undo_action_id + changed_by )) history_id = cur.lastrowid diff --git a/src/api/routes/supplies.py b/src/api/routes/supplies.py index 8b520b7..19acbd6 100644 --- a/src/api/routes/supplies.py +++ b/src/api/routes/supplies.py @@ -742,7 +742,6 @@ def get_supply_history(current_user_id=None): h.new_last_order_date, h.changed_by, h.changed_at, - h.undo_action_id, COALESCE(s.name, h.old_name, h.new_name) as supply_name FROM supplies_history h LEFT JOIN supplies s ON h.supply_id = s.id @@ -795,15 +794,15 @@ def get_supply_history(current_user_id=None): category_changes = [{'category_id': c['category_id'], 'action': c['action']} for c in cur.fetchall()] - # Check if can be undone (not already undone and supply still exists or was deleted) - can_undo = row['undo_action_id'] is None - if can_undo and row['action_type'] == 'DELETE': + # Check if can be undone (supply still exists or was deleted) + can_undo = False + if row['action_type'] == 'DELETE': # DELETE can always be undone (recreate) can_undo = True - elif can_undo and row['action_type'] == 'CREATE': + elif row['action_type'] == 'CREATE': # CREATE can be undone if supply still exists can_undo = row['supply_id'] is not None - elif can_undo and row['action_type'] == 'UPDATE': + elif row['action_type'] == 'UPDATE': # UPDATE can be undone if supply still exists can_undo = row['supply_id'] is not None @@ -825,7 +824,6 @@ def get_supply_history(current_user_id=None): 'changed_by_email': user['uf_email'] if user else None, 'changed_at': row['changed_at'].isoformat() if row['changed_at'] else None, 'can_undo': can_undo, - 'is_undone': row['undo_action_id'] is not None, 'team_changes': team_changes, 'category_changes': category_changes } @@ -870,11 +868,6 @@ def undo_supply_history(history_id, current_user_id=None): conn.close() return jsonify({'error': 'History entry not found'}), 404 - if history['undo_action_id'] is not None: - cur.close() - conn.close() - return jsonify({'error': 'This action has already been undone'}), 400 - # Get team and category changes cur.execute(""" SELECT team_name, action FROM supplies_history_teams WHERE history_id = %s @@ -987,22 +980,8 @@ def undo_supply_history(history_id, current_user_id=None): VALUES (%s, %s) """, (history['supply_id'], cat_change['category_id'])) - # Create undo history entry - undo_history_id = log_supply_history( - conn, - history['supply_id'], - history['action_type'], # Same action type for undo - history['new_name'] and {'name': history['new_name'], 'description': history['new_description'], - 'image': history['new_image'], 'last_order_date': history['new_last_order_date']} or {}, - history['old_name'] and {'name': history['old_name'], 'description': history['old_description'], - 'image': history['old_image'], 'last_order_date': history['old_last_order_date']} or {}, - current_user_id - ) - - # Mark original history as undone - cur.execute(""" - UPDATE supplies_history SET undo_action_id = %s WHERE id = %s - """, (undo_history_id, history_id)) + # Delete the history entry and all related data (CASCADE will handle teams/categories) + cur.execute("DELETE FROM supplies_history WHERE id = %s", (history_id,)) conn.commit() cur.close() @@ -1010,8 +989,7 @@ def undo_supply_history(history_id, current_user_id=None): return jsonify({ 'success': True, - 'message': f'Successfully undid {history["action_type"]} action', - 'undo_history_id': undo_history_id + 'message': f'Successfully undid {history["action_type"]} action' }), 200 except mysql.connector.IntegrityError as e: conn.rollback() diff --git a/src/sql/supplies_history/table_supplies_history.sql b/src/sql/supplies_history/table_supplies_history.sql index 671b5f8..5ee5090 100644 --- a/src/sql/supplies_history/table_supplies_history.sql +++ b/src/sql/supplies_history/table_supplies_history.sql @@ -1,6 +1,6 @@ CREATE TABLE supplies_history ( id BIGINT AUTO_INCREMENT PRIMARY KEY, - supply_id BIGINT NOT NULL, + supply_id BIGINT NULL, -- Nullable to allow history entries for deleted supplies action_type ENUM('CREATE', 'UPDATE', 'DELETE') NOT NULL, -- Snapshot of data before/after change @@ -16,15 +16,12 @@ CREATE TABLE supplies_history ( -- Metadata changed_by CHAR(8), -- UF ID (nullable to allow member deletion) changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - undo_action_id BIGINT NULL, -- Points to history entry that undid this action -- Foreign keys CONSTRAINT fk_history_supply FOREIGN KEY (supply_id) REFERENCES supplies(id) ON UPDATE CASCADE ON DELETE CASCADE, CONSTRAINT fk_history_changed_by FOREIGN KEY (changed_by) REFERENCES members(uf_id) ON UPDATE CASCADE ON DELETE SET NULL, - CONSTRAINT fk_history_undo FOREIGN KEY (undo_action_id) REFERENCES supplies_history(id) - ON UPDATE CASCADE ON DELETE SET NULL, INDEX idx_supply_id (supply_id), INDEX idx_changed_at (changed_at DESC), From 921c1a32e03233891c01ffdce4b74f79bad6f980 Mon Sep 17 00:00:00 2001 From: willzoo Date: Mon, 2 Mar 2026 20:35:12 -0500 Subject: [PATCH 21/73] fix seed data not seeding teams/cats --- src/api/app.py | 5 +- src/scripts/seed_data.py | 65 +++++++++++++------ .../table_supplies_history.sql | 2 +- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/src/api/app.py b/src/api/app.py index 2e2fc11..2bd349e 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -97,8 +97,9 @@ def initialize_schema(): conn.commit() if failed_tables: - print(f"⚠ Schema initialization incomplete: {success_count}/{len(missing_tables)} tables created") - print(f" Failed tables: {', '.join(failed_tables)}") + print(f"\n❌ SCHEMA INITIALIZATION FAILED: {success_count}/{len(missing_tables)} tables created successfully") + print(f"❌ FAILED TABLES ({len(failed_tables)}): {', '.join(failed_tables)}") + print("⚠️ The API will continue, but some endpoints may not work until these tables are created") else: print(f"✓ Schema initialization complete ({success_count}/{len(missing_tables)} tables created)") diff --git a/src/scripts/seed_data.py b/src/scripts/seed_data.py index e168c44..a8b012e 100644 --- a/src/scripts/seed_data.py +++ b/src/scripts/seed_data.py @@ -84,14 +84,19 @@ def derive_location_type(title): def ensure_all_tables_exist(conn, cur): - """Ensure all tables exist by creating missing ones.""" + """Ensure all tables exist by creating missing ones. + + Returns: + tuple: (success_count, failed_tables_list) where failed_tables_list is a list of table names that failed to create + """ + failed_tables = [] try: sql_base_path = get_sql_base_path(__file__) table_files = discover_table_files(sql_base_path) if not table_files: print("⚠ No table_*.sql files found") - return + return 0, [] sorted_tables = topological_sort_tables(table_files) missing_tables = [] @@ -102,18 +107,32 @@ def ensure_all_tables_exist(conn, cur): if missing_tables: print(f"📋 Creating {len(missing_tables)} missing table(s)...") + success_count = 0 for table_name, sql_file in missing_tables: description = f"{table_name} table" print(f" 🔨 Creating {table_name} from {sql_file.name}...") if execute_sql_file(cur, sql_file, description): print(f" ✓ {table_name} created") + success_count += 1 else: print(f" ✗ Failed to create {table_name}") + failed_tables.append(table_name) conn.commit() + + if failed_tables: + print(f"\n❌ TABLE CREATION FAILED: {success_count}/{len(missing_tables)} tables created successfully") + print(f"❌ FAILED TABLES ({len(failed_tables)}): {', '.join(failed_tables)}") + else: + print(f"✓ All {success_count} missing table(s) created successfully") + + return success_count, failed_tables + else: + return 0, [] except Exception as e: print(f"⚠ Warning while ensuring tables exist: {e}") import traceback traceback.print_exc() + return 0, failed_tables def get_db_connection(): @@ -168,11 +187,14 @@ def seed_categories(): cur = conn.cursor() # Ensure all tables exist (including categories) - ensure_all_tables_exist(conn, cur) + success_count, failed_tables = ensure_all_tables_exist(conn, cur) # Verify categories table exists if not table_exists(cur, 'categories'): - print("✗ Categories table still does not exist after creation attempt") + if 'categories' in failed_tables: + print("✗ Categories table failed to be created (see errors above)") + else: + print("✗ Categories table still does not exist after creation attempt") cur.close() conn.close() return @@ -227,11 +249,14 @@ def seed_locations(): cur = conn.cursor() # Ensure all tables exist (including locations) - ensure_all_tables_exist(conn, cur) + success_count, failed_tables = ensure_all_tables_exist(conn, cur) # Verify locations table exists if not table_exists(cur, 'locations'): - print("✗ Locations table still does not exist after creation attempt") + if 'locations' in failed_tables: + print("✗ Locations table failed to be created (see errors above)") + else: + print("✗ Locations table still does not exist after creation attempt") cur.close() conn.close() return @@ -292,16 +317,16 @@ def seed_locations(): update_count += 1 except mysql.connector.Error as e2: if 'Unknown column' in str(e2): - cur.execute( - "UPDATE locations SET type = %s, shelf_count = %s WHERE name = %s", - (location_type, shelf_count, name) - ) - if cur.rowcount > 0: - update_count += 1 + cur.execute( + "UPDATE locations SET type = %s, shelf_count = %s WHERE name = %s", + (location_type, shelf_count, name) + ) + if cur.rowcount > 0: + update_count += 1 + else: + raise else: raise - else: - raise else: # Insert new location with coordinates - set protected=True for locations from JSON try: @@ -322,10 +347,10 @@ def seed_locations(): except mysql.connector.Error as e2: # If coordinate columns don't exist, insert without them if 'Unknown column' in str(e2): - cur.execute( - "INSERT INTO locations (name, type, shelf_count) VALUES (%s, %s, %s)", - (name, location_type, shelf_count) - ) + cur.execute( + "INSERT INTO locations (name, type, shelf_count) VALUES (%s, %s, %s)", + (name, location_type, shelf_count) + ) else: raise else: @@ -366,7 +391,7 @@ def seed_teams(): cur = conn.cursor() # Ensure all tables exist (including teams) - ensure_all_tables_exist(conn, cur) + success_count, failed_tables = ensure_all_tables_exist(conn, cur) # Check if teams exist cur.execute("SELECT COUNT(*) FROM teams") @@ -412,7 +437,7 @@ def seed_test_user(): cur = conn.cursor(dictionary=True) # Ensure all tables exist (including members) - ensure_all_tables_exist(conn, cur) + success_count, failed_tables = ensure_all_tables_exist(conn, cur) # Check if test user exists cur.execute("SELECT uf_id FROM members WHERE uf_email = %s", ("test@ufl.edu",)) diff --git a/src/sql/supplies_history/table_supplies_history.sql b/src/sql/supplies_history/table_supplies_history.sql index 5ee5090..de623e3 100644 --- a/src/sql/supplies_history/table_supplies_history.sql +++ b/src/sql/supplies_history/table_supplies_history.sql @@ -19,7 +19,7 @@ CREATE TABLE supplies_history ( -- Foreign keys CONSTRAINT fk_history_supply FOREIGN KEY (supply_id) REFERENCES supplies(id) - ON UPDATE CASCADE ON DELETE CASCADE, + ON UPDATE CASCADE ON DELETE SET NULL, CONSTRAINT fk_history_changed_by FOREIGN KEY (changed_by) REFERENCES members(uf_id) ON UPDATE CASCADE ON DELETE SET NULL, From 24e340822b4a18d54c0020eed518e18085e76d06 Mon Sep 17 00:00:00 2001 From: willzoo Date: Mon, 2 Mar 2026 22:10:46 -0500 Subject: [PATCH 22/73] add folder for tests and fix supply routes --- .gitignore | 3 +- Makefile | 8 + src/api/routes/supplies.py | 84 +++++-- tests/package-lock.json | 421 ++++++++++++++++++++++++++++++++++ tests/package.json | 13 ++ tests/test-history-api.js | 446 +++++++++++++++++++++++++++++++++++++ 6 files changed, 951 insertions(+), 24 deletions(-) create mode 100644 tests/package-lock.json create mode 100644 tests/package.json create mode 100644 tests/test-history-api.js diff --git a/.gitignore b/.gitignore index d75edea..becda0c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ venv -__pycache__ \ No newline at end of file +__pycache__ +node_modules \ No newline at end of file diff --git a/Makefile b/Makefile index c1b4ad6..e8dd604 100644 --- a/Makefile +++ b/Makefile @@ -67,6 +67,14 @@ locations: milventory: @cd milventory && npm install && npm start +## test-api: Run history API integration tests +.PHONY: test-api +test-api: + @echo Installing test dependencies... + @if not exist tests\node_modules (cd tests & npm install) + @echo Running history API tests... + @cd tests & node test-history-api.js + ## help: Show this help menu help: @echo "Available commands:" diff --git a/src/api/routes/supplies.py b/src/api/routes/supplies.py index 19acbd6..01191b2 100644 --- a/src/api/routes/supplies.py +++ b/src/api/routes/supplies.py @@ -868,6 +868,27 @@ def undo_supply_history(history_id, current_user_id=None): conn.close() return jsonify({'error': 'History entry not found'}), 404 + # For DELETE actions, supply_id might be NULL due to ON DELETE SET NULL + # We need to find the original supply_id by looking at the old_name and matching + # with any existing supplies, or we can try to extract it from the history entry + # before it was set to NULL. Actually, we can't - but we can use the old_name + # to find if a supply with that name exists, or we need to store the ID elsewhere. + # For now, let's try to get it from the history entry's old data or find by name. + original_supply_id = history['supply_id'] + + # If supply_id is NULL (for DELETE), try to find the supply by name + if not original_supply_id and history['action_type'] == 'DELETE': + if history['old_name']: + cur.execute("SELECT id FROM supplies WHERE name = %s", (history['old_name'],)) + existing = cur.fetchone() + if existing: + original_supply_id = existing['id'] + else: + # Supply doesn't exist, we'll need to create it with a new ID + # But we don't know the original ID, so we can't restore it exactly + # For now, we'll create it without specifying the ID (auto-increment) + original_supply_id = None + # Get team and category changes cur.execute(""" SELECT team_name, action FROM supplies_history_teams WHERE history_id = %s @@ -946,23 +967,34 @@ def undo_supply_history(history_id, current_user_id=None): elif history['action_type'] == 'DELETE': # Undo DELETE: Recreate the supply - if not history['supply_id']: - cur.close() - conn.close() - return jsonify({'error': 'Cannot undo: supply ID not available'}), 400 - - # Recreate supply - cur.execute(""" - INSERT INTO supplies (id, name, description, image, last_order_date, last_modified_by) - VALUES (%s, %s, %s, %s, %s, %s) - """, ( - history['supply_id'], - history['old_name'], - history['old_description'], - history['old_image'], - history['old_last_order_date'], - current_user_id - )) + # If original_supply_id is None, we'll create with auto-increment + if original_supply_id: + # Recreate supply with original ID + cur.execute(""" + INSERT INTO supplies (id, name, description, image, last_order_date, last_modified_by) + VALUES (%s, %s, %s, %s, %s, %s) + """, ( + original_supply_id, + history['old_name'], + history['old_description'], + history['old_image'], + history['old_last_order_date'], + current_user_id + )) + restored_supply_id = original_supply_id + else: + # Recreate supply without ID (auto-increment) + cur.execute(""" + INSERT INTO supplies (name, description, image, last_order_date, last_modified_by) + VALUES (%s, %s, %s, %s, %s) + """, ( + history['old_name'], + history['old_description'], + history['old_image'], + history['old_last_order_date'], + current_user_id + )) + restored_supply_id = cur.lastrowid # Recreate teams (all that were REMOVED in delete) for team_change in team_changes: @@ -970,7 +1002,7 @@ def undo_supply_history(history_id, current_user_id=None): cur.execute(""" INSERT IGNORE INTO supplies_teams (supply_id, team_name) VALUES (%s, %s) - """, (history['supply_id'], team_change['team_name'])) + """, (restored_supply_id, team_change['team_name'])) # Recreate categories (all that were REMOVED in delete) for cat_change in category_changes: @@ -978,19 +1010,25 @@ def undo_supply_history(history_id, current_user_id=None): cur.execute(""" INSERT IGNORE INTO supplies_categories (supply_id, category_id) VALUES (%s, %s) - """, (history['supply_id'], cat_change['category_id'])) + """, (restored_supply_id, cat_change['category_id'])) # Delete the history entry and all related data (CASCADE will handle teams/categories) cur.execute("DELETE FROM supplies_history WHERE id = %s", (history_id,)) conn.commit() - cur.close() - conn.close() - return jsonify({ + # Return the restored supply_id if it was a DELETE undo + response_data = { 'success': True, 'message': f'Successfully undid {history["action_type"]} action' - }), 200 + } + if history['action_type'] == 'DELETE' and 'restored_supply_id' in locals(): + response_data['restored_supply_id'] = restored_supply_id + + cur.close() + conn.close() + + return jsonify(response_data), 200 except mysql.connector.IntegrityError as e: conn.rollback() return jsonify({'error': str(e)}), 400 diff --git a/tests/package-lock.json b/tests/package-lock.json new file mode 100644 index 0000000..21335af --- /dev/null +++ b/tests/package-lock.json @@ -0,0 +1,421 @@ +{ + "name": "mil-sql-tests", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mil-sql-tests", + "version": "1.0.0", + "dependencies": { + "axios": "^1.6.0", + "axios-cookiejar-support": "^4.0.0", + "tough-cookie": "^4.1.3" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios-cookiejar-support": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/axios-cookiejar-support/-/axios-cookiejar-support-4.0.7.tgz", + "integrity": "sha512-9vpE3y/a2l2Vs2XEJE4L2z0GWnlpJ4Xj+kDaoCtrpPfS1J3oikXBrxRJX6H62/ZcelOGe+519yW7mqXCIoPXuw==", + "license": "MIT", + "dependencies": { + "http-cookie-agent": "^5.0.4" + }, + "engines": { + "node": ">=14.18.0 <15.0.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/3846masa" + }, + "peerDependencies": { + "axios": ">=0.20.0", + "tough-cookie": ">=4.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-cookie-agent": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/http-cookie-agent/-/http-cookie-agent-5.0.4.tgz", + "integrity": "sha512-OtvikW69RvfyP6Lsequ0fN5R49S+8QcS9zwd58k6VSr6r57T8G29BkPdyrBcSwLq6ExLs9V+rBlfxu7gDstJag==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0" + }, + "engines": { + "node": ">=14.18.0 <15.0.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/3846masa" + }, + "peerDependencies": { + "deasync": "^0.1.26", + "tough-cookie": "^4.0.0", + "undici": "^5.11.0" + }, + "peerDependenciesMeta": { + "deasync": { + "optional": true + }, + "undici": { + "optional": true + } + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + } + } +} diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 0000000..eb7614b --- /dev/null +++ b/tests/package.json @@ -0,0 +1,13 @@ +{ + "name": "mil-sql-tests", + "version": "1.0.0", + "description": "API integration tests for mil-sql", + "scripts": { + "test": "node test-history-api.js" + }, + "dependencies": { + "axios": "^1.6.0", + "axios-cookiejar-support": "^4.0.0", + "tough-cookie": "^4.1.3" + } +} diff --git a/tests/test-history-api.js b/tests/test-history-api.js new file mode 100644 index 0000000..7cb4098 --- /dev/null +++ b/tests/test-history-api.js @@ -0,0 +1,446 @@ +/** + * History API Integration Tests + * + * These tests verify that backend API behavior matches what a user would experience + * in the browser. If these tests pass, a user will 100% succeed at doing the same + * thing in their browser (as long as the UI-only logic isn't broken). + * + * Test Flow: + * 1. Create ItemA + * 2. Assert CREATE entry in history table + * 3. Delete ItemA + * 4. Assert DELETE entry in history table + * 5. Undo delete ItemA + * 6. Assert ItemA exists in master table + * 7. Assert DELETE entry removed from history table + */ + +const axios = require('axios'); +const { wrapper } = require('axios-cookiejar-support'); +const tough = require('tough-cookie'); + +// Configure axios to use cookies (same as browser) +const cookieJar = new tough.CookieJar(); +const axiosWithCookies = wrapper(axios); + +// Configuration +const API_BASE = process.env.API_URL || 'http://localhost:5000/api'; +const TEST_EMAIL = 'test@ufl.edu'; +const TEST_PASSWORD = 'test'; + +// Test state +let testResults = { + passed: 0, + failed: 0, + errors: [], +}; + +// Helper to make authenticated requests (same as browser) +const api = axiosWithCookies.create({ + baseURL: API_BASE, + withCredentials: true, + jar: cookieJar, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Colors for console output +const colors = { + reset: '\x1b[0m', + green: '\x1b[32m', + red: '\x1b[31m', + yellow: '\x1b[33m', + cyan: '\x1b[36m', + bright: '\x1b[1m', +}; + +function log(message, color = 'reset') { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +// Assertion helper +function assert(condition, message) { + if (condition) { + testResults.passed++; + log(` ✓ ${message}`, 'green'); + return true; + } else { + testResults.failed++; + testResults.errors.push(message); + log(` ✗ ${message}`, 'red'); + return false; + } +} + +function assertEqual(actual, expected, message) { + const passed = actual === expected; + if (passed) { + testResults.passed++; + log(` ✓ ${message}`, 'green'); + } else { + testResults.failed++; + testResults.errors.push(`${message} - Expected: ${expected}, Got: ${actual}`); + log(` ✗ ${message} - Expected: ${expected}, Got: ${actual}`, 'red'); + } + return passed; +} + +function assertExists(value, message) { + return assert(value != null && value !== undefined, message); +} + +function assertNotExists(value, message) { + return assert(value == null || value === undefined, message); +} + +// Delay helper +const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + +// API helpers (same as browser would use) +async function login() { + const response = await api.post('/auth/login', { + email: TEST_EMAIL, + password: TEST_PASSWORD, + }); + return response.data.user; +} + +async function createSupply(name, description, teams = [], categories = []) { + const response = await api.post('/supplies', { + name, + description, + teams, + categories, + }); + return response.data; +} + +async function deleteSupply(supplyId) { + await api.delete(`/supplies/${supplyId}`); +} + +async function getSupply(supplyId) { + try { + const response = await api.get(`/supplies/${supplyId}`); + return response.data; + } catch (error) { + if (error.response?.status === 404) { + return null; + } + throw error; + } +} + +async function getAllSupplies() { + const response = await api.get('/supplies'); + return response.data || []; +} + +async function getSupplyHistory(filters = {}) { + const params = new URLSearchParams(); + if (filters.supply_id) params.append('supply_id', filters.supply_id); + if (filters.action_type) params.append('action_type', filters.action_type); + + const url = params.toString() ? `/supplies/history?${params}` : '/supplies/history'; + const response = await api.get(url); + return response.data.history || []; +} + +async function undoSupplyHistory(historyId) { + const response = await api.post(`/supplies/history/${historyId}/undo`); + return response.data; +} + +async function getCategories() { + const response = await api.get('/categories'); + return response.data.categories || []; +} + +async function getTeams() { + const response = await api.get('/teams'); + return response.data.teams || []; +} + +// Test functions +async function testCreateItem() { + log('\n[TEST 1] Creating ItemA...', 'cyan'); + + const categories = await getCategories(); + const teams = await getTeams(); + + if (categories.length === 0 || teams.length === 0) { + log(' ✗ Cannot create item: need at least one category and one team', 'red'); + testResults.failed++; + return null; + } + + const timestamp = Date.now(); + const itemName = `ItemA-${timestamp}`; + + // Ensure we have valid team and category data + const teamName = teams[0]?.name || teams[0]; + const categoryId = categories[0]?.id || categories[0]; + + if (!teamName || !categoryId) { + log(' ✗ Cannot create item: invalid team or category data', 'red'); + testResults.failed++; + return null; + } + + const supply = await createSupply( + itemName, + 'Test item for history API tests', + [teamName], + [categoryId] + ); + + assertExists(supply, 'ItemA was created'); + assertExists(supply.id, 'ItemA has an ID'); + assertEqual(supply.name, itemName, 'ItemA has correct name'); + + await delay(500); // Give DB time to commit + + return { supply, itemName }; +} + +async function testAssertCreateInHistory(itemName, supplyId) { + log('\n[TEST 2] Asserting CREATE entry in history table...', 'cyan'); + + const history = await getSupplyHistory({ supply_id: supplyId }); + + const createEntry = history.find( + entry => entry.action_type === 'CREATE' && + (entry.supply_name === itemName || entry.new_name === itemName) + ); + + assertExists(createEntry, 'CREATE entry exists in history'); + + if (createEntry) { + assertEqual(createEntry.action_type, 'CREATE', 'History entry has correct action type'); + assertEqual(createEntry.supply_id, supplyId, 'History entry has correct supply_id'); + assertEqual(createEntry.new_name, itemName, 'History entry has correct item name'); + assertExists(createEntry.id, 'History entry has an ID'); + } + + return createEntry; +} + +async function testDeleteItem(supplyId, itemName) { + log('\n[TEST 3] Deleting ItemA...', 'cyan'); + + await deleteSupply(supplyId); + + // Verify item is deleted + const deletedItem = await getSupply(supplyId); + assertNotExists(deletedItem, 'ItemA is deleted from master table'); + + await delay(500); // Give DB time to commit + + return true; +} + +async function testAssertDeleteInHistory(supplyId, itemName) { + log('\n[TEST 4] Asserting DELETE entry in history table...', 'cyan'); + + // After deletion, supply_id is NULL, so we need to search all history by name + // or search by action_type DELETE + const history = await getSupplyHistory({ action_type: 'DELETE' }); + + const deleteEntry = history.find( + entry => (entry.supply_name === itemName || entry.old_name === itemName) && + entry.action_type === 'DELETE' + ); + + assertExists(deleteEntry, 'DELETE entry exists in history'); + + if (deleteEntry) { + assertEqual(deleteEntry.action_type, 'DELETE', 'History entry has correct action type'); + assertEqual(deleteEntry.old_name, itemName, 'History entry has correct item name'); + assertExists(deleteEntry.id, 'History entry has an ID'); + // After deletion, supply_id should be NULL (due to ON DELETE SET NULL) + assertEqual(deleteEntry.supply_id, null, 'History entry has NULL supply_id after deletion'); + } + + return deleteEntry; +} + +async function testUndoDelete(deleteHistoryId, supplyId, itemName) { + log('\n[TEST 5] Undoing delete ItemA...', 'cyan'); + + const result = await undoSupplyHistory(deleteHistoryId); + + assertExists(result, 'Undo operation returned a result'); + assertEqual(result.success, true, 'Undo operation was successful'); + + await delay(500); // Give DB time to commit + + // Return the restored supply_id if available (for DELETE undo, it might be a new ID) + return result.restored_supply_id || supplyId; +} + +async function testAssertItemExists(supplyId, itemName, restoredSupplyId = null) { + log('\n[TEST 6] Asserting ItemA exists in master table...', 'cyan'); + + // After DELETE undo, the supply might have a new ID, so try both + let supply = null; + if (restoredSupplyId) { + supply = await getSupply(restoredSupplyId); + if (supply) { + supplyId = restoredSupplyId; // Update for subsequent tests + } + } + + // If not found by restored ID, try original ID + if (!supply) { + supply = await getSupply(supplyId); + } + + // If still not found, try finding by name + if (!supply) { + const allSupplies = await getAllSupplies(); + supply = allSupplies.find(s => s.name === itemName); + if (supply) { + supplyId = supply.id; // Update for subsequent tests + } + } + + assertExists(supply, 'ItemA exists in master table'); + + if (supply) { + assertEqual(supply.name, itemName, 'ItemA has correct name'); + } + + return { supply, supplyId }; +} + +async function testAssertDeleteRemovedFromHistory(supplyId, deleteHistoryId, itemName) { + log('\n[TEST 7] Asserting DELETE entry removed from history table...', 'cyan'); + + // After undo, supply_id might be different (new ID), so search all history by name + const allHistory = await getSupplyHistory(); + + const deleteEntry = allHistory.find(entry => entry.id === deleteHistoryId); + + assertNotExists(deleteEntry, 'DELETE entry is removed from history'); + + // Also verify CREATE entry still exists (search by name since supply_id might have changed) + const createEntry = allHistory.find( + entry => entry.action_type === 'CREATE' && + (entry.supply_name === itemName || entry.new_name === itemName) + ); + + assertExists(createEntry, 'CREATE entry still exists in history after undo'); + + // Note: The CREATE entry's supply_id may be NULL if the supply was deleted and recreated + // This is expected behavior - history entries reflect the state when they were created + // and are not updated when supplies are recreated + + return true; +} + +// Main test runner +async function runTests() { + log('\n' + '='.repeat(70), 'bright'); + log('HISTORY API INTEGRATION TESTS', 'bright'); + log('='.repeat(70), 'bright'); + log('\nThese tests verify backend API behavior matches browser behavior.\n', 'yellow'); + + let testState = { + supplyId: null, + itemName: null, + createHistoryId: null, + deleteHistoryId: null, + }; + + try { + // Login + log('[SETUP] Logging in...', 'cyan'); + const user = await login(); + assertExists(user, 'Login successful'); + log(` ✓ Logged in as ${user.email}\n`, 'green'); + + // Test 1: Create ItemA + const createResult = await testCreateItem(); + if (!createResult) { + log('\n✗ Cannot continue tests without creating item', 'red'); + return; + } + testState.supplyId = createResult.supply.id; + testState.itemName = createResult.itemName; + + // Test 2: Assert CREATE in history + const createEntry = await testAssertCreateInHistory(testState.itemName, testState.supplyId); + if (createEntry) { + testState.createHistoryId = createEntry.id; + } + + // Test 3: Delete ItemA + await testDeleteItem(testState.supplyId, testState.itemName); + + // Test 4: Assert DELETE in history + const deleteEntry = await testAssertDeleteInHistory(testState.supplyId, testState.itemName); + if (deleteEntry) { + testState.deleteHistoryId = deleteEntry.id; + } + + // Test 5: Undo delete + let restoredSupplyId = null; + if (testState.deleteHistoryId) { + restoredSupplyId = await testUndoDelete(testState.deleteHistoryId, testState.supplyId, testState.itemName); + } + + // Test 6: Assert item exists + const itemResult = await testAssertItemExists(testState.supplyId, testState.itemName, restoredSupplyId); + if (itemResult.supply) { + testState.supplyId = itemResult.supplyId; // Update supply_id in case it changed + } + + // Test 7: Assert DELETE removed from history + if (testState.deleteHistoryId) { + await testAssertDeleteRemovedFromHistory( + testState.supplyId, + testState.deleteHistoryId, + testState.itemName + ); + } + + } catch (error) { + log(`\n✗ Test execution failed: ${error.message}`, 'red'); + if (error.response) { + log(` Response: ${JSON.stringify(error.response.data, null, 2)}`, 'red'); + } + testResults.failed++; + testResults.errors.push(`Test execution error: ${error.message}`); + } + + // Print summary + log('\n' + '='.repeat(70), 'bright'); + log('TEST SUMMARY', 'bright'); + log('='.repeat(70), 'bright'); + log(`\nPassed: ${testResults.passed}`, testResults.passed > 0 ? 'green' : 'reset'); + log(`Failed: ${testResults.failed}`, testResults.failed > 0 ? 'red' : 'reset'); + + if (testResults.errors.length > 0) { + log('\nErrors:', 'red'); + testResults.errors.forEach((error, idx) => { + log(` ${idx + 1}. ${error}`, 'red'); + }); + } + + log('\n' + '='.repeat(70) + '\n', 'bright'); + + // Exit with appropriate code + process.exit(testResults.failed > 0 ? 1 : 0); +} + +// Run tests +if (require.main === module) { + runTests().catch(error => { + log(`\n✗ Unhandled error: ${error.message}`, 'red'); + console.error(error); + process.exit(1); + }); +} + +module.exports = { runTests }; From c5ac0fb29d6f9b1caa59239f0b6120470f946d16 Mon Sep 17 00:00:00 2001 From: willzoo Date: Mon, 2 Mar 2026 22:26:48 -0500 Subject: [PATCH 23/73] Remove optimistic updates from inventory context --- milventory/src/context/InventoryContext.js | 301 +++++---------------- 1 file changed, 64 insertions(+), 237 deletions(-) diff --git a/milventory/src/context/InventoryContext.js b/milventory/src/context/InventoryContext.js index c189f80..24a2e55 100644 --- a/milventory/src/context/InventoryContext.js +++ b/milventory/src/context/InventoryContext.js @@ -133,17 +133,16 @@ export const InventoryProvider = ({ children }) => { }); }); - // Merge into inventoryData + // Merge into inventoryData - update ALL boxes, even if they're now empty setInventoryData(prev => { const next = new Map(prev); - locationMap.forEach((items, locationName) => { - const boxData = next.get(locationName); - if (boxData) { - next.set(locationName, { - ...boxData, - inventory: items - }); - } + // Update all existing boxes - set inventory to empty array if not in locationMap + prev.forEach((boxData, locationName) => { + const items = locationMap.get(locationName) || []; + next.set(locationName, { + ...boxData, + inventory: items + }); }); return next; }); @@ -335,17 +334,6 @@ export const InventoryProvider = ({ children }) => { }, []); const updateInventory = useCallback(async (boxTitle, newInventory) => { - // Update local state immediately (optimistic update) - setInventoryData(prev => { - const next = new Map(prev); - const boxData = next.get(boxTitle); - if (boxData) { - next.set(boxTitle, { ...boxData, inventory: newInventory }); - } - return next; - }); - - // Sync to API (fire and forget for now - could add error handling later) try { // Get current supply locations for this box const currentLocations = await api.getLocationSupplies(boxTitle); @@ -405,14 +393,22 @@ export const InventoryProvider = ({ children }) => { for (const id of toDelete) { await api.deleteSupplyLocation(id); } + + // Reload supply locations to ensure UI reflects actual server state + await reloadSupplyLocations(); } catch (error) { console.error('Error syncing inventory to API:', error); // Only set error if not panning (to avoid breaking pan) if (!isPanningRef.current) { - setError(error.message || 'Failed to sync inventory changes'); + const errorInfo = await handleApiError(error); + if (errorInfo.isConflict) { + setConflictError(errorInfo); + } else { + setError(errorInfo.message || 'Failed to sync inventory changes'); + } } } - }, [supplyNameToId]); + }, [supplyNameToId, reloadSupplyLocations]); // Add Mode functions const startAddMode = useCallback((itemName) => { @@ -489,52 +485,7 @@ export const InventoryProvider = ({ children }) => { additions: additions }); - // Update local state optimistically - const byBox = new Map(); - pending.forEach((qty, key) => { - const parts = key.split('||'); - const boxTitle = parts[0]; - const shelf = parts.length > 1 ? parseInt(parts[1], 10) : undefined; - if (!byBox.has(boxTitle)) byBox.set(boxTitle, []); - byBox.get(boxTitle).push({ shelf, qty }); - }); - - byBox.forEach((entries, boxTitle) => { - const boxData = inventoryData.get(boxTitle); - if (!boxData) return; - - const newInventory = [...boxData.inventory]; - - entries.forEach(({ shelf, qty }) => { - const existingIndex = newInventory.findIndex(item => { - if (item.name !== currentItem) return false; - if (shelf !== undefined) return (item.shelf ?? 0) === shelf; - return true; - }); - - if (existingIndex >= 0) { - newInventory[existingIndex] = { - ...newInventory[existingIndex], - qty: newInventory[existingIndex].qty + qty - }; - } else { - const newItem = { name: currentItem, qty }; - if (shelf !== undefined) newItem.shelf = shelf; - newInventory.push(newItem); - } - }); - - setInventoryData(prev => { - const next = new Map(prev); - const box = next.get(boxTitle); - if (box) { - next.set(boxTitle, { ...box, inventory: newInventory }); - } - return next; - }); - }); - - // Reload supply locations to get the IDs for newly added items + // Reload supply locations to ensure UI reflects actual server state await reloadSupplyLocations(); // Clear add mode @@ -553,7 +504,7 @@ export const InventoryProvider = ({ children }) => { } } } - }, [inventoryData, supplyNameToId, reloadSupplyLocations]); + }, [supplyNameToId, reloadSupplyLocations]); const cancelAddMode = useCallback(() => { setAddModeItem(null); @@ -630,14 +581,17 @@ export const InventoryProvider = ({ children }) => { } // Convert pending map to deletions + // Use functional update to get latest inventoryData const deletions = []; + let currentInventoryData = inventoryData; + pending.forEach((pendingQty, key) => { const parts = key.split('||'); const boxTitle = parts[0]; const shelf = parts.length > 1 ? parseInt(parts[1], 10) : null; // Find the supply_location_id for this item at this location - const boxData = inventoryData.get(boxTitle); + const boxData = currentInventoryData.get(boxTitle); if (!boxData) return; const matchingItems = boxData.inventory.filter(item => { @@ -672,7 +626,10 @@ export const InventoryProvider = ({ children }) => { try { // Delete items via API for (const deletion of deletions) { - const item = inventoryData.get(deletion.location)?.inventory.find(i => i.id === deletion.id); + // Get the item from current state to check quantity + const boxData = currentInventoryData.get(deletion.location); + const item = boxData?.inventory.find(i => i.id === deletion.id); + if (!item) continue; if (item.qty <= deletion.amount) { @@ -684,45 +641,8 @@ export const InventoryProvider = ({ children }) => { } } - // Update local state optimistically - const byBox = new Map(); - deletions.forEach(({ location, shelf, amount, id }) => { - if (!byBox.has(location)) byBox.set(location, []); - byBox.get(location).push({ shelf, amount, id }); - }); - - byBox.forEach((entries, boxTitle) => { - const boxData = inventoryData.get(boxTitle); - if (!boxData) return; - - const newInventory = [...boxData.inventory]; - - entries.forEach(({ shelf, amount, id }) => { - const existingIndex = newInventory.findIndex(item => item.id === id); - if (existingIndex >= 0) { - const newQty = newInventory[existingIndex].qty - amount; - if (newQty <= 0) { - // Remove item - newInventory.splice(existingIndex, 1); - } else { - // Update quantity - newInventory[existingIndex] = { - ...newInventory[existingIndex], - qty: newQty - }; - } - } - }); - - setInventoryData(prev => { - const next = new Map(prev); - const box = next.get(boxTitle); - if (box) { - next.set(boxTitle, { ...box, inventory: newInventory }); - } - return next; - }); - }); + // Reload supply locations to ensure UI reflects actual server state + await reloadSupplyLocations(); // Clear delete mode setDeleteModeItem(null); @@ -732,10 +652,15 @@ export const InventoryProvider = ({ children }) => { console.error('Error finishing delete mode:', error); // Only set error if not panning (to avoid breaking pan) if (!isPanningRef.current) { - setError(error.message || 'Failed to delete items'); + const errorInfo = await handleApiError(error); + if (errorInfo.isConflict) { + setConflictError(errorInfo); + } else { + setError(errorInfo.message || 'Failed to delete items'); + } } } - }, [inventoryData, supplyNameToId]); + }, [inventoryData, supplyNameToId, reloadSupplyLocations]); const cancelDeleteMode = useCallback(() => { setDeleteModeItem(null); @@ -803,90 +728,25 @@ export const InventoryProvider = ({ children }) => { amount: qty }); - // Update local state optimistically - const sourceBoxData = inventoryData.get(sourceBoxTitle); - const targetBoxData = inventoryData.get(targetBoxTitle); - - if (!sourceBoxData || !targetBoxData) { - setMoveModeDragging(null); - return; - } - - // Remove from source - find the exact item and subtract qty - const newSourceInventory = []; - let foundSource = false; - - for (const item of sourceBoxData.inventory) { - if (item.name === moveModeItemRef.current) { - const itemShelf = item.shelf ?? 0; - const sourceShelfValue = sourceShelf ?? 0; - - if (itemShelf === sourceShelfValue && !foundSource) { - // This is the source item - subtract qty - foundSource = true; - const newQty = item.qty - qty; - if (newQty > 0) { - // Keep item with reduced qty - newSourceInventory.push({ ...item, qty: newQty }); - } - // If newQty <= 0, don't add it (effectively removing it) - } else { - // Different shelf or already found - keep as is - newSourceInventory.push(item); - } - } else { - // Different item - keep as is - newSourceInventory.push(item); - } - } - - const isSameBox = sourceBoxTitle === targetBoxTitle; - - // For same-box moves (between shelves), work from the already-updated source inventory - const baseTargetInventory = isSameBox ? newSourceInventory : [...targetBoxData.inventory]; - - // Add to target (combine if exists) - const newTargetInventory = [...baseTargetInventory]; - const existingIndex = newTargetInventory.findIndex(item => { - if (item.name !== moveModeItemRef.current) return false; - if (targetShelf !== undefined) return (item.shelf ?? 0) === targetShelf; - return item.shelf === undefined; - }); - - if (existingIndex >= 0) { - newTargetInventory[existingIndex] = { - ...newTargetInventory[existingIndex], - qty: newTargetInventory[existingIndex].qty + qty - }; - } else { - const newItem = { name: moveModeItemRef.current, qty }; - if (targetShelf !== undefined) newItem.shelf = targetShelf; - newTargetInventory.push(newItem); - } - - setInventoryData(prev => { - const next = new Map(prev); - if (isSameBox) { - // Same box, different shelf — only set once with the fully updated inventory - next.set(sourceBoxTitle, { ...sourceBoxData, inventory: newTargetInventory }); - } else { - next.set(sourceBoxTitle, { ...sourceBoxData, inventory: newSourceInventory }); - next.set(targetBoxTitle, { ...targetBoxData, inventory: newTargetInventory }); - } - return next; - }); + // Reload supply locations to ensure UI reflects actual server state + await reloadSupplyLocations(); setMoveModeDragging(null); isDraggingMoveBoxRef.current = false; } catch (error) { console.error('Error moving item:', error); if (!isPanningRef.current) { - setError(error.message || 'Failed to move item'); + const errorInfo = await handleApiError(error); + if (errorInfo.isConflict) { + setConflictError(errorInfo); + } else { + setError(errorInfo.message || 'Failed to move item'); + } } setMoveModeDragging(null); isDraggingMoveBoxRef.current = false; } - }, [moveModeDragging, inventoryData, supplyNameToId]); + }, [moveModeDragging, supplyNameToId, reloadSupplyLocations]); const handleDragStart = useCallback((boxTitle, index, isMultiple, selectedIndices) => { const boxData = inventoryData.get(boxTitle); @@ -950,33 +810,8 @@ export const InventoryProvider = ({ children }) => { }); } - // Update local state optimistically - let newSourceInventory = [...sourceBoxData.inventory]; - let newTargetInventory = [...targetBoxData.inventory]; - - if (draggedItemData.isMultiple) { - const sortedIndices = [...draggedItemData.sourceIndices].sort((a, b) => b - a); - sortedIndices.forEach(idx => { - newSourceInventory.splice(idx, 1); - }); - newTargetInventory.push(...draggedItemData.items); - } else { - newSourceInventory.splice(draggedItemData.sourceIndex, 1); - newTargetInventory.push(draggedItemData.item); - } - - setInventoryData(prev => { - const next = new Map(prev); - const sourceBox = next.get(draggedItemData.sourceBox); - const targetBox = next.get(targetBoxTitle); - if (sourceBox) { - next.set(draggedItemData.sourceBox, { ...sourceBox, inventory: newSourceInventory }); - } - if (targetBox) { - next.set(targetBoxTitle, { ...targetBox, inventory: newTargetInventory }); - } - return next; - }); + // Reload supply locations to ensure UI reflects actual server state + await reloadSupplyLocations(); // Auto-select the target box after successful drop setSelectedBox(targetBoxTitle); @@ -990,10 +825,15 @@ export const InventoryProvider = ({ children }) => { console.error('Error moving items:', error); // Only set error if not panning (to avoid breaking pan) if (!isPanningRef.current) { - setError(error.message || 'Failed to move items'); + const errorInfo = await handleApiError(error); + if (errorInfo.isConflict) { + setConflictError(errorInfo); + } else { + setError(errorInfo.message || 'Failed to move items'); + } } } - }, [draggedItemData, inventoryData, supplyNameToId]); + }, [draggedItemData, supplyNameToId, reloadSupplyLocations]); // Master Item helper functions const resolveMasterItem = useCallback((itemName) => { @@ -1091,17 +931,6 @@ export const InventoryProvider = ({ children }) => { const next = new Map(prev); if (oldName !== newItem.name) { next.delete(oldName); - // Update all box references if name changed - setInventoryData(prevData => { - const newData = new Map(prevData); - newData.forEach((boxData, boxTitle) => { - const updatedInventory = boxData.inventory.map(item => - item.name === oldName ? { ...item, name: newItem.name } : item - ); - newData.set(boxTitle, { ...boxData, inventory: updatedInventory }); - }); - return newData; - }); } next.set(updated.name, { name: updated.name, @@ -1127,6 +956,10 @@ export const InventoryProvider = ({ children }) => { return next; }); } + + // Reload supply locations to get updated item names in boxes + // (supply locations API JOINs with supplies table, so names will be updated) + await reloadSupplyLocations(); } catch (error) { console.error('Error updating Master item:', error); // Only set error if not panning (to avoid breaking pan) @@ -1140,7 +973,7 @@ export const InventoryProvider = ({ children }) => { } throw error; } - }, [masterInventoryItems]); + }, [masterInventoryItems, reloadSupplyLocations]); const deleteMasterItem = useCallback(async (itemName) => { try { @@ -1165,15 +998,9 @@ export const InventoryProvider = ({ children }) => { return next; }); - // Remove from all boxes (CASCADE in DB handles this, but update UI) - setInventoryData(prev => { - const newData = new Map(prev); - newData.forEach((boxData, boxTitle) => { - const updatedInventory = boxData.inventory.filter(item => item.name !== itemName); - newData.set(boxTitle, { ...boxData, inventory: updatedInventory }); - }); - return newData; - }); + // Reload supply locations to ensure UI reflects actual server state + // (CASCADE in DB removes items from boxes, reload will reflect this) + await reloadSupplyLocations(); // Close preview if this item was selected if (selectedMasterItem === itemName) { @@ -1192,7 +1019,7 @@ export const InventoryProvider = ({ children }) => { } throw error; } - }, [selectedMasterItem, masterInventoryItems]); + }, [selectedMasterItem, masterInventoryItems, reloadSupplyLocations]); const clearSelectedMasterItem = useCallback(() => { setSelectedMasterItem(null); From 0767bb5ff06a3bcd58ce519677e13358a02688ff Mon Sep 17 00:00:00 2001 From: willzoo Date: Wed, 4 Mar 2026 11:02:34 -0500 Subject: [PATCH 24/73] add button on admin dash to take back to user dash --- milventory/package-lock.json | 40 +++++++++------------ milventory/src/components/AdminDashboard.js | 18 ++++++++++ 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/milventory/package-lock.json b/milventory/package-lock.json index 70cb302..b97ac26 100644 --- a/milventory/package-lock.json +++ b/milventory/package-lock.json @@ -53,7 +53,6 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -657,7 +656,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.27.1.tgz", "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, @@ -1483,7 +1481,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", - "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.1", "@babel/helper-module-imports": "^7.27.1", @@ -3408,7 +3405,6 @@ "version": "24.10.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3542,7 +3538,6 @@ "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.62.0", @@ -3594,7 +3589,6 @@ "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -3934,7 +3928,6 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4024,7 +4017,6 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4875,7 +4867,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6113,7 +6104,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "peer": true, "engines": { "node": ">=12" } @@ -6928,7 +6918,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -9518,7 +9507,6 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", - "peer": true, "dependencies": { "@jest/core": "^27.5.1", "import-local": "^3.0.2", @@ -10364,7 +10352,6 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -11673,7 +11660,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -12740,7 +12726,6 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13060,7 +13045,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -13187,7 +13171,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -13210,7 +13193,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13647,7 +13629,6 @@ "version": "2.79.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -13882,7 +13863,6 @@ "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -15113,6 +15093,22 @@ } } }, + "node_modules/tailwindcss/node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -15422,7 +15418,6 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "peer": true, "engines": { "node": ">=10" }, @@ -15809,7 +15804,6 @@ "version": "5.102.1", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -15879,7 +15873,6 @@ "version": "4.15.2", "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz", "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", - "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -16267,7 +16260,6 @@ "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/milventory/src/components/AdminDashboard.js b/milventory/src/components/AdminDashboard.js index 35ae03b..39c97a6 100644 --- a/milventory/src/components/AdminDashboard.js +++ b/milventory/src/components/AdminDashboard.js @@ -1,4 +1,5 @@ import React, { useRef, useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useInventory } from '../context/InventoryContext'; import AdminMap from './AdminMap'; import AdminLeftPanel from './AdminLeftPanel'; @@ -8,6 +9,7 @@ import LocationPreview from './LocationPreview'; import MoveLocationsModal from './MoveLocationsModal'; const AdminDashboard = () => { + const navigate = useNavigate(); const { wrapRef, leftPaneWidth, leftPaneCollapsed } = useInventory(); const svgRef = useRef(null); const [drawMode, setDrawMode] = useState(false); @@ -129,6 +131,22 @@ const AdminDashboard = () => {
Admin Dashboard + + + {drawMode && ( Date: Wed, 4 Mar 2026 15:19:25 -0500 Subject: [PATCH 25/73] track locations in history --- Makefile | 7 +- milventory/src/api.js | 113 ++++ .../src/components/AdminActionsPanel.js | 12 +- milventory/src/components/HistoryModal.js | 9 +- milventory/src/components/HistoryTab.js | 207 ++++++++ .../src/components/InventoryBoxesTable.js | 22 +- .../src/components/MasterInventoryTable.js | 107 ++-- milventory/src/context/InventoryContext.js | 77 +-- milventory/src/index.css | 28 + src/api/app.py | 4 +- src/api/helpers/history.py | 92 ++++ src/api/routes/supplies.py | 60 ++- src/api/routes/supplies_location.py | 190 ++++++- src/api/routes/supplies_location_history.py | 484 ++++++++++++++++++ .../migrate_supplies_location_history.py | 81 +++ .../table_supplies_location_history.sql | 61 +++ 16 files changed, 1436 insertions(+), 118 deletions(-) create mode 100644 milventory/src/components/HistoryTab.js create mode 100644 src/api/routes/supplies_location_history.py create mode 100644 src/scripts/migrate_supplies_location_history.py create mode 100644 src/sql/supplies_location/table_supplies_location_history.sql diff --git a/Makefile b/Makefile index e8dd604..f9ee3ff 100644 --- a/Makefile +++ b/Makefile @@ -6,13 +6,16 @@ COMPOSE=docker-compose -p $(PROJECT_NAME) check-docker: @docker info >nul 2>&1 || (echo. && echo ERROR: Docker is not running. Please start Docker Desktop and try again. && echo. && exit /b 1) -## up: Start the mysql, api (port 5000), and api-test (port 5001) containers and seed default locations +## up: Start the mysql, api (port 5000), and api-test (port 5001) containers and seed default locations. Provides detailed error messages if anything fails .PHONY: up up: check-docker $(COMPOSE) up -d @echo "Waiting for services to be ready..." @timeout /t 5 /nobreak >nul 2>&1 || sleep 5 2>/dev/null || true - @$(COMPOSE) exec api python src/scripts/seed_locations.py + @echo Checking API container status... + @docker inspect mysql_api --format "{{.State.Status}}" 2>nul >nul || (echo. && echo ======================================== && echo ERROR: API container 'mysql_api' does not exist! && echo ======================================== && echo. && echo Try running: make build && echo. && exit /b 1) + @docker inspect mysql_api --format "{{.State.Status}}" 2>nul | findstr /C:"running" >nul 2>&1 || (echo. && echo ======================================== && echo ERROR: API container is not running! && echo ======================================== && echo. && echo Container status: && docker inspect mysql_api --format "Status: {{.State.Status}} (Exit Code: {{.State.ExitCode}})" 2>nul && echo. && echo Container logs: && echo. && docker logs mysql_api 2>&1 && echo. && echo ======================================== && echo. && exit /b 1) + @docker exec mysql_api python src/scripts/seed_locations.py || (echo. && echo ======================================== && echo ERROR: seed_locations.py failed! && echo ======================================== && echo. && echo Recent container logs: && echo. && docker logs --tail 50 mysql_api 2>&1 && echo. && echo ======================================== && echo. && exit /b 1) ## up-empty: Start the mysql, api (port 5000), and api-test (port 5001) containers without seeding data .PHONY: up-empty diff --git a/milventory/src/api.js b/milventory/src/api.js index e56e713..eb53469 100644 --- a/milventory/src/api.js +++ b/milventory/src/api.js @@ -636,3 +636,116 @@ export const admin = { }), }; +// Location History API +export const locationHistory = { + /** + * Get location history with optional filters. + * @param {Object} params - Query parameters (supply_id, supply_name, location_name, limit, offset) + * @returns {Promise} Array of history entries + */ + getAll: (params = {}) => { + const queryParams = new URLSearchParams(); + if (params.supply_id) queryParams.append('supply_id', params.supply_id); + if (params.supply_name) queryParams.append('supply_name', params.supply_name); + if (params.location_name) queryParams.append('location_name', params.location_name); + if (params.limit) queryParams.append('limit', params.limit); + if (params.offset) queryParams.append('offset', params.offset); + + const queryString = queryParams.toString(); + const url = `${API_BASE}/supplies-location-history${queryString ? `?${queryString}` : ''}`; + + return fetch(url, { + method: 'GET', + credentials: 'include', + headers: authHeaders() + }).then(r => { + if (r.status === 401) { + const error = new Error('Authentication required'); + error.response = { status: 401 }; + throw error; + } + if (!r.ok) { + return r.json().then(data => { + const error = new Error(data.error || 'Request failed'); + error.response = r; + throw error; + }); + } + return r.json(); + }).catch(err => { + if (err instanceof TypeError && err.message.includes('fetch')) { + const networkError = new Error('Network error: Unable to connect to server. Please check if the server is running.'); + networkError.response = { status: 0 }; + throw networkError; + } + throw err; + }); + }, + + /** + * Undo a single history entry. + * @param {number} historyId - History entry ID + * @returns {Promise} Updated history entry + */ + undo: (historyId) => + fetch(`${API_BASE}/supplies-location-history/${historyId}/undo`, { + method: 'POST', + credentials: 'include', + headers: authHeaders() + }).then(r => { + if (r.status === 401) { + const error = new Error('Authentication required'); + error.response = { status: 401 }; + throw error; + } + if (!r.ok) { + return r.json().then(data => { + const error = new Error(data.error || 'Request failed'); + error.response = r; + throw error; + }); + } + return r.json(); + }).catch(err => { + if (err instanceof TypeError && err.message.includes('fetch')) { + const networkError = new Error('Network error: Unable to connect to server. Please check if the server is running.'); + networkError.response = { status: 0 }; + throw networkError; + } + throw err; + }), + + /** + * Undo all entries in a batch. + * @param {string} batchId - Batch ID (UUID) + * @returns {Promise} Result with undone_count + */ + undoBatch: (batchId) => + fetch(`${API_BASE}/supplies-location-history/batch/${batchId}/undo`, { + method: 'POST', + credentials: 'include', + headers: authHeaders() + }).then(r => { + if (r.status === 401) { + const error = new Error('Authentication required'); + error.response = { status: 401 }; + throw error; + } + if (!r.ok) { + return r.json().then(data => { + const error = new Error(data.error || 'Request failed'); + error.response = r; + throw error; + }); + } + return r.json(); + }).catch(err => { + if (err instanceof TypeError && err.message.includes('fetch')) { + const networkError = new Error('Network error: Unable to connect to server. Please check if the server is running.'); + networkError.response = { status: 0 }; + throw networkError; + } + throw err; + }), +}; + diff --git a/milventory/src/components/AdminActionsPanel.js b/milventory/src/components/AdminActionsPanel.js index 4de9cd7..2228086 100644 --- a/milventory/src/components/AdminActionsPanel.js +++ b/milventory/src/components/AdminActionsPanel.js @@ -17,14 +17,14 @@ const AdminActionsPanel = ({ onAddLocation, drawMode, onCancelDraw, onStartMove, ) : ( <> - + > + Add Location + + + {activeTab === 'inventory' && ( + <> +
+ setSearchQuery(e.target.value)} + className="master-search-input" + /> +
+
+ {sortedItems.length === 0 ? ( +
+ {searchQuery ? 'No items found' : 'No Master items. Click "+ Add Item" to create one.'} +
+ ) : ( + + + + + + + + + + + {sortedItems.map(([itemName, itemData]) => ( + handleRowClick(itemName)} + /> + ))} + +
NameQtyLocationLast Modified
+ )} +
+
+ +
+ + )} + {activeTab === 'history' && } setShowAddModal(false)} /> diff --git a/milventory/src/context/InventoryContext.js b/milventory/src/context/InventoryContext.js index 24a2e55..60b33c7 100644 --- a/milventory/src/context/InventoryContext.js +++ b/milventory/src/context/InventoryContext.js @@ -100,12 +100,12 @@ export const InventoryProvider = ({ children }) => { const isPanningRef = useRef(false); // Helper function to get fill color for location type - const getFillForType = (type) => { - const typeFills = { - 'drawer': 'var(--drawer)', - 'cabinet': 'var(--table)', + const getFillForType = (type) => { + const typeFills = { + 'drawer': 'var(--drawer)', + 'cabinet': 'var(--table)', 'tall_cabinet': 'var(--files)', // Tall cabinets use files color - 'table': 'var(--table)', + 'table': 'var(--table)', 'other': '#e7ebf3', // Other category (includes workbench) has special color 'special': '#ff69b4', // Special category - pink 'external': '#ff9800', // External category - orange @@ -115,38 +115,38 @@ export const InventoryProvider = ({ children }) => { // Function to reload supply locations from API const reloadSupplyLocations = useCallback(async () => { - try { - const supplyLocations = await api.getAllSupplyLocations(); - - // Group by location_name and merge into inventoryData - const locationMap = new Map(); - supplyLocations.forEach(sl => { - const key = sl.location; - if (!locationMap.has(key)) { - locationMap.set(key, []); - } - locationMap.get(key).push({ + try { + const supplyLocations = await api.getAllSupplyLocations(); + + // Group by location_name and merge into inventoryData + const locationMap = new Map(); + supplyLocations.forEach(sl => { + const key = sl.location; + if (!locationMap.has(key)) { + locationMap.set(key, []); + } + locationMap.get(key).push({ id: sl.id, // supply_location_id from API - name: sl.supply_name || '', // From JOIN in API - qty: sl.qty, // API maps amount to qty - shelf: sl.shelf !== null ? sl.shelf : undefined - }); - }); - + name: sl.supply_name || '', // From JOIN in API + qty: sl.qty, // API maps amount to qty + shelf: sl.shelf !== null ? sl.shelf : undefined + }); + }); + // Merge into inventoryData - update ALL boxes, even if they're now empty - setInventoryData(prev => { - const next = new Map(prev); + setInventoryData(prev => { + const next = new Map(prev); // Update all existing boxes - set inventory to empty array if not in locationMap prev.forEach((boxData, locationName) => { const items = locationMap.get(locationName) || []; - next.set(locationName, { - ...boxData, - inventory: items + next.set(locationName, { + ...boxData, + inventory: items + }); + }); + return next; }); - }); - return next; - }); - } catch (apiError) { + } catch (apiError) { console.error('Error reloading supply locations from API:', apiError); } }, []); @@ -527,15 +527,15 @@ export const InventoryProvider = ({ children }) => { const key = shelf !== undefined ? `${boxTitle}||${shelf}` : boxTitle; // Get current quantity in this location - const boxData = inventoryData.get(boxTitle); - if (!boxData) return; - + const boxData = inventoryData.get(boxTitle); + if (!boxData) return; + const matchingItems = boxData.inventory.filter(item => { if (item.name !== deleteModeItemRef.current) return false; - if (shelf !== undefined) return (item.shelf ?? 0) === shelf; + if (shelf !== undefined) return (item.shelf ?? 0) === shelf; return item.shelf === undefined; - }); - + }); + const currentQty = matchingItems.reduce((sum, item) => sum + (item.qty || 0), 0); const existingPending = deleteModePendingRef.current.get(key) || 0; @@ -613,8 +613,8 @@ export const InventoryProvider = ({ children }) => { }); remainingToDelete -= deleteQty; } + }); }); - }); if (deletions.length === 0) { setDeleteModeItem(null); @@ -1124,6 +1124,7 @@ export const InventoryProvider = ({ children }) => { deleteMasterItem, clearSelectedMasterItem, reloadMasterItems, + reloadSupplyLocations, // Add Mode addModeItem, addModeQtyPerClick, diff --git a/milventory/src/index.css b/milventory/src/index.css index 38c70a5..4913f81 100644 --- a/milventory/src/index.css +++ b/milventory/src/index.css @@ -733,6 +733,34 @@ svg.overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-e font-weight: 600; } +.master-subtabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + border-bottom: 1px solid rgba(255,255,255,.1); + flex-shrink: 0; +} + +.master-subtab { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--muted); + padding: 0.5rem 1rem; + cursor: pointer; + font-size: 0.9rem; + transition: color 0.2s, border-color 0.2s; +} + +.master-subtab:hover { + color: var(--text); +} + +.master-subtab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + .master-table-search { flex-shrink: 0; margin-bottom: 1rem; diff --git a/src/api/app.py b/src/api/app.py index 2bd349e..6ef97ce 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -13,6 +13,7 @@ from src.api.routes.locations import locations_bp from src.api.routes.supplies import supplies_bp from src.api.routes.supplies_location import supplies_location_bp +from src.api.routes.supplies_location_history import supplies_location_history_bp from src.api.routes.auth import auth_bp from src.api.routes.categories import categories_bp from src.api.routes.teams import teams_bp @@ -31,12 +32,13 @@ # Set secret key for sessions app.secret_key = os.getenv('FLASK_SECRET_KEY', 'dev-secret-key-change-in-production') # Configure CORS to allow credentials (cookies) -CORS(app, supports_credentials=True, origins=['http://localhost:3000', 'http://localhost:5000']) +CORS(app, supports_credentials=True, origins=['http://localhost:3000', 'http://localhost:5000', 'http://localhost:6001']) # Register blueprints app.register_blueprint(locations_bp, url_prefix='/api/locations') app.register_blueprint(supplies_bp, url_prefix='/api/supplies') app.register_blueprint(supplies_location_bp, url_prefix='/api/supplies-location') +app.register_blueprint(supplies_location_history_bp, url_prefix='/api/supplies-location-history') app.register_blueprint(auth_bp, url_prefix='/api/auth') app.register_blueprint(categories_bp, url_prefix='/api') app.register_blueprint(teams_bp, url_prefix='/api') diff --git a/src/api/helpers/history.py b/src/api/helpers/history.py index 6d67ab0..953dbfc 100644 --- a/src/api/helpers/history.py +++ b/src/api/helpers/history.py @@ -2,6 +2,7 @@ History tracking helper functions for supplies. """ import sys +import uuid from pathlib import Path # Add src to path for imports @@ -173,3 +174,94 @@ def get_supply_current_state(conn, supply_id): } finally: cur.close() + + +def log_location_history(conn, action_type, supply_id, supply_name, + location_name, shelf, + old_amount, new_amount, + changed_by, + related_location=None, related_shelf=None, + batch_id=None): + """ + Insert one row into supplies_location_history. + Pass batch_id from the caller to group related rows. + Returns the inserted row id. + + Args: + conn: Database connection + action_type: 'ADD', 'REMOVE', 'UPDATE', 'MOVE', or 'SUPPLY_DELETE_SNAPSHOT' + supply_id: Supply ID (can be None) + supply_name: Supply name (denormalized, required) + location_name: Location name + shelf: Shelf number (can be None) + old_amount: Amount before change (None for ADD) + new_amount: Amount after change (None for REMOVE/SNAPSHOT) + changed_by: UF ID of user making the change + related_location: For MOVE actions, the other location + related_shelf: For MOVE actions, the other shelf + batch_id: UUID string to group related operations + + Returns: + History entry ID + """ + cur = conn.cursor() + try: + cur.execute(""" + INSERT INTO supplies_location_history + (supply_id, supply_name, location_name, shelf, + action_type, old_amount, new_amount, + related_location, related_shelf, + batch_id, changed_by) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, ( + supply_id, supply_name, location_name, shelf, + action_type, old_amount, new_amount, + related_location, related_shelf, + batch_id, changed_by + )) + return cur.lastrowid + finally: + cur.close() + + +def snapshot_supply_locations_before_delete(conn, supply_id, supply_name, changed_by): + """ + Called BEFORE deleting a supply. Reads all current supplies_location rows + for this supply and writes SUPPLY_DELETE_SNAPSHOT history entries. + These are later used to restore the supply's full location state. + batch_id ties all snapshots from the same delete together. + + Args: + conn: Database connection + supply_id: Supply ID to snapshot + supply_name: Supply name (denormalized) + changed_by: UF ID of user making the change + + Returns: + batch_id (UUID string) that groups all snapshot rows + """ + cur = conn.cursor(dictionary=True) + batch_id = str(uuid.uuid4()) + try: + cur.execute(""" + SELECT location_name, shelf, amount + FROM supplies_location + WHERE supply_id = %s + """, (supply_id,)) + rows = cur.fetchall() + for row in rows: + log_location_history( + conn, + action_type='SUPPLY_DELETE_SNAPSHOT', + supply_id=supply_id, + supply_name=supply_name, + location_name=row['location_name'], + shelf=row['shelf'], + old_amount=row['amount'], + new_amount=None, + changed_by=changed_by, + batch_id=batch_id + ) + return batch_id # return so caller can attach to the supplies_history row too + finally: + cur.close() diff --git a/src/api/routes/supplies.py b/src/api/routes/supplies.py index 01191b2..c4be01e 100644 --- a/src/api/routes/supplies.py +++ b/src/api/routes/supplies.py @@ -16,7 +16,8 @@ log_supply_history, log_team_changes, log_category_changes, - get_supply_current_state + get_supply_current_state, + snapshot_supply_locations_before_delete ) supplies_bp = Blueprint('supplies', __name__) @@ -690,6 +691,11 @@ def delete_supply(supply_id, current_user_id=None): log_team_changes(conn, history_id, old_teams, []) log_category_changes(conn, history_id, old_categories, []) + # Snapshot all location data BEFORE delete (CASCADE will remove supplies_location rows) + snapshot_supply_locations_before_delete( + conn, supply_id, supply_check['name'], current_user_id + ) + # Now delete the supply (CASCADE will handle related tables) cur.execute("DELETE FROM supplies WHERE id = %s", (supply_id,)) conn.commit() @@ -1011,6 +1017,58 @@ def undo_supply_history(history_id, current_user_id=None): INSERT IGNORE INTO supplies_categories (supply_id, category_id) VALUES (%s, %s) """, (restored_supply_id, cat_change['category_id'])) + + # Restore locations from SUPPLY_DELETE_SNAPSHOT entries + # Find the most recent snapshot batch for this supply_name that hasn't been undone + # The snapshot was created right before the DELETE, so match by supply_name and timestamp + cur.execute(""" + SELECT batch_id, MAX(changed_at) as max_changed_at + FROM supplies_location_history + WHERE supply_name = %s + AND action_type = 'SUPPLY_DELETE_SNAPSHOT' + AND undone = FALSE + AND changed_at >= DATE_SUB(%s, INTERVAL 10 SECOND) + AND changed_at <= DATE_ADD(%s, INTERVAL 10 SECOND) + GROUP BY batch_id + ORDER BY max_changed_at DESC + LIMIT 1 + """, (history['old_name'], history['changed_at'], history['changed_at'])) + + snapshot_batch = cur.fetchone() + if snapshot_batch and snapshot_batch['batch_id']: + batch_id = snapshot_batch['batch_id'] + + # Get all snapshot entries for this batch + cur.execute(""" + SELECT location_name, shelf, old_amount + FROM supplies_location_history + WHERE batch_id = %s + AND action_type = 'SUPPLY_DELETE_SNAPSHOT' + AND undone = FALSE + """, (batch_id,)) + + snapshot_entries = cur.fetchall() + + # Re-insert all location rows from the snapshot + for entry in snapshot_entries: + cur.execute(""" + INSERT INTO supplies_location (supply_id, location_name, shelf, amount, last_modified_by) + VALUES (%s, %s, %s, %s, %s) + """, ( + restored_supply_id, + entry['location_name'], + entry['shelf'], + entry['old_amount'], + current_user_id + )) + + # Mark all snapshot entries as undone + cur.execute(""" + UPDATE supplies_location_history + SET undone = TRUE, undone_at = NOW(), undone_by = %s + WHERE batch_id = %s + AND action_type = 'SUPPLY_DELETE_SNAPSHOT' + """, (current_user_id, batch_id)) # Delete the history entry and all related data (CASCADE will handle teams/categories) cur.execute("DELETE FROM supplies_history WHERE id = %s", (history_id,)) diff --git a/src/api/routes/supplies_location.py b/src/api/routes/supplies_location.py index fff6a63..01830ab 100644 --- a/src/api/routes/supplies_location.py +++ b/src/api/routes/supplies_location.py @@ -9,9 +9,11 @@ from flask import Blueprint, request, jsonify import mysql.connector +import uuid from src.api.db import get_db from src.api.models.supply_location import SupplyLocation from src.api.middleware.auth import require_auth +from src.api.helpers.history import log_location_history supplies_location_bp = Blueprint('supplies_location', __name__) @@ -235,15 +237,29 @@ def add_supply_location(current_user_id=None): existing = cur.fetchone() + batch_id = str(uuid.uuid4()) if existing: # Update existing entry (increment amount) - new_amount = existing[1] + amount + old_amount = existing[1] + new_amount = old_amount + amount cur.execute(""" UPDATE supplies_location SET amount = %s, last_modified_by = %s WHERE id = %s """, (new_amount, current_user_id, existing[0])) location_id = existing[0] + # Log history: ADD action (incrementing existing) + log_location_history( + conn, 'ADD', + supply_id=supply_id, + supply_name=supply['name'], + location_name=location_name, + shelf=shelf, + old_amount=old_amount, + new_amount=new_amount, + changed_by=current_user_id, + batch_id=batch_id + ) else: # Insert new entry cur.execute(""" @@ -251,6 +267,18 @@ def add_supply_location(current_user_id=None): VALUES (%s, %s, %s, %s, %s) """, (supply_id, location_name, shelf, amount, current_user_id)) location_id = cur.lastrowid + # Log history: ADD action (new entry) + log_location_history( + conn, 'ADD', + supply_id=supply_id, + supply_name=supply['name'], + location_name=location_name, + shelf=shelf, + old_amount=None, + new_amount=amount, + changed_by=current_user_id, + batch_id=batch_id + ) conn.commit() @@ -303,19 +331,32 @@ def update_supply_location(location_id, current_user_id=None): return jsonify({'error': 'Request body is required'}), 400 conn = get_db() - cur = conn.cursor() + cur = conn.cursor(dictionary=True) - # Check if location exists - cur.execute("SELECT id FROM supplies_location WHERE id = %s", (location_id,)) - if not cur.fetchone(): + # Fetch current location data (for history) + cur.execute(""" + SELECT sl.id, sl.supply_id, sl.location_name, sl.shelf, sl.amount, + s.name as supply_name + FROM supplies_location sl + JOIN supplies s ON sl.supply_id = s.id + WHERE sl.id = %s + """, (location_id,)) + old_location = cur.fetchone() + if not old_location: cur.close() conn.close() return jsonify({'error': 'Supply location not found'}), 404 + cur = conn.cursor() # Switch to regular cursor for updates + # Build update query updates = [] values = [] + old_amount = old_location['amount'] + old_location_name = old_location['location_name'] + old_shelf = old_location['shelf'] + if 'amount' in data: if data['amount'] < 0: cur.close() @@ -341,6 +382,22 @@ def update_supply_location(location_id, current_user_id=None): if updates: query = f"UPDATE supplies_location SET {', '.join(updates)} WHERE id = %s" cur.execute(query, values) + + # Log history: UPDATE action (only if amount changed) + if 'amount' in data: + new_amount = data['amount'] + log_location_history( + conn, 'UPDATE', + supply_id=old_location['supply_id'], + supply_name=old_location['supply_name'], + location_name=old_location_name, + shelf=old_shelf, + old_amount=old_amount, + new_amount=new_amount, + changed_by=current_user_id, + batch_id=str(uuid.uuid4()) + ) + conn.commit() # Fetch updated location @@ -381,15 +438,36 @@ def delete_supply_location(location_id, current_user_id=None): """ try: conn = get_db() - cur = conn.cursor() + cur = conn.cursor(dictionary=True) - # Check if location exists - cur.execute("SELECT id FROM supplies_location WHERE id = %s", (location_id,)) - if not cur.fetchone(): + # Fetch location details before deleting (for history) + cur.execute(""" + SELECT sl.id, sl.supply_id, sl.location_name, sl.shelf, sl.amount, + s.name as supply_name + FROM supplies_location sl + JOIN supplies s ON sl.supply_id = s.id + WHERE sl.id = %s + """, (location_id,)) + location_data = cur.fetchone() + if not location_data: cur.close() conn.close() return jsonify({'error': 'Supply location not found'}), 404 + # Log history: REMOVE action + log_location_history( + conn, 'REMOVE', + supply_id=location_data['supply_id'], + supply_name=location_data['supply_name'], + location_name=location_data['location_name'], + shelf=location_data['shelf'], + old_amount=location_data['amount'], + new_amount=None, + changed_by=current_user_id, + batch_id=str(uuid.uuid4()) + ) + + cur = conn.cursor() # Switch back to regular cursor cur.execute("DELETE FROM supplies_location WHERE id = %s", (location_id,)) conn.commit() cur.close() @@ -437,13 +515,23 @@ def move_supply_locations(current_user_id=None): return jsonify({'error': 'Amount must be positive'}), 400 conn = get_db() - cur = conn.cursor() + cur = conn.cursor(dictionary=True) supply_id = data['supply_id'] shelf_from = data.get('shelf_from') shelf_to = data.get('shelf_to') amount = data['amount'] + # Get supply name for history + cur.execute("SELECT name FROM supplies WHERE id = %s", (supply_id,)) + supply = cur.fetchone() + if not supply: + cur.close() + conn.close() + return jsonify({'error': 'Supply not found'}), 404 + + supply_name = supply['name'] + # Get source location cur.execute(""" SELECT id, amount FROM supplies_location @@ -456,7 +544,8 @@ def move_supply_locations(current_user_id=None): conn.close() return jsonify({'error': 'Source supply location not found'}), 404 - source_id, source_amount = source + source_id = source['id'] + source_amount = source['amount'] if amount > source_amount: cur.close() @@ -474,8 +563,13 @@ def move_supply_locations(current_user_id=None): # Calculate new amounts new_source_amount = source_amount - amount + batch_id = str(uuid.uuid4()) + + cur = conn.cursor() # Switch to regular cursor for updates + if dest: - dest_id, dest_amount = dest + dest_id = dest['id'] + dest_amount = dest['amount'] new_dest_amount = dest_amount + amount cur.execute(""" UPDATE supplies_location @@ -499,6 +593,34 @@ def move_supply_locations(current_user_id=None): else: cur.execute("DELETE FROM supplies_location WHERE id = %s", (source_id,)) + # Log history: MOVE action (two rows: REMOVE from source, ADD to dest) + log_location_history( + conn, 'REMOVE', + supply_id=supply_id, + supply_name=supply_name, + location_name=data['from_location'], + shelf=shelf_from, + old_amount=source_amount, + new_amount=new_source_amount if new_source_amount > 0 else None, + changed_by=current_user_id, + related_location=data['to_location'], + related_shelf=shelf_to, + batch_id=batch_id + ) + log_location_history( + conn, 'ADD', + supply_id=supply_id, + supply_name=supply_name, + location_name=data['to_location'], + shelf=shelf_to, + old_amount=dest['amount'] if dest else None, + new_amount=new_dest_amount, + changed_by=current_user_id, + related_location=data['from_location'], + related_shelf=shelf_from, + batch_id=batch_id + ) + conn.commit() result = { @@ -554,11 +676,21 @@ def bulk_add_supply_locations(current_user_id=None): return jsonify({'error': 'additions must be a non-empty array'}), 400 conn = get_db() - cur = conn.cursor() + cur = conn.cursor(dictionary=True) supply_id = data['supply_id'] additions = data['additions'] + # Get supply name for history + cur.execute("SELECT name FROM supplies WHERE id = %s", (supply_id,)) + supply = cur.fetchone() + if not supply: + cur.close() + conn.close() + return jsonify({'error': 'Supply not found'}), 404 + + supply_name = supply['name'] + # Validate all additions for addition in additions: if 'location' not in addition or 'amount' not in addition: @@ -570,6 +702,11 @@ def bulk_add_supply_locations(current_user_id=None): conn.close() return jsonify({'error': 'Amount must be positive'}), 400 + # Generate batch_id for all additions + batch_id = str(uuid.uuid4()) + + cur = conn.cursor() # Switch to regular cursor for updates + # Process all additions in a transaction results = [] for addition in additions: @@ -587,7 +724,8 @@ def bulk_add_supply_locations(current_user_id=None): if existing: # Increment existing - new_amount = existing[1] + amount + old_amount = existing[1] + new_amount = old_amount + amount cur.execute(""" UPDATE supplies_location SET amount = %s, last_modified_by = %s @@ -599,6 +737,18 @@ def bulk_add_supply_locations(current_user_id=None): 'action': 'updated', 'new_amount': new_amount }) + # Log history: ADD action (incrementing existing) + log_location_history( + conn, 'ADD', + supply_id=supply_id, + supply_name=supply_name, + location_name=location_name, + shelf=shelf, + old_amount=old_amount, + new_amount=new_amount, + changed_by=current_user_id, + batch_id=batch_id + ) else: # Insert new cur.execute(""" @@ -611,6 +761,18 @@ def bulk_add_supply_locations(current_user_id=None): 'action': 'created', 'new_amount': amount }) + # Log history: ADD action (new entry) + log_location_history( + conn, 'ADD', + supply_id=supply_id, + supply_name=supply_name, + location_name=location_name, + shelf=shelf, + old_amount=None, + new_amount=amount, + changed_by=current_user_id, + batch_id=batch_id + ) conn.commit() diff --git a/src/api/routes/supplies_location_history.py b/src/api/routes/supplies_location_history.py new file mode 100644 index 0000000..cc3ced3 --- /dev/null +++ b/src/api/routes/supplies_location_history.py @@ -0,0 +1,484 @@ +""" +Supply Location History API routes. +""" +import sys +from pathlib import Path + +# Add src to path for imports (must be before other imports) +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from flask import Blueprint, request, jsonify +import mysql.connector +from src.api.db import get_db +from src.api.middleware.auth import require_auth + +supplies_location_history_bp = Blueprint('supplies_location_history', __name__) + + +@supplies_location_history_bp.route('', methods=['GET']) +@require_auth +def get_location_history(current_user_id=None): + """ + GET /api/supplies-location-history + Get paginated location history. + + Query parameters: + supply_id: Filter by supply ID + supply_name: Filter by supply name (for deleted supplies) + location_name: Filter by location name + limit: Number of results (default 50) + offset: Offset for pagination (default 0) + + Returns: + JSON array of history entries + """ + try: + supply_id = request.args.get('supply_id', type=int) + supply_name = request.args.get('supply_name') + location_name = request.args.get('location_name') + limit = request.args.get('limit', default=50, type=int) + offset = request.args.get('offset', default=0, type=int) + + conn = get_db() + cur = conn.cursor(dictionary=True) + + # Build query with filters + query = """ + SELECT + slh.id, + slh.supply_id, + slh.supply_name, + slh.location_name, + slh.shelf, + slh.action_type, + slh.old_amount, + slh.new_amount, + slh.related_location, + slh.related_shelf, + slh.batch_id, + slh.undone, + slh.undone_at, + slh.undone_by, + slh.changed_by, + slh.changed_at, + m.first_name, + m.last_name + FROM supplies_location_history slh + LEFT JOIN members m ON slh.changed_by = m.uf_id + WHERE 1=1 + """ + params = [] + + if supply_id: + query += " AND slh.supply_id = %s" + params.append(supply_id) + + if supply_name: + query += " AND slh.supply_name LIKE %s" + params.append(f'%{supply_name}%') + + if location_name: + query += " AND slh.location_name = %s" + params.append(location_name) + + query += " ORDER BY slh.changed_at DESC LIMIT %s OFFSET %s" + params.extend([limit, offset]) + + cur.execute(query, params) + rows = cur.fetchall() + + # Format results + results = [] + for row in rows: + result = { + 'id': row['id'], + 'supply_id': row['supply_id'], + 'supply_name': row['supply_name'], + 'location_name': row['location_name'], + 'shelf': row['shelf'], + 'action_type': row['action_type'], + 'old_amount': row['old_amount'], + 'new_amount': row['new_amount'], + 'related_location': row['related_location'], + 'related_shelf': row['related_shelf'], + 'batch_id': row['batch_id'], + 'undone': bool(row['undone']), + 'undone_at': row['undone_at'].isoformat() if row['undone_at'] else None, + 'undone_by': row['undone_by'], + 'changed_by': row['changed_by'], + 'changed_by_name': f"{row['first_name']} {row['last_name']}" if row['first_name'] and row['last_name'] else None, + 'changed_at': row['changed_at'].isoformat() if row['changed_at'] else None + } + results.append(result) + + cur.close() + conn.close() + + return jsonify(results), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@supplies_location_history_bp.route('//undo', methods=['POST']) +@require_auth +def undo_location_history(history_id, current_user_id=None): + """ + POST /api/supplies-location-history//undo + Undo a single history entry. + + Args: + history_id: History entry ID to undo + + Returns: + JSON object of the updated history entry + """ + try: + conn = get_db() + cur = conn.cursor(dictionary=True) + + # Fetch history entry + cur.execute(""" + SELECT id, supply_id, supply_name, location_name, shelf, + action_type, old_amount, new_amount, + related_location, related_shelf, batch_id, undone + FROM supplies_location_history + WHERE id = %s + """, (history_id,)) + + history = cur.fetchone() + if not history: + cur.close() + conn.close() + return jsonify({'error': 'History entry not found'}), 404 + + if history['undone']: + cur.close() + conn.close() + return jsonify({'error': 'This action has already been undone'}), 400 + + action_type = history['action_type'] + + # Handle SUPPLY_DELETE_SNAPSHOT separately (cannot undo individual snapshots) + if action_type == 'SUPPLY_DELETE_SNAPSHOT': + cur.close() + conn.close() + return jsonify({ + 'error': 'Cannot undo individual snapshot entries. Use batch restore endpoint instead.', + 'error_type': 'SNAPSHOT_ENTRY' + }), 400 + + cur = conn.cursor() # Switch to regular cursor for updates + + # Undo based on action type + if action_type == 'ADD': + # Decrement amount by (new_amount - old_amount) + # If old_amount was None, decrement by new_amount + amount_to_remove = history['new_amount'] - (history['old_amount'] or 0) + + # Check if supply still exists + if history['supply_id']: + cur.execute("SELECT id FROM supplies WHERE id = %s", (history['supply_id'],)) + if not cur.fetchone(): + cur.close() + conn.close() + return jsonify({ + 'error': 'Supply no longer exists', + 'error_type': 'SUPPLY_DELETED', + 'supply_name': history['supply_name'] + }), 409 + + # Find current location entry + cur.execute(""" + SELECT id, amount FROM supplies_location + WHERE supply_id = %s AND location_name = %s + AND (shelf = %s OR (shelf IS NULL AND %s IS NULL)) + """, (history['supply_id'], history['location_name'], + history['shelf'], history['shelf'])) + + current = cur.fetchone() + if current: + new_amount = current[1] - amount_to_remove + if new_amount <= 0: + # Delete the row + cur.execute("DELETE FROM supplies_location WHERE id = %s", (current[0],)) + else: + # Update amount + cur.execute(""" + UPDATE supplies_location + SET amount = %s, last_modified_by = %s + WHERE id = %s + """, (new_amount, current_user_id, current[0])) + else: + # Location entry doesn't exist (already deleted), can't undo + cur.close() + conn.close() + return jsonify({ + 'error': 'Location entry no longer exists, cannot undo', + 'error_type': 'LOCATION_DELETED' + }), 409 + + elif action_type == 'REMOVE': + # Re-insert or increment location entry with old_amount + if not history['supply_id']: + cur.close() + conn.close() + return jsonify({ + 'error': 'Supply no longer exists', + 'error_type': 'SUPPLY_DELETED', + 'supply_name': history['supply_name'] + }), 409 + + # Check if supply still exists + cur.execute("SELECT id FROM supplies WHERE id = %s", (history['supply_id'],)) + if not cur.fetchone(): + cur.close() + conn.close() + return jsonify({ + 'error': 'Supply no longer exists', + 'error_type': 'SUPPLY_DELETED', + 'supply_name': history['supply_name'] + }), 409 + + # Check if location entry exists + cur.execute(""" + SELECT id, amount FROM supplies_location + WHERE supply_id = %s AND location_name = %s + AND (shelf = %s OR (shelf IS NULL AND %s IS NULL)) + """, (history['supply_id'], history['location_name'], + history['shelf'], history['shelf'])) + + existing = cur.fetchone() + if existing: + # Increment by old_amount + new_amount = existing[1] + (history['old_amount'] or 0) + cur.execute(""" + UPDATE supplies_location + SET amount = %s, last_modified_by = %s + WHERE id = %s + """, (new_amount, current_user_id, existing[0])) + else: + # Insert new entry + cur.execute(""" + INSERT INTO supplies_location (supply_id, location_name, shelf, amount, last_modified_by) + VALUES (%s, %s, %s, %s, %s) + """, (history['supply_id'], history['location_name'], + history['shelf'], history['old_amount'], current_user_id)) + + elif action_type == 'UPDATE': + # Restore old_amount + if not history['supply_id']: + cur.close() + conn.close() + return jsonify({ + 'error': 'Supply no longer exists', + 'error_type': 'SUPPLY_DELETED', + 'supply_name': history['supply_name'] + }), 409 + + cur.execute(""" + UPDATE supplies_location + SET amount = %s, last_modified_by = %s + WHERE supply_id = %s AND location_name = %s + AND (shelf = %s OR (shelf IS NULL AND %s IS NULL)) + """, (history['old_amount'], current_user_id, + history['supply_id'], history['location_name'], + history['shelf'], history['shelf'])) + + elif action_type == 'MOVE': + # Find the paired history row via batch_id and reverse both legs + cur.execute(""" + SELECT id, location_name, shelf, old_amount, new_amount + FROM supplies_location_history + WHERE batch_id = %s AND id != %s AND undone = FALSE + """, (history['batch_id'], history_id)) + + paired = cur.fetchone() + if not paired: + cur.close() + conn.close() + return jsonify({'error': 'Paired MOVE entry not found or already undone'}), 400 + + # Reverse both legs: undo REMOVE by restoring source, undo ADD by removing from dest + if history['action_type'] == 'REMOVE': + # This is the REMOVE leg - restore source + cur.execute(""" + SELECT id, amount FROM supplies_location + WHERE supply_id = %s AND location_name = %s + AND (shelf = %s OR (shelf IS NULL AND %s IS NULL)) + """, (history['supply_id'], history['location_name'], + history['shelf'], history['shelf'])) + + existing = cur.fetchone() + amount_to_restore = history['old_amount'] - (history['new_amount'] or 0) + if existing: + new_amount = existing[1] + amount_to_restore + cur.execute(""" + UPDATE supplies_location + SET amount = %s, last_modified_by = %s + WHERE id = %s + """, (new_amount, current_user_id, existing[0])) + else: + cur.execute(""" + INSERT INTO supplies_location (supply_id, location_name, shelf, amount, last_modified_by) + VALUES (%s, %s, %s, %s, %s) + """, (history['supply_id'], history['location_name'], + history['shelf'], amount_to_restore, current_user_id)) + + # Undo the ADD leg (remove from destination) + cur.execute(""" + SELECT id, amount FROM supplies_location + WHERE supply_id = %s AND location_name = %s + AND (shelf = %s OR (shelf IS NULL AND %s IS NULL)) + """, (history['supply_id'], paired['location_name'], + paired['shelf'], paired['shelf'])) + + dest_existing = cur.fetchone() + if dest_existing: + amount_to_remove = paired['new_amount'] - (paired['old_amount'] or 0) + new_dest_amount = dest_existing[1] - amount_to_remove + if new_dest_amount <= 0: + cur.execute("DELETE FROM supplies_location WHERE id = %s", (dest_existing[0],)) + else: + cur.execute(""" + UPDATE supplies_location + SET amount = %s, last_modified_by = %s + WHERE id = %s + """, (new_dest_amount, current_user_id, dest_existing[0])) + + # Mark paired entry as undone too + cur.execute(""" + UPDATE supplies_location_history + SET undone = TRUE, undone_at = NOW(), undone_by = %s + WHERE id = %s + """, (current_user_id, paired['id'])) + + # Mark this history entry as undone + cur.execute(""" + UPDATE supplies_location_history + SET undone = TRUE, undone_at = NOW(), undone_by = %s + WHERE id = %s + """, (current_user_id, history_id)) + + conn.commit() + + # Fetch updated history entry + cur = conn.cursor(dictionary=True) + cur.execute(""" + SELECT + slh.id, + slh.supply_id, + slh.supply_name, + slh.location_name, + slh.shelf, + slh.action_type, + slh.old_amount, + slh.new_amount, + slh.related_location, + slh.related_shelf, + slh.batch_id, + slh.undone, + slh.undone_at, + slh.undone_by, + slh.changed_by, + slh.changed_at, + m.first_name, + m.last_name + FROM supplies_location_history slh + LEFT JOIN members m ON slh.changed_by = m.uf_id + WHERE slh.id = %s + """, (history_id,)) + + updated = cur.fetchone() + result = { + 'id': updated['id'], + 'supply_id': updated['supply_id'], + 'supply_name': updated['supply_name'], + 'location_name': updated['location_name'], + 'shelf': updated['shelf'], + 'action_type': updated['action_type'], + 'old_amount': updated['old_amount'], + 'new_amount': updated['new_amount'], + 'related_location': updated['related_location'], + 'related_shelf': updated['related_shelf'], + 'batch_id': updated['batch_id'], + 'undone': bool(updated['undone']), + 'undone_at': updated['undone_at'].isoformat() if updated['undone_at'] else None, + 'undone_by': updated['undone_by'], + 'changed_by': updated['changed_by'], + 'changed_by_name': f"{updated['first_name']} {updated['last_name']}" if updated['first_name'] and updated['last_name'] else None, + 'changed_at': updated['changed_at'].isoformat() if updated['changed_at'] else None + } + + cur.close() + conn.close() + + return jsonify(result), 200 + except mysql.connector.IntegrityError as e: + if 'foreign key constraint' in str(e).lower(): + return jsonify({'error': 'Supply or location does not exist'}), 400 + return jsonify({'error': str(e)}), 400 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@supplies_location_history_bp.route('/batch//undo', methods=['POST']) +@require_auth +def undo_batch_history(batch_id, current_user_id=None): + """ + POST /api/supplies-location-history/batch//undo + Undo all non-undone history entries sharing a batch_id atomically. + + Args: + batch_id: Batch ID (UUID string) + + Returns: + JSON object with count of undone entries + """ + try: + conn = get_db() + cur = conn.cursor(dictionary=True) + + # Fetch all non-undone entries for this batch + cur.execute(""" + SELECT id, supply_id, supply_name, location_name, shelf, + action_type, old_amount, new_amount, + related_location, related_shelf, undone + FROM supplies_location_history + WHERE batch_id = %s AND undone = FALSE + ORDER BY id + """, (batch_id,)) + + entries = cur.fetchall() + if not entries: + cur.close() + conn.close() + return jsonify({'error': 'No undoable entries found for this batch'}), 404 + + # Undo each entry (reuse the undo logic from single undo endpoint) + undone_count = 0 + for entry in entries: + # Call the undo logic inline (simplified version) + # For simplicity, we'll mark them all as undone and let the frontend + # handle the actual data reversal via individual undo calls + # OR we can implement full reversal here + + # For now, mark as undone (actual reversal would require full logic) + cur.execute(""" + UPDATE supplies_location_history + SET undone = TRUE, undone_at = NOW(), undone_by = %s + WHERE id = %s + """, (current_user_id, entry['id'])) + undone_count += 1 + + conn.commit() + cur.close() + conn.close() + + return jsonify({ + 'success': True, + 'batch_id': batch_id, + 'undone_count': undone_count + }), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + diff --git a/src/scripts/migrate_supplies_location_history.py b/src/scripts/migrate_supplies_location_history.py new file mode 100644 index 0000000..1500f91 --- /dev/null +++ b/src/scripts/migrate_supplies_location_history.py @@ -0,0 +1,81 @@ +""" +Migration script to create the supplies_location_history table. +""" +import sys +import os +from pathlib import Path + +# Add project root to path for imports +project_root = Path(__file__).parent.parent.parent +sys.path.insert(0, str(project_root)) + +import mysql.connector +from src.scripts.helpers import parse_database_url, get_sql_base_path, execute_sql_file, table_exists + +def migrate_supplies_location_history(): + """Create the supplies_location_history table if it doesn't exist.""" + try: + # Use environment variables or defaults (same as app.py) + database_url = os.getenv("DATABASE_URL") + if not database_url: + # Build from individual env vars (same pattern as src/api/db.py) + db_host = os.getenv('DB_HOST', 'localhost') + db_port = int(os.getenv('DB_PORT', 3306)) + db_user = os.getenv('DB_USER', 'mysqluser') + db_password = os.getenv('DB_PASSWORD', 'mysqlpassword') + db_name = os.getenv('DB_NAME', 'mydb') + database_url = f"mysql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}" + + db_params = parse_database_url(database_url) + # Use Pure Python connector to avoid auth plugin issues + db_params['use_pure'] = True + db_params['auth_plugin'] = 'mysql_native_password' + + print("Migrating supplies_location_history table...") + + conn = mysql.connector.connect(**db_params) + cur = conn.cursor() + + # Check if table already exists + if table_exists(cur, 'supplies_location_history'): + print("[OK] supplies_location_history table already exists, skipping migration") + cur.close() + conn.close() + return True + + # Get the SQL file path + sql_base_path = get_sql_base_path(__file__) + sql_file = sql_base_path / 'supplies_location' / 'table_supplies_location_history.sql' + + if not sql_file.exists(): + print(f"[ERROR] SQL file not found: {sql_file}") + cur.close() + conn.close() + return False + + print(f"Creating supplies_location_history table from {sql_file.name}...") + + # Execute the SQL file + if execute_sql_file(cur, sql_file, "supplies_location_history table"): + conn.commit() + print("[OK] Migration completed successfully") + cur.close() + conn.close() + return True + else: + conn.rollback() + print("[ERROR] Migration failed") + cur.close() + conn.close() + return False + + except Exception as e: + print(f"[ERROR] Migration error: {e}") + import traceback + traceback.print_exc() + return False + +if __name__ == '__main__': + success = migrate_supplies_location_history() + sys.exit(0 if success else 1) + diff --git a/src/sql/supplies_location/table_supplies_location_history.sql b/src/sql/supplies_location/table_supplies_location_history.sql new file mode 100644 index 0000000..243f997 --- /dev/null +++ b/src/sql/supplies_location/table_supplies_location_history.sql @@ -0,0 +1,61 @@ +-- History of all location operations (ADD, REMOVE, UPDATE, MOVE, SUPPLY_DELETE_SNAPSHOT) +CREATE TABLE supplies_location_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + + -- Supply reference (nullable: SET NULL when supply is hard-deleted) + supply_id BIGINT NULL, + -- Denormalized supply name: REQUIRED for restoration after supply deletion + supply_name VARCHAR(200) NOT NULL, + + -- Location info (denormalized: location rows can be renamed/deleted) + location_name VARCHAR(100) NOT NULL, + shelf INT DEFAULT NULL, + + -- Action + action_type ENUM('ADD', 'REMOVE', 'UPDATE', 'MOVE', 'SUPPLY_DELETE_SNAPSHOT') NOT NULL, + -- ADD = units placed into this location + -- REMOVE = units removed from this location + -- UPDATE = amount changed directly (e.g. edit qty field) + -- MOVE = units moved between locations (generates two rows: one REMOVE, one ADD) + -- SUPPLY_DELETE_SNAPSHOT = snapshot of location state at moment supply was deleted + + -- Amounts (NULL means "not applicable" for that side) + old_amount INT DEFAULT NULL, -- amount before change (NULL for ADD) + new_amount INT DEFAULT NULL, -- amount after change (NULL for REMOVE/SNAPSHOT) + + -- For MOVE actions: where the units came from / went to + related_location VARCHAR(100) DEFAULT NULL, -- the other location in a MOVE + related_shelf INT DEFAULT NULL, + + -- For grouping a single logical operation (e.g. bulk-add touches multiple rows) + batch_id CHAR(36) DEFAULT NULL, -- UUID generated per API call + + -- Undo bookkeeping + undone BOOLEAN NOT NULL DEFAULT FALSE, + undone_at DATETIME DEFAULT NULL, + undone_by CHAR(8) DEFAULT NULL, + + -- Audit + changed_by CHAR(8) DEFAULT NULL, + changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- Indexes + INDEX idx_supply_id (supply_id), + INDEX idx_supply_name (supply_name), + INDEX idx_location (location_name), + INDEX idx_changed_at (changed_at DESC), + INDEX idx_batch (batch_id), + INDEX idx_undone (undone, changed_at DESC), + + -- FKs + CONSTRAINT fk_slh_supply + FOREIGN KEY (supply_id) REFERENCES supplies(id) + ON UPDATE CASCADE ON DELETE SET NULL, + CONSTRAINT fk_slh_changed_by + FOREIGN KEY (changed_by) REFERENCES members(uf_id) + ON UPDATE CASCADE ON DELETE SET NULL, + CONSTRAINT fk_slh_undone_by + FOREIGN KEY (undone_by) REFERENCES members(uf_id) + ON UPDATE CASCADE ON DELETE SET NULL +); + From fd4c946055e53dbc1feb12960b209e1439e6d89e Mon Sep 17 00:00:00 2001 From: willzoo Date: Wed, 4 Mar 2026 15:33:17 -0500 Subject: [PATCH 26/73] rename delete some to subtract for clarity --- milventory/src/components/Map.js | 72 ++++---- .../src/components/MasterItemPreview.js | 10 +- ...eModePreview.js => SubtractModePreview.js} | 48 +++-- milventory/src/context/InventoryContext.js | 166 +++++++++--------- 4 files changed, 147 insertions(+), 149 deletions(-) rename milventory/src/components/{DeleteModePreview.js => SubtractModePreview.js} (75%) diff --git a/milventory/src/components/Map.js b/milventory/src/components/Map.js index 1afbabc..c02da65 100644 --- a/milventory/src/components/Map.js +++ b/milventory/src/components/Map.js @@ -5,7 +5,7 @@ import ArrowConnections from './ArrowConnections'; import BoxInventoryOverlay from './BoxInventoryOverlay'; import AddModeArrow from './AddModeArrow'; import MoveModeBoxes from './MoveModeBoxes'; -import DeleteModePreview from './DeleteModePreview'; +import SubtractModePreview from './SubtractModePreview'; const SHELF_NAMES = [ 'Shelf 6 (Top)', @@ -17,13 +17,13 @@ const SHELF_NAMES = [ ]; const MapComponent = forwardRef((props, ref) => { - const { worldRef, inventoryData, inventoryBounds, selectedBox, currentDragOverBox, handleBoxClick, handleBoxHover, handleBoxHoverLeave, handleDrop, setCurrentDragOverBox, addModeItem, addModePending, handleBoxClickAddMode, boxHasAnyPending, selectedMasterItem, getItemLocations, moveModeItem, moveModeDragging, handleMoveModeDrop, deleteModeItem, deleteModePending, handleBoxClickDeleteMode, boxHasAnyDeletePending } = useInventory(); + const { worldRef, inventoryData, inventoryBounds, selectedBox, currentDragOverBox, handleBoxClick, handleBoxHover, handleBoxHoverLeave, handleDrop, setCurrentDragOverBox, addModeItem, addModePending, handleBoxClickAddMode, boxHasAnyPending, selectedMasterItem, getItemLocations, moveModeItem, moveModeDragging, handleMoveModeDrop, subtractModeItem, subtractModePending, handleBoxClickSubtractMode, boxHasAnySubtractPending } = useInventory(); // Compute highlighted box set from selected Master item (for React-managed className) const highlightedBoxes = selectedMasterItem ? new Set(getItemLocations(selectedMasterItem)) : null; - // Compute highlighted box set for delete mode (all boxes containing the item) - const deleteModeHighlightedBoxes = deleteModeItem ? new Set(getItemLocations(deleteModeItem)) : null; + // Compute highlighted box set for subtract mode (all boxes containing the item) + const subtractModeHighlightedBoxes = subtractModeItem ? new Set(getItemLocations(subtractModeItem)) : null; const handleBoxMouseEnter = (e, boxTitle) => { const rect = e.currentTarget.getBoundingClientRect(); @@ -130,8 +130,8 @@ const MapComponent = forwardRef((props, ref) => { ry: 18 }; - // All Tall Cabinets get shelf overlays in add mode, delete mode, or move mode - const tallCabinets = (addModeItem || deleteModeItem || moveModeItem) + // All Tall Cabinets get shelf overlays in add mode, subtract mode, or move mode + const tallCabinets = (addModeItem || subtractModeItem || moveModeItem) ? boxes.filter(b => b.title.startsWith('Tall Cabinet')) : []; @@ -147,25 +147,25 @@ const MapComponent = forwardRef((props, ref) => { const hasAddPending = isRegularBox && addModeItem && addModePending.has(box.title); const addPendingQty = hasAddPending ? addModePending.get(box.title) : null; - // For delete mode, get current quantity, deleted quantity, and remaining - const hasDeletePending = isRegularBox && deleteModeItem && deleteModePending.has(box.title); - const deletePendingQty = hasDeletePending ? deleteModePending.get(box.title) : 0; - const hasDeleteItem = isRegularBox && deleteModeItem && deleteModeHighlightedBoxes && deleteModeHighlightedBoxes.has(box.title); + // For subtract mode, get current quantity, subtracted quantity, and remaining + const hasSubtractPending = isRegularBox && subtractModeItem && subtractModePending.has(box.title); + const subtractPendingQty = hasSubtractPending ? subtractModePending.get(box.title) : 0; + const hasSubtractItem = isRegularBox && subtractModeItem && subtractModeHighlightedBoxes && subtractModeHighlightedBoxes.has(box.title); let currentQty = 0; let remainingQty = 0; - if (deleteModeItem && isRegularBox) { + if (subtractModeItem && isRegularBox) { const boxData = inventoryData.get(box.title); if (boxData) { - const matchingItems = boxData.inventory.filter(item => item.name === deleteModeItem); + const matchingItems = boxData.inventory.filter(item => item.name === subtractModeItem); currentQty = matchingItems.reduce((sum, item) => sum + (item.qty || 0), 0); - remainingQty = Math.max(0, currentQty - deletePendingQty); + remainingQty = Math.max(0, currentQty - subtractPendingQty); } } return ( { if (!box.title.startsWith('Tall Cabinet')) { handleBoxClickAddMode(box.title); } - } else if (deleteModeItem) { + } else if (subtractModeItem) { // For Tall Cabinets, shelf rects on top handle clicks if (!box.title.startsWith('Tall Cabinet')) { - handleBoxClickDeleteMode(box.title); + handleBoxClickSubtractMode(box.title); } } else { handleBoxClick(box.title); @@ -207,7 +207,7 @@ const MapComponent = forwardRef((props, ref) => { +{addPendingQty} )} - {hasDeleteItem && ( + {hasSubtractItem && ( { textAnchor="end" dominantBaseline="middle" pointerEvents="none" - fill={hasDeletePending ? "#ff6b6b" : "var(--accent)"} + fill={hasSubtractPending ? "#ff6b6b" : "var(--accent)"} > - {hasDeletePending ? `${currentQty} / -${deletePendingQty} / ${remainingQty}` : currentQty} + {hasSubtractPending ? `${currentQty} / -${subtractPendingQty} / ${remainingQty}` : currentQty} )} ); })} - {/* Shelf overlays for all Tall Cabinets in add mode, delete mode, or move mode */} + {/* Shelf overlays for all Tall Cabinets in add mode, subtract mode, or move mode */} {tallCabinets.map(box => { const shelfH = box.height / SHELF_NAMES.length; return ( @@ -235,25 +235,25 @@ const MapComponent = forwardRef((props, ref) => { const isAddAffected = addModeItem && addModePending.has(pendingKey); const addPendingQty = addModePending.get(pendingKey); - // For delete mode, get current quantity, deleted quantity, and remaining - const isDeleteAffected = deleteModeItem && deleteModePending.has(pendingKey); - const deletePendingQty = isDeleteAffected ? deleteModePending.get(pendingKey) : 0; + // For subtract mode, get current quantity, subtracted quantity, and remaining + const isSubtractAffected = subtractModeItem && subtractModePending.has(pendingKey); + const subtractPendingQty = isSubtractAffected ? subtractModePending.get(pendingKey) : 0; let currentQty = 0; let remainingQty = 0; - let hasDeleteItemOnShelf = false; - if (deleteModeItem) { + let hasSubtractItemOnShelf = false; + if (subtractModeItem) { const boxData = inventoryData.get(box.title); if (boxData) { const matchingItems = boxData.inventory.filter(item => - item.name === deleteModeItem && (item.shelf ?? 0) === idx + item.name === subtractModeItem && (item.shelf ?? 0) === idx ); currentQty = matchingItems.reduce((sum, item) => sum + (item.qty || 0), 0); - remainingQty = Math.max(0, currentQty - deletePendingQty); - hasDeleteItemOnShelf = currentQty > 0; + remainingQty = Math.max(0, currentQty - subtractPendingQty); + hasSubtractItemOnShelf = currentQty > 0; } } - const isAffected = isAddAffected || isDeleteAffected || hasDeleteItemOnShelf; + const isAffected = isAddAffected || isSubtractAffected || hasSubtractItemOnShelf; return ( @@ -263,12 +263,12 @@ const MapComponent = forwardRef((props, ref) => { y={shelfY} width={box.width} height={shelfH} - onClick={(addModeItem || deleteModeItem) ? (e) => { + onClick={(addModeItem || subtractModeItem) ? (e) => { e.stopPropagation(); if (addModeItem) { handleBoxClickAddMode(box.title, idx); - } else if (deleteModeItem) { - handleBoxClickDeleteMode(box.title, idx); + } else if (subtractModeItem) { + handleBoxClickSubtractMode(box.title, idx); } } : undefined} style={moveModeItem ? { pointerEvents: 'none' } : undefined} @@ -295,7 +295,7 @@ const MapComponent = forwardRef((props, ref) => { +{addPendingQty} )} - {hasDeleteItemOnShelf && ( + {hasSubtractItemOnShelf && ( { textAnchor="end" dominantBaseline="middle" pointerEvents="none" - fill={isDeleteAffected ? "#ff6b6b" : "var(--accent)"} + fill={isSubtractAffected ? "#ff6b6b" : "var(--accent)"} > - {isDeleteAffected ? `${currentQty} / -${deletePendingQty} / ${remainingQty}` : currentQty} + {isSubtractAffected ? `${currentQty} / -${subtractPendingQty} / ${remainingQty}` : currentQty} )} @@ -322,7 +322,7 @@ const MapComponent = forwardRef((props, ref) => { - + ); }); diff --git a/milventory/src/components/MasterItemPreview.js b/milventory/src/components/MasterItemPreview.js index f85a1ab..75db98b 100644 --- a/milventory/src/components/MasterItemPreview.js +++ b/milventory/src/components/MasterItemPreview.js @@ -13,7 +13,7 @@ const MasterItemPreview = () => { deleteMasterItem, startAddMode, startMoveMode, - startDeleteMode, + startSubtractMode, cancelMoveMode, moveModeItem, leftPaneWidth, @@ -95,8 +95,8 @@ const MasterItemPreview = () => { startMoveMode(selectedMasterItem); }; - const handleDelete = () => { - startDeleteMode(selectedMasterItem); + const handleSubtract = () => { + startSubtractMode(selectedMasterItem); }; const handleEdit = () => { @@ -231,8 +231,8 @@ const MasterItemPreview = () => { - @@ -140,7 +140,5 @@ const DeleteModePreview = () => { ); }; -export default DeleteModePreview; - - +export default SubtractModePreview; diff --git a/milventory/src/context/InventoryContext.js b/milventory/src/context/InventoryContext.js index 60b33c7..328398a 100644 --- a/milventory/src/context/InventoryContext.js +++ b/milventory/src/context/InventoryContext.js @@ -49,14 +49,14 @@ export const InventoryProvider = ({ children }) => { const addModePendingRef = useRef(new Map()); const addModeQtyPerClickRef = useRef(1); - // Delete Mode state - const [deleteModeItem, setDeleteModeItem] = useState(null); - const [deleteModeQtyPerClick, setDeleteModeQtyPerClick] = useState(1); - const [deleteModePending, setDeleteModePending] = useState(new Map()); // Map - const deleteModePreviewRef = useRef(null); - const deleteModeItemRef = useRef(null); - const deleteModePendingRef = useRef(new Map()); - const deleteModeQtyPerClickRef = useRef(1); + // Subtract Mode state (removes items from boxes, not the master entry) + const [subtractModeItem, setSubtractModeItem] = useState(null); + const [subtractModeQtyPerClick, setSubtractModeQtyPerClick] = useState(1); + const [subtractModePending, setSubtractModePending] = useState(new Map()); // Map + const subtractModePreviewRef = useRef(null); + const subtractModeItemRef = useRef(null); + const subtractModePendingRef = useRef(new Map()); + const subtractModeQtyPerClickRef = useRef(1); // Move Mode state const [moveModeItem, setMoveModeItem] = useState(null); @@ -78,16 +78,16 @@ export const InventoryProvider = ({ children }) => { }, [addModeQtyPerClick]); useEffect(() => { - deleteModeItemRef.current = deleteModeItem; - }, [deleteModeItem]); + subtractModeItemRef.current = subtractModeItem; + }, [subtractModeItem]); useEffect(() => { - deleteModePendingRef.current = deleteModePending; - }, [deleteModePending]); + subtractModePendingRef.current = subtractModePending; + }, [subtractModePending]); useEffect(() => { - deleteModeQtyPerClickRef.current = deleteModeQtyPerClick; - }, [deleteModeQtyPerClick]); + subtractModeQtyPerClickRef.current = subtractModeQtyPerClick; + }, [subtractModeQtyPerClick]); useEffect(() => { moveModeItemRef.current = moveModeItem; @@ -512,18 +512,18 @@ export const InventoryProvider = ({ children }) => { setAddModePending(new Map()); }, []); - // Delete Mode functions - const startDeleteMode = useCallback((itemName) => { - setDeleteModeItem(itemName); - setDeleteModeQtyPerClick(1); - setDeleteModePending(new Map()); - setSelectedBox(null); // Clear box selection when entering delete mode - setSelectedMasterItem(null); // Clear Master preview when entering delete mode + // Subtract Mode functions (removes items from boxes, not the master entry) + const startSubtractMode = useCallback((itemName) => { + setSubtractModeItem(itemName); + setSubtractModeQtyPerClick(1); + setSubtractModePending(new Map()); + setSelectedBox(null); // Clear box selection when entering subtract mode + setSelectedMasterItem(null); // Clear Master preview when entering subtract mode }, []); // shelf is optional — undefined for non-shelf boxes, number for Tall Cabinet shelves - const handleBoxClickDeleteMode = useCallback((boxTitle, shelf) => { - const qty = deleteModeQtyPerClickRef.current; + const handleBoxClickSubtractMode = useCallback((boxTitle, shelf) => { + const qty = subtractModeQtyPerClickRef.current; const key = shelf !== undefined ? `${boxTitle}||${shelf}` : boxTitle; // Get current quantity in this location @@ -531,44 +531,44 @@ export const InventoryProvider = ({ children }) => { if (!boxData) return; const matchingItems = boxData.inventory.filter(item => { - if (item.name !== deleteModeItemRef.current) return false; + if (item.name !== subtractModeItemRef.current) return false; if (shelf !== undefined) return (item.shelf ?? 0) === shelf; return item.shelf === undefined; }); const currentQty = matchingItems.reduce((sum, item) => sum + (item.qty || 0), 0); - const existingPending = deleteModePendingRef.current.get(key) || 0; + const existingPending = subtractModePendingRef.current.get(key) || 0; - // Don't allow deleting more than what's available - const maxDeletable = currentQty - existingPending; - const toDelete = Math.min(qty, maxDeletable); + // Don't allow subtracting more than what's available + const maxSubtractable = currentQty - existingPending; + const toSubtract = Math.min(qty, maxSubtractable); - if (toDelete <= 0) return; // Nothing to delete + if (toSubtract <= 0) return; // Nothing to subtract - setDeleteModePending(prev => { + setSubtractModePending(prev => { const next = new Map(prev); const existing = next.get(key) || 0; - next.set(key, existing + toDelete); + next.set(key, existing + toSubtract); return next; }); }, [inventoryData]); - // Check if any pending deletion belongs to a given box (handles compound keys) - const boxHasAnyDeletePending = useCallback((boxTitle) => { - for (const key of deleteModePending.keys()) { + // Check if any pending subtraction belongs to a given box (handles compound keys) + const boxHasAnySubtractPending = useCallback((boxTitle) => { + for (const key of subtractModePending.keys()) { if (key === boxTitle || key.startsWith(boxTitle + '||')) return true; } return false; - }, [deleteModePending]); + }, [subtractModePending]); - const finishDeleteMode = useCallback(async () => { - const currentItem = deleteModeItemRef.current; - const pending = deleteModePendingRef.current; + const finishSubtractMode = useCallback(async () => { + const currentItem = subtractModeItemRef.current; + const pending = subtractModePendingRef.current; if (!currentItem) { - setDeleteModeItem(null); - setDeleteModeQtyPerClick(1); - setDeleteModePending(new Map()); + setSubtractModeItem(null); + setSubtractModeQtyPerClick(1); + setSubtractModePending(new Map()); return; } @@ -580,9 +580,9 @@ export const InventoryProvider = ({ children }) => { return; } - // Convert pending map to deletions + // Convert pending map to subtractions // Use functional update to get latest inventoryData - const deletions = []; + const subtractions = []; let currentInventoryData = inventoryData; pending.forEach((pendingQty, key) => { @@ -600,72 +600,72 @@ export const InventoryProvider = ({ children }) => { return item.shelf === undefined; }); - // For each matching item, we need to delete or reduce it - let remainingToDelete = pendingQty; + // For each matching item, we need to subtract or reduce it + let remainingToSubtract = pendingQty; matchingItems.forEach(item => { - if (item.id && remainingToDelete > 0) { - const deleteQty = Math.min(remainingToDelete, item.qty); - deletions.push({ + if (item.id && remainingToSubtract > 0) { + const subtractQty = Math.min(remainingToSubtract, item.qty); + subtractions.push({ id: item.id, location: boxTitle, shelf: shelf, - amount: deleteQty + amount: subtractQty }); - remainingToDelete -= deleteQty; + remainingToSubtract -= subtractQty; } }); }); - if (deletions.length === 0) { - setDeleteModeItem(null); - setDeleteModeQtyPerClick(1); - setDeleteModePending(new Map()); + if (subtractions.length === 0) { + setSubtractModeItem(null); + setSubtractModeQtyPerClick(1); + setSubtractModePending(new Map()); return; } try { - // Delete items via API - for (const deletion of deletions) { + // Subtract items via API + for (const subtraction of subtractions) { // Get the item from current state to check quantity - const boxData = currentInventoryData.get(deletion.location); - const item = boxData?.inventory.find(i => i.id === deletion.id); + const boxData = currentInventoryData.get(subtraction.location); + const item = boxData?.inventory.find(i => i.id === subtraction.id); if (!item) continue; - if (item.qty <= deletion.amount) { - // Delete the entire entry - await api.deleteSupplyLocation(deletion.id); + if (item.qty <= subtraction.amount) { + // Delete the entire entry (no items left in this box) + await api.deleteSupplyLocation(subtraction.id); } else { // Reduce the quantity - await api.updateSupplyLocation(deletion.id, { amount: item.qty - deletion.amount }); + await api.updateSupplyLocation(subtraction.id, { amount: item.qty - subtraction.amount }); } } // Reload supply locations to ensure UI reflects actual server state await reloadSupplyLocations(); - // Clear delete mode - setDeleteModeItem(null); - setDeleteModeQtyPerClick(1); - setDeleteModePending(new Map()); + // Clear subtract mode + setSubtractModeItem(null); + setSubtractModeQtyPerClick(1); + setSubtractModePending(new Map()); } catch (error) { - console.error('Error finishing delete mode:', error); + console.error('Error finishing subtract mode:', error); // Only set error if not panning (to avoid breaking pan) if (!isPanningRef.current) { const errorInfo = await handleApiError(error); if (errorInfo.isConflict) { setConflictError(errorInfo); } else { - setError(errorInfo.message || 'Failed to delete items'); + setError(errorInfo.message || 'Failed to subtract items'); } } } }, [inventoryData, supplyNameToId, reloadSupplyLocations]); - const cancelDeleteMode = useCallback(() => { - setDeleteModeItem(null); - setDeleteModeQtyPerClick(1); - setDeleteModePending(new Map()); + const cancelSubtractMode = useCallback(() => { + setSubtractModeItem(null); + setSubtractModeQtyPerClick(1); + setSubtractModePending(new Map()); }, []); // Move Mode functions @@ -1136,17 +1136,17 @@ export const InventoryProvider = ({ children }) => { cancelAddMode, handleBoxClickAddMode, boxHasAnyPending, - // Delete Mode - deleteModeItem, - deleteModeQtyPerClick, - setDeleteModeQtyPerClick, - deleteModePending, - deleteModePreviewRef, - startDeleteMode, - finishDeleteMode, - cancelDeleteMode, - handleBoxClickDeleteMode, - boxHasAnyDeletePending, + // Subtract Mode (removes items from boxes, not the master entry) + subtractModeItem, + subtractModeQtyPerClick, + setSubtractModeQtyPerClick, + subtractModePending, + subtractModePreviewRef, + startSubtractMode, + finishSubtractMode, + cancelSubtractMode, + handleBoxClickSubtractMode, + boxHasAnySubtractPending, // Move Mode moveModeItem, moveModeDragging, From a5a3722a403851cb02e54732f61c96f42584643f Mon Sep 17 00:00:00 2001 From: willzoo Date: Wed, 4 Mar 2026 15:59:07 -0500 Subject: [PATCH 27/73] undone records fully removed --- milventory/src/components/HistoryModal.js | 104 +++++++++++++---- milventory/src/components/HistoryTableRow.js | 74 +++++++++++- milventory/src/components/MasterAddModal.js | 6 +- .../src/components/MasterInventoryTable.js | 107 +++++++---------- milventory/src/context/InventoryContext.js | 6 +- src/api/routes/supplies.py | 15 +-- src/api/routes/supplies_location_history.py | 109 ++++-------------- .../table_supplies_history.sql | 4 + .../table_supplies_location_history.sql | 5 +- 9 files changed, 234 insertions(+), 196 deletions(-) diff --git a/milventory/src/components/HistoryModal.js b/milventory/src/components/HistoryModal.js index 82e9eb8..7d0f963 100644 --- a/milventory/src/components/HistoryModal.js +++ b/milventory/src/components/HistoryModal.js @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { api } from '../api'; +import { api, locationHistory } from '../api'; import { useInventory } from '../context/InventoryContext'; import HistoryTableRow from './HistoryTableRow'; import './HistoryModal.css'; @@ -12,7 +12,7 @@ const HistoryModal = ({ isOpen, onClose }) => { const [filters, setFilters] = useState({ action_type: '', search: '', - limit: 100, + limit: 200, offset: 0 }); const [total, setTotal] = useState(0); @@ -27,24 +27,71 @@ const HistoryModal = ({ isOpen, onClose }) => { setLoading(true); setError(null); try { - const response = await api.getSupplyHistory({ - action_type: filters.action_type || undefined, - limit: filters.limit, - offset: filters.offset + // Fetch both supply history and location history in parallel + const [supplyResponse, locationData] = await Promise.all([ + api.getSupplyHistory({ + action_type: filters.action_type || undefined, + limit: filters.limit, + offset: filters.offset + }), + locationHistory.getAll({ + limit: filters.limit, + offset: filters.offset + }) + ]); + + // Mark supply history entries + const supplyHistory = supplyResponse.history.map(entry => ({ + ...entry, + historyType: 'supply' + })); + + // Mark location history entries + const locHistory = Array.isArray(locationData) ? locationData.map(entry => ({ + ...entry, + historyType: 'location' + })) : []; + + // Combine and sort by timestamp (newest first) + let combined = [...supplyHistory, ...locHistory].sort((a, b) => { + const dateA = new Date(a.changed_at || 0); + const dateB = new Date(b.changed_at || 0); + return dateB - dateA; }); - let filtered = response.history; + // No need to filter undone entries - undone actions are deleted entirely from the database + // See undo_location_history and undo_batch_history endpoints which DELETE entries instead of marking them undone + + // Filter by action type if specified + if (filters.action_type) { + combined = combined.filter(entry => { + if (entry.historyType === 'location') { + // Map location history action types + if (filters.action_type === 'ADD') return entry.action_type === 'ADD'; + if (filters.action_type === 'SUBTRACT') return entry.action_type === 'REMOVE'; + if (filters.action_type === 'UPDATE') return entry.action_type === 'UPDATE'; + if (filters.action_type === 'MOVE') return entry.action_type === 'MOVE'; + return false; + } else { + return entry.action_type === filters.action_type; + } + }); + } // Client-side search filter if (filters.search) { const searchLower = filters.search.toLowerCase(); - filtered = filtered.filter(entry => - entry.supply_name?.toLowerCase().includes(searchLower) + combined = combined.filter(entry => + entry.supply_name?.toLowerCase().includes(searchLower) || + entry.location_name?.toLowerCase().includes(searchLower) ); } - setHistory(filtered); - setTotal(response.total); + // Apply pagination + const paginated = combined.slice(filters.offset, filters.offset + filters.limit); + + setHistory(paginated); + setTotal(combined.length); } catch (err) { setError(err.message || 'Failed to load history'); } finally { @@ -52,18 +99,22 @@ const HistoryModal = ({ isOpen, onClose }) => { } }; - const handleUndo = async (historyId) => { + const handleUndo = async (historyId, historyType) => { try { - const response = await api.undoSupplyHistory(historyId); - // Reload history after undo (the undone entry will disappear) - await loadHistory(); - // Reload master inventory to reflect changes - await reloadMasterItems(); - // Reload supply locations to update quantities on the map - // This is especially important when restoring a deleted supply (DELETE undo) - if (reloadSupplyLocations) { - await reloadSupplyLocations(); + if (historyType === 'location') { + await locationHistory.undo(historyId); + if (reloadSupplyLocations) { + await reloadSupplyLocations(); + } + } else { + await api.undoSupplyHistory(historyId); + await reloadMasterItems(); + if (reloadSupplyLocations) { + await reloadSupplyLocations(); + } } + // Reload history after undo + await loadHistory(); } catch (err) { setError(err.message || 'Failed to undo action'); } @@ -104,11 +155,14 @@ const HistoryModal = ({ isOpen, onClose }) => { + + + - setFilters({ ...filters, search: e.target.value, offset: 0 })} className="history-filter-search" @@ -141,9 +195,9 @@ const HistoryModal = ({ isOpen, onClose }) => { {history.map(entry => ( handleUndo(id, entry.historyType)} /> ))} diff --git a/milventory/src/components/HistoryTableRow.js b/milventory/src/components/HistoryTableRow.js index ff4541d..b3147b5 100644 --- a/milventory/src/components/HistoryTableRow.js +++ b/milventory/src/components/HistoryTableRow.js @@ -18,12 +18,60 @@ const HistoryTableRow = ({ entry, onUndo }) => { return 'history-badge-update'; case 'DELETE': return 'history-badge-delete'; + case 'ADD': + return 'history-badge-create'; + case 'REMOVE': + return 'history-badge-delete'; + case 'MOVE': + return 'history-badge-update'; + case 'SUPPLY_DELETE_SNAPSHOT': + return 'history-badge-default'; default: return 'history-badge-default'; } }; + const formatActionType = (actionType) => { + switch (actionType) { + case 'REMOVE': + return 'SUBTRACT'; + default: + return actionType; + } + }; + const formatChangesSummary = () => { + // Handle location history entries + if (entry.historyType === 'location') { + const changes = []; + + if (entry.location_name) { + let location = entry.location_name; + if (entry.shelf !== null && entry.shelf !== undefined) { + location += ` (Shelf ${entry.shelf})`; + } + changes.push(`Location: ${location}`); + } + + if (entry.action_type === 'ADD') { + const added = entry.new_amount - (entry.old_amount || 0); + changes.push(`Added: +${added}`); + } else if (entry.action_type === 'REMOVE') { + const removed = entry.old_amount - (entry.new_amount || 0); + changes.push(`Subtracted: -${removed}`); + } else if (entry.action_type === 'UPDATE') { + const diff = entry.new_amount - entry.old_amount; + changes.push(`Amount: ${entry.old_amount || 0} → ${entry.new_amount || 0} (${diff >= 0 ? '+' : ''}${diff})`); + } else if (entry.action_type === 'MOVE') { + if (entry.related_location) { + changes.push(`Moved to: ${entry.related_location}`); + } + } + + return changes.join('; ') || 'Location change'; + } + + // Handle supply history entries const changes = []; if (entry.old_name !== entry.new_name) { @@ -80,15 +128,28 @@ const HistoryTableRow = ({ entry, onUndo }) => { } }; + // Determine if entry can be undone + // Note: Undone entries are deleted entirely from the database, so we don't need to check undone status + const canUndo = entry.historyType === 'location' + ? entry.action_type !== 'SUPPLY_DELETE_SNAPSHOT' + : entry.can_undo !== false; + return ( {formatDate(entry.changed_at)} - {entry.action_type} + {formatActionType(entry.action_type)} - {entry.supply_name || 'N/A'} + + {entry.supply_name || 'N/A'} + {entry.historyType === 'location' && entry.location_name && ( + + @ {entry.location_name} + + )} + {formatChangesSummary()} @@ -96,7 +157,9 @@ const HistoryTableRow = ({ entry, onUndo }) => { - {showConfirm ? ( + {entry.action_type === 'SUPPLY_DELETE_SNAPSHOT' ? ( + Use restore + ) : showConfirm ? (
@@ -127,3 +190,4 @@ const HistoryTableRow = ({ entry, onUndo }) => { }; export default HistoryTableRow; + diff --git a/milventory/src/components/MasterAddModal.js b/milventory/src/components/MasterAddModal.js index 5588a9b..90c4d11 100644 --- a/milventory/src/components/MasterAddModal.js +++ b/milventory/src/components/MasterAddModal.js @@ -157,7 +157,7 @@ const TagDropdown = ({ placeholder, selectedItems, availableItems, onSelect, onR }; const MasterAddModal = ({ isOpen, onClose }) => { - const { addMasterItem, masterInventoryItems } = useInventory(); + const { createMasterItem, masterInventoryItems } = useInventory(); const [name, setName] = useState(''); const [description, setDescription] = useState(''); @@ -279,7 +279,7 @@ const MasterAddModal = ({ isOpen, onClose }) => { locations: [] }; - addMasterItem(newItem); + createMasterItem(newItem); onClose(); } }; @@ -312,7 +312,7 @@ const MasterAddModal = ({ isOpen, onClose }) => { onKeyDown={handleKeyDown} >
-

Add Master Item

+

Create Item

{ - const [activeTab, setActiveTab] = useState('inventory'); // 'inventory' or 'history' const { masterInventoryItems, computeMasterQuantities, @@ -46,70 +44,51 @@ const MasterInventoryTable = () => {

Master Inventory

-
- - +
+ setSearchQuery(e.target.value)} + className="master-search-input" + />
- {activeTab === 'inventory' && ( - <> -
- setSearchQuery(e.target.value)} - className="master-search-input" - /> -
-
- {sortedItems.length === 0 ? ( -
- {searchQuery ? 'No items found' : 'No Master items. Click "+ Add Item" to create one.'} -
- ) : ( - - - - - - - - - - - {sortedItems.map(([itemName, itemData]) => ( - handleRowClick(itemName)} - /> - ))} - -
NameQtyLocationLast Modified
- )} -
-
- +
+ {sortedItems.length === 0 ? ( +
+ {searchQuery ? 'No items found' : 'No Master items. Click "+ Create Item" to create one.'}
- - )} - {activeTab === 'history' && } + ) : ( + + + + + + + + + + + {sortedItems.map(([itemName, itemData]) => ( + handleRowClick(itemName)} + /> + ))} + +
NameQtyLocationLast Modified
+ )} +
+
+ +
setShowAddModal(false)} /> diff --git a/milventory/src/context/InventoryContext.js b/milventory/src/context/InventoryContext.js index 328398a..3e2be3b 100644 --- a/milventory/src/context/InventoryContext.js +++ b/milventory/src/context/InventoryContext.js @@ -862,7 +862,7 @@ export const InventoryProvider = ({ children }) => { return locations; }, [inventoryData]); - const addMasterItem = useCallback(async (item) => { + const createMasterItem = useCallback(async (item) => { try { const created = await api.createSupply({ name: item.name, @@ -897,7 +897,7 @@ export const InventoryProvider = ({ children }) => { return next; }); } catch (error) { - console.error('Error adding Master item:', error); + console.error('Error creating Master item:', error); // Only set error if not panning (to avoid breaking pan) if (!isPanningRef.current) { const errorInfo = await handleApiError(error); @@ -1119,7 +1119,7 @@ export const InventoryProvider = ({ children }) => { resolveMasterItem, computeMasterQuantities, getItemLocations, - addMasterItem, + createMasterItem, updateMasterItem, deleteMasterItem, clearSelectedMasterItem, diff --git a/src/api/routes/supplies.py b/src/api/routes/supplies.py index c4be01e..cc0cbbd 100644 --- a/src/api/routes/supplies.py +++ b/src/api/routes/supplies.py @@ -722,6 +722,9 @@ def get_supply_history(current_user_id=None): Returns: JSON object with history array and total count + + NOTE: Undone entries are DELETED entirely from the database (not just marked as undone). + See undo_supply_history endpoint which handles supply history undo. """ try: supply_id_filter = request.args.get('supply_id', type=int) @@ -1019,14 +1022,14 @@ def undo_supply_history(history_id, current_user_id=None): """, (restored_supply_id, cat_change['category_id'])) # Restore locations from SUPPLY_DELETE_SNAPSHOT entries - # Find the most recent snapshot batch for this supply_name that hasn't been undone + # Find the most recent snapshot batch for this supply_name # The snapshot was created right before the DELETE, so match by supply_name and timestamp + # NOTE: No need to check undone=FALSE since undone entries are deleted entirely cur.execute(""" SELECT batch_id, MAX(changed_at) as max_changed_at FROM supplies_location_history WHERE supply_name = %s AND action_type = 'SUPPLY_DELETE_SNAPSHOT' - AND undone = FALSE AND changed_at >= DATE_SUB(%s, INTERVAL 10 SECOND) AND changed_at <= DATE_ADD(%s, INTERVAL 10 SECOND) GROUP BY batch_id @@ -1044,7 +1047,6 @@ def undo_supply_history(history_id, current_user_id=None): FROM supplies_location_history WHERE batch_id = %s AND action_type = 'SUPPLY_DELETE_SNAPSHOT' - AND undone = FALSE """, (batch_id,)) snapshot_entries = cur.fetchall() @@ -1062,13 +1064,12 @@ def undo_supply_history(history_id, current_user_id=None): current_user_id )) - # Mark all snapshot entries as undone + # Delete all snapshot entries (not just mark as undone) cur.execute(""" - UPDATE supplies_location_history - SET undone = TRUE, undone_at = NOW(), undone_by = %s + DELETE FROM supplies_location_history WHERE batch_id = %s AND action_type = 'SUPPLY_DELETE_SNAPSHOT' - """, (current_user_id, batch_id)) + """, (batch_id,)) # Delete the history entry and all related data (CASCADE will handle teams/categories) cur.execute("DELETE FROM supplies_history WHERE id = %s", (history_id,)) diff --git a/src/api/routes/supplies_location_history.py b/src/api/routes/supplies_location_history.py index cc3ced3..49ac464 100644 --- a/src/api/routes/supplies_location_history.py +++ b/src/api/routes/supplies_location_history.py @@ -31,6 +31,9 @@ def get_location_history(current_user_id=None): Returns: JSON array of history entries + + NOTE: Undone entries are DELETED entirely from the database (not just marked as undone). + See undo_location_history and undo_batch_history endpoints which DELETE entries. """ try: supply_id = request.args.get('supply_id', type=int) @@ -151,10 +154,7 @@ def undo_location_history(history_id, current_user_id=None): conn.close() return jsonify({'error': 'History entry not found'}), 404 - if history['undone']: - cur.close() - conn.close() - return jsonify({'error': 'This action has already been undone'}), 400 + # No need to check undone status - if entry exists, it can be undone action_type = history['action_type'] @@ -289,7 +289,7 @@ def undo_location_history(history_id, current_user_id=None): cur.execute(""" SELECT id, location_name, shelf, old_amount, new_amount FROM supplies_location_history - WHERE batch_id = %s AND id != %s AND undone = FALSE + WHERE batch_id = %s AND id != %s """, (history['batch_id'], history_id)) paired = cur.fetchone() @@ -345,74 +345,17 @@ def undo_location_history(history_id, current_user_id=None): WHERE id = %s """, (new_dest_amount, current_user_id, dest_existing[0])) - # Mark paired entry as undone too - cur.execute(""" - UPDATE supplies_location_history - SET undone = TRUE, undone_at = NOW(), undone_by = %s - WHERE id = %s - """, (current_user_id, paired['id'])) + # Delete paired entry too + cur.execute("DELETE FROM supplies_location_history WHERE id = %s", (paired['id'],)) - # Mark this history entry as undone - cur.execute(""" - UPDATE supplies_location_history - SET undone = TRUE, undone_at = NOW(), undone_by = %s - WHERE id = %s - """, (current_user_id, history_id)) + # Delete this history entry entirely (not just mark as undone) + cur.execute("DELETE FROM supplies_location_history WHERE id = %s", (history_id,)) conn.commit() - - # Fetch updated history entry - cur = conn.cursor(dictionary=True) - cur.execute(""" - SELECT - slh.id, - slh.supply_id, - slh.supply_name, - slh.location_name, - slh.shelf, - slh.action_type, - slh.old_amount, - slh.new_amount, - slh.related_location, - slh.related_shelf, - slh.batch_id, - slh.undone, - slh.undone_at, - slh.undone_by, - slh.changed_by, - slh.changed_at, - m.first_name, - m.last_name - FROM supplies_location_history slh - LEFT JOIN members m ON slh.changed_by = m.uf_id - WHERE slh.id = %s - """, (history_id,)) - - updated = cur.fetchone() - result = { - 'id': updated['id'], - 'supply_id': updated['supply_id'], - 'supply_name': updated['supply_name'], - 'location_name': updated['location_name'], - 'shelf': updated['shelf'], - 'action_type': updated['action_type'], - 'old_amount': updated['old_amount'], - 'new_amount': updated['new_amount'], - 'related_location': updated['related_location'], - 'related_shelf': updated['related_shelf'], - 'batch_id': updated['batch_id'], - 'undone': bool(updated['undone']), - 'undone_at': updated['undone_at'].isoformat() if updated['undone_at'] else None, - 'undone_by': updated['undone_by'], - 'changed_by': updated['changed_by'], - 'changed_by_name': f"{updated['first_name']} {updated['last_name']}" if updated['first_name'] and updated['last_name'] else None, - 'changed_at': updated['changed_at'].isoformat() if updated['changed_at'] else None - } - cur.close() conn.close() - return jsonify(result), 200 + return jsonify({'success': True, 'deleted_id': history_id}), 200 except mysql.connector.IntegrityError as e: if 'foreign key constraint' in str(e).lower(): return jsonify({'error': 'Supply or location does not exist'}), 400 @@ -426,25 +369,25 @@ def undo_location_history(history_id, current_user_id=None): def undo_batch_history(batch_id, current_user_id=None): """ POST /api/supplies-location-history/batch//undo - Undo all non-undone history entries sharing a batch_id atomically. + Undo all history entries sharing a batch_id atomically by deleting them. Args: batch_id: Batch ID (UUID string) Returns: - JSON object with count of undone entries + JSON object with count of deleted entries """ try: conn = get_db() cur = conn.cursor(dictionary=True) - # Fetch all non-undone entries for this batch + # Fetch all entries for this batch cur.execute(""" SELECT id, supply_id, supply_name, location_name, shelf, action_type, old_amount, new_amount, - related_location, related_shelf, undone + related_location, related_shelf FROM supplies_location_history - WHERE batch_id = %s AND undone = FALSE + WHERE batch_id = %s ORDER BY id """, (batch_id,)) @@ -452,23 +395,13 @@ def undo_batch_history(batch_id, current_user_id=None): if not entries: cur.close() conn.close() - return jsonify({'error': 'No undoable entries found for this batch'}), 404 + return jsonify({'error': 'No entries found for this batch'}), 404 - # Undo each entry (reuse the undo logic from single undo endpoint) - undone_count = 0 + # Delete all entries in the batch + deleted_count = 0 for entry in entries: - # Call the undo logic inline (simplified version) - # For simplicity, we'll mark them all as undone and let the frontend - # handle the actual data reversal via individual undo calls - # OR we can implement full reversal here - - # For now, mark as undone (actual reversal would require full logic) - cur.execute(""" - UPDATE supplies_location_history - SET undone = TRUE, undone_at = NOW(), undone_by = %s - WHERE id = %s - """, (current_user_id, entry['id'])) - undone_count += 1 + cur.execute("DELETE FROM supplies_location_history WHERE id = %s", (entry['id'],)) + deleted_count += 1 conn.commit() cur.close() @@ -477,7 +410,7 @@ def undo_batch_history(batch_id, current_user_id=None): return jsonify({ 'success': True, 'batch_id': batch_id, - 'undone_count': undone_count + 'deleted_count': deleted_count }), 200 except Exception as e: return jsonify({'error': str(e)}), 500 diff --git a/src/sql/supplies_history/table_supplies_history.sql b/src/sql/supplies_history/table_supplies_history.sql index de623e3..a225a70 100644 --- a/src/sql/supplies_history/table_supplies_history.sql +++ b/src/sql/supplies_history/table_supplies_history.sql @@ -1,3 +1,7 @@ +-- History of all supply (master item) operations (CREATE, UPDATE, DELETE) +-- NOTE: This table does not track "undone" status. Instead, the API determines +-- if an entry can be undone based on whether the supply still exists. +-- See supplies_location_history table for undone tracking. CREATE TABLE supplies_history ( id BIGINT AUTO_INCREMENT PRIMARY KEY, supply_id BIGINT NULL, -- Nullable to allow history entries for deleted supplies diff --git a/src/sql/supplies_location/table_supplies_location_history.sql b/src/sql/supplies_location/table_supplies_location_history.sql index 243f997..94bd593 100644 --- a/src/sql/supplies_location/table_supplies_location_history.sql +++ b/src/sql/supplies_location/table_supplies_location_history.sql @@ -30,7 +30,10 @@ CREATE TABLE supplies_location_history ( -- For grouping a single logical operation (e.g. bulk-add touches multiple rows) batch_id CHAR(36) DEFAULT NULL, -- UUID generated per API call - -- Undo bookkeeping + -- Undo bookkeeping (DEPRECATED: Entries are now DELETED entirely when undone) + -- NOTE: The undone fields are kept for backward compatibility but are no longer used. + -- When an action is undone, the history entry is DELETED entirely from the database. + -- See undo_location_history and undo_batch_history endpoints which DELETE entries. undone BOOLEAN NOT NULL DEFAULT FALSE, undone_at DATETIME DEFAULT NULL, undone_by CHAR(8) DEFAULT NULL, From e2e6b68af87411eb8044d251707a6dcb03a74dd3 Mon Sep 17 00:00:00 2001 From: willzoo Date: Wed, 4 Mar 2026 16:12:14 -0500 Subject: [PATCH 28/73] finish renaming add to create --- milventory/src/api.js | 2 +- milventory/src/components/HistoryTab.js | 6 +----- .../components/{MasterAddModal.js => MasterCreateModal.js} | 7 ++++--- milventory/src/components/MasterInventoryTable.js | 4 ++-- src/api/routes/supplies_location_history.py | 2 +- 5 files changed, 9 insertions(+), 12 deletions(-) rename milventory/src/components/{MasterAddModal.js => MasterCreateModal.js} (99%) diff --git a/milventory/src/api.js b/milventory/src/api.js index eb53469..797ce98 100644 --- a/milventory/src/api.js +++ b/milventory/src/api.js @@ -718,7 +718,7 @@ export const locationHistory = { /** * Undo all entries in a batch. * @param {string} batchId - Batch ID (UUID) - * @returns {Promise} Result with undone_count + * @returns {Promise} Result with deleted_count (entries are deleted entirely, not marked as undone) */ undoBatch: (batchId) => fetch(`${API_BASE}/supplies-location-history/batch/${batchId}/undo`, { diff --git a/milventory/src/components/HistoryTab.js b/milventory/src/components/HistoryTab.js index 048ce75..bcde5b9 100644 --- a/milventory/src/components/HistoryTab.js +++ b/milventory/src/components/HistoryTab.js @@ -156,7 +156,7 @@ const HistoryTab = () => { {history.map((entry) => ( - + {formatTime(entry.changed_at)} {entry.supply_name} {formatAction(entry)} @@ -168,10 +168,6 @@ const HistoryTab = () => { Use restore - ) : entry.undone ? ( - - Undone - ) : ( @@ -406,4 +406,5 @@ const MasterAddModal = ({ isOpen, onClose }) => { ); }; -export default MasterAddModal; +export default MasterCreateModal; + diff --git a/milventory/src/components/MasterInventoryTable.js b/milventory/src/components/MasterInventoryTable.js index a10eb3e..2087816 100644 --- a/milventory/src/components/MasterInventoryTable.js +++ b/milventory/src/components/MasterInventoryTable.js @@ -1,7 +1,7 @@ import React, { useState, useMemo } from 'react'; import { useInventory } from '../context/InventoryContext'; import MasterTableRow from './MasterTableRow'; -import MasterAddModal from './MasterAddModal'; +import MasterCreateModal from './MasterCreateModal'; const MasterInventoryTable = () => { const { @@ -90,7 +90,7 @@ const MasterInventoryTable = () => { - setShowAddModal(false)} /> + setShowAddModal(false)} /> ); }; diff --git a/src/api/routes/supplies_location_history.py b/src/api/routes/supplies_location_history.py index 49ac464..5a4fb9a 100644 --- a/src/api/routes/supplies_location_history.py +++ b/src/api/routes/supplies_location_history.py @@ -296,7 +296,7 @@ def undo_location_history(history_id, current_user_id=None): if not paired: cur.close() conn.close() - return jsonify({'error': 'Paired MOVE entry not found or already undone'}), 400 + return jsonify({'error': 'Paired MOVE entry not found'}), 400 # Reverse both legs: undo REMOVE by restoring source, undo ADD by removing from dest if history['action_type'] == 'REMOVE': From b7d7d3989834db3e90521f2ae0b31a0e9e380100 Mon Sep 17 00:00:00 2001 From: willzoo Date: Wed, 4 Mar 2026 21:38:34 -0500 Subject: [PATCH 29/73] renames --- milventory/src/components/HistoryTab.js | 6 ++--- milventory/src/components/HistoryTableRow.js | 6 ++--- src/api/app.py | 25 ++++++++++++++++--- src/api/db.py | 25 ++++++++++++++----- src/api/helpers/history.py | 6 ++--- src/api/routes/supplies.py | 8 +++--- src/api/routes/supplies_location_history.py | 8 +++--- ...e_rename_snapshot_to_cascaded_subtract.sql | 23 +++++++++++++++++ .../table_supplies_location_history.sql | 8 +++--- 9 files changed, 84 insertions(+), 31 deletions(-) create mode 100644 src/sql/supplies_location/migrate_rename_snapshot_to_cascaded_subtract.sql diff --git a/milventory/src/components/HistoryTab.js b/milventory/src/components/HistoryTab.js index bcde5b9..657d035 100644 --- a/milventory/src/components/HistoryTab.js +++ b/milventory/src/components/HistoryTab.js @@ -61,8 +61,8 @@ const HistoryTab = () => { return 'Updated'; case 'MOVE': return 'Moved'; - case 'SUPPLY_DELETE_SNAPSHOT': - return 'Snapshot'; + case 'CASCADED_SUBTRACT': + return 'Cascaded Subtract'; default: return entry.action_type; } @@ -164,7 +164,7 @@ const HistoryTab = () => { {formatAmount(entry)} {entry.changed_by_name || entry.changed_by || 'Unknown'} - {entry.action_type === 'SUPPLY_DELETE_SNAPSHOT' ? ( + {entry.action_type === 'CASCADED_SUBTRACT' ? ( Use restore diff --git a/milventory/src/components/HistoryTableRow.js b/milventory/src/components/HistoryTableRow.js index b3147b5..9b79ae0 100644 --- a/milventory/src/components/HistoryTableRow.js +++ b/milventory/src/components/HistoryTableRow.js @@ -24,7 +24,7 @@ const HistoryTableRow = ({ entry, onUndo }) => { return 'history-badge-delete'; case 'MOVE': return 'history-badge-update'; - case 'SUPPLY_DELETE_SNAPSHOT': + case 'CASCADED_SUBTRACT': return 'history-badge-default'; default: return 'history-badge-default'; @@ -131,7 +131,7 @@ const HistoryTableRow = ({ entry, onUndo }) => { // Determine if entry can be undone // Note: Undone entries are deleted entirely from the database, so we don't need to check undone status const canUndo = entry.historyType === 'location' - ? entry.action_type !== 'SUPPLY_DELETE_SNAPSHOT' + ? entry.action_type !== 'CASCADED_SUBTRACT' : entry.can_undo !== false; return ( @@ -157,7 +157,7 @@ const HistoryTableRow = ({ entry, onUndo }) => { - {entry.action_type === 'SUPPLY_DELETE_SNAPSHOT' ? ( + {entry.action_type === 'CASCADED_SUBTRACT' ? ( Use restore ) : showConfirm ? (
diff --git a/src/api/app.py b/src/api/app.py index 6ef97ce..f2bef3c 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -46,10 +46,26 @@ def initialize_schema(): """Initialize database schema if tables are missing.""" + max_retries = 5 + retry_delay = 2 + + for attempt in range(max_retries): + try: + print("🔍 Checking database schema...") + conn = get_db() + cur = conn.cursor() + break + except Exception as e: + if attempt < max_retries - 1: + print(f"⚠ Database connection attempt {attempt + 1}/{max_retries} failed: {e}") + print(f" Retrying in {retry_delay} seconds...") + import time + time.sleep(retry_delay) + else: + print(f"❌ Failed to connect to database after {max_retries} attempts: {e}") + raise + try: - print("🔍 Checking database schema...") - conn = get_db() - cur = conn.cursor() # Get SQL base path SQL_BASE_PATH = get_sql_base_path(__file__) @@ -107,12 +123,13 @@ def initialize_schema(): cur.close() conn.close() - + except Exception as e: print(f"⚠ Schema initialization warning: {e}") import traceback traceback.print_exc() print(" API will continue, but some endpoints may not work until tables are created") + # Don't raise - allow the API to start even if schema init fails # Initialize schema on startup diff --git a/src/api/db.py b/src/api/db.py index deb998d..2b7a6a8 100644 --- a/src/api/db.py +++ b/src/api/db.py @@ -4,6 +4,7 @@ import mysql.connector from mysql.connector import pooling import os +import time # Database configuration from environment variables config = { @@ -17,12 +18,24 @@ 'pool_reset_session': True } -# Create connection pool -try: - connection_pool = pooling.MySQLConnectionPool(**config) -except Exception as e: - print(f"Error creating connection pool: {e}") - connection_pool = None +# Create connection pool with retry logic +connection_pool = None +max_retries = 10 +retry_delay = 2 # seconds + +for attempt in range(max_retries): + try: + connection_pool = pooling.MySQLConnectionPool(**config) + print(f"✓ Database connection pool initialized successfully") + break + except Exception as e: + if attempt < max_retries - 1: + print(f"⚠ Database connection attempt {attempt + 1}/{max_retries} failed: {e}") + print(f" Retrying in {retry_delay} seconds...") + time.sleep(retry_delay) + else: + print(f"❌ Error creating connection pool after {max_retries} attempts: {e}") + connection_pool = None def get_db(): diff --git a/src/api/helpers/history.py b/src/api/helpers/history.py index 953dbfc..c2daaba 100644 --- a/src/api/helpers/history.py +++ b/src/api/helpers/history.py @@ -189,7 +189,7 @@ def log_location_history(conn, action_type, supply_id, supply_name, Args: conn: Database connection - action_type: 'ADD', 'REMOVE', 'UPDATE', 'MOVE', or 'SUPPLY_DELETE_SNAPSHOT' + action_type: 'ADD', 'REMOVE', 'UPDATE', 'MOVE', or 'CASCADED_SUBTRACT' supply_id: Supply ID (can be None) supply_name: Supply name (denormalized, required) location_name: Location name @@ -227,7 +227,7 @@ def log_location_history(conn, action_type, supply_id, supply_name, def snapshot_supply_locations_before_delete(conn, supply_id, supply_name, changed_by): """ Called BEFORE deleting a supply. Reads all current supplies_location rows - for this supply and writes SUPPLY_DELETE_SNAPSHOT history entries. + for this supply and writes CASCADED_SUBTRACT history entries. These are later used to restore the supply's full location state. batch_id ties all snapshots from the same delete together. @@ -252,7 +252,7 @@ def snapshot_supply_locations_before_delete(conn, supply_id, supply_name, change for row in rows: log_location_history( conn, - action_type='SUPPLY_DELETE_SNAPSHOT', + action_type='CASCADED_SUBTRACT', supply_id=supply_id, supply_name=supply_name, location_name=row['location_name'], diff --git a/src/api/routes/supplies.py b/src/api/routes/supplies.py index cc0cbbd..99c9d78 100644 --- a/src/api/routes/supplies.py +++ b/src/api/routes/supplies.py @@ -1021,7 +1021,7 @@ def undo_supply_history(history_id, current_user_id=None): VALUES (%s, %s) """, (restored_supply_id, cat_change['category_id'])) - # Restore locations from SUPPLY_DELETE_SNAPSHOT entries + # Restore locations from CASCADED_SUBTRACT entries # Find the most recent snapshot batch for this supply_name # The snapshot was created right before the DELETE, so match by supply_name and timestamp # NOTE: No need to check undone=FALSE since undone entries are deleted entirely @@ -1029,7 +1029,7 @@ def undo_supply_history(history_id, current_user_id=None): SELECT batch_id, MAX(changed_at) as max_changed_at FROM supplies_location_history WHERE supply_name = %s - AND action_type = 'SUPPLY_DELETE_SNAPSHOT' + AND action_type = 'CASCADED_SUBTRACT' AND changed_at >= DATE_SUB(%s, INTERVAL 10 SECOND) AND changed_at <= DATE_ADD(%s, INTERVAL 10 SECOND) GROUP BY batch_id @@ -1046,7 +1046,7 @@ def undo_supply_history(history_id, current_user_id=None): SELECT location_name, shelf, old_amount FROM supplies_location_history WHERE batch_id = %s - AND action_type = 'SUPPLY_DELETE_SNAPSHOT' + AND action_type = 'CASCADED_SUBTRACT' """, (batch_id,)) snapshot_entries = cur.fetchall() @@ -1068,7 +1068,7 @@ def undo_supply_history(history_id, current_user_id=None): cur.execute(""" DELETE FROM supplies_location_history WHERE batch_id = %s - AND action_type = 'SUPPLY_DELETE_SNAPSHOT' + AND action_type = 'CASCADED_SUBTRACT' """, (batch_id,)) # Delete the history entry and all related data (CASCADE will handle teams/categories) diff --git a/src/api/routes/supplies_location_history.py b/src/api/routes/supplies_location_history.py index 5a4fb9a..4bbb0cb 100644 --- a/src/api/routes/supplies_location_history.py +++ b/src/api/routes/supplies_location_history.py @@ -158,13 +158,13 @@ def undo_location_history(history_id, current_user_id=None): action_type = history['action_type'] - # Handle SUPPLY_DELETE_SNAPSHOT separately (cannot undo individual snapshots) - if action_type == 'SUPPLY_DELETE_SNAPSHOT': + # Handle CASCADED_SUBTRACT separately (cannot undo individual cascaded subtractions) + if action_type == 'CASCADED_SUBTRACT': cur.close() conn.close() return jsonify({ - 'error': 'Cannot undo individual snapshot entries. Use batch restore endpoint instead.', - 'error_type': 'SNAPSHOT_ENTRY' + 'error': 'Cannot undo individual cascaded subtract entries. Use batch restore endpoint instead.', + 'error_type': 'CASCADED_SUBTRACT_ENTRY' }), 400 cur = conn.cursor() # Switch to regular cursor for updates diff --git a/src/sql/supplies_location/migrate_rename_snapshot_to_cascaded_subtract.sql b/src/sql/supplies_location/migrate_rename_snapshot_to_cascaded_subtract.sql new file mode 100644 index 0000000..dfe8092 --- /dev/null +++ b/src/sql/supplies_location/migrate_rename_snapshot_to_cascaded_subtract.sql @@ -0,0 +1,23 @@ +-- Migration: Rename SUPPLY_DELETE_SNAPSHOT to CASCADED_SUBTRACT +-- This better reflects that deleting a master item cascades to subtract all location entries + +-- Step 1: Update existing history entries +UPDATE supplies_location_history +SET action_type = 'CASCADED_SUBTRACT' +WHERE action_type = 'SUPPLY_DELETE_SNAPSHOT'; + +-- Step 2: Modify the ENUM to replace SUPPLY_DELETE_SNAPSHOT with CASCADED_SUBTRACT +-- Note: MySQL doesn't support direct ENUM modification, so we need to: +-- 1. Add the new value +-- 2. Update existing rows (done above) +-- 3. Remove the old value + +-- Add CASCADED_SUBTRACT to the enum (if it doesn't exist) +ALTER TABLE supplies_location_history +MODIFY COLUMN action_type ENUM('ADD', 'REMOVE', 'UPDATE', 'MOVE', 'SUPPLY_DELETE_SNAPSHOT', 'CASCADED_SUBTRACT') NOT NULL; + +-- Remove SUPPLY_DELETE_SNAPSHOT from the enum (MySQL will only allow this if no rows use it) +-- Since we updated all rows above, this should work: +ALTER TABLE supplies_location_history +MODIFY COLUMN action_type ENUM('ADD', 'REMOVE', 'UPDATE', 'MOVE', 'CASCADED_SUBTRACT') NOT NULL; + diff --git a/src/sql/supplies_location/table_supplies_location_history.sql b/src/sql/supplies_location/table_supplies_location_history.sql index 94bd593..33c1121 100644 --- a/src/sql/supplies_location/table_supplies_location_history.sql +++ b/src/sql/supplies_location/table_supplies_location_history.sql @@ -1,4 +1,4 @@ --- History of all location operations (ADD, REMOVE, UPDATE, MOVE, SUPPLY_DELETE_SNAPSHOT) +-- History of all location operations (ADD, REMOVE, UPDATE, MOVE, CASCADED_SUBTRACT) CREATE TABLE supplies_location_history ( id BIGINT AUTO_INCREMENT PRIMARY KEY, @@ -12,16 +12,16 @@ CREATE TABLE supplies_location_history ( shelf INT DEFAULT NULL, -- Action - action_type ENUM('ADD', 'REMOVE', 'UPDATE', 'MOVE', 'SUPPLY_DELETE_SNAPSHOT') NOT NULL, + action_type ENUM('ADD', 'REMOVE', 'UPDATE', 'MOVE', 'CASCADED_SUBTRACT') NOT NULL, -- ADD = units placed into this location -- REMOVE = units removed from this location -- UPDATE = amount changed directly (e.g. edit qty field) -- MOVE = units moved between locations (generates two rows: one REMOVE, one ADD) - -- SUPPLY_DELETE_SNAPSHOT = snapshot of location state at moment supply was deleted + -- CASCADED_SUBTRACT = items subtracted from location when master item is deleted (CASCADE) -- Amounts (NULL means "not applicable" for that side) old_amount INT DEFAULT NULL, -- amount before change (NULL for ADD) - new_amount INT DEFAULT NULL, -- amount after change (NULL for REMOVE/SNAPSHOT) + new_amount INT DEFAULT NULL, -- amount after change (NULL for REMOVE/CASCADED_SUBTRACT) -- For MOVE actions: where the units came from / went to related_location VARCHAR(100) DEFAULT NULL, -- the other location in a MOVE From 33b0359ca65f7d349f9fc2407aa6e7a031b78163 Mon Sep 17 00:00:00 2001 From: willzoo Date: Wed, 4 Mar 2026 22:47:48 -0500 Subject: [PATCH 30/73] add filter & dropdown --- .../src/components/MasterInventoryTable.js | 485 +++++++++++++++++- milventory/src/index.css | 11 + 2 files changed, 488 insertions(+), 8 deletions(-) diff --git a/milventory/src/components/MasterInventoryTable.js b/milventory/src/components/MasterInventoryTable.js index 2087816..25af41e 100644 --- a/milventory/src/components/MasterInventoryTable.js +++ b/milventory/src/components/MasterInventoryTable.js @@ -1,7 +1,8 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useRef, useEffect } from 'react'; import { useInventory } from '../context/InventoryContext'; import MasterTableRow from './MasterTableRow'; import MasterCreateModal from './MasterCreateModal'; +import { getCategories } from '../api'; const MasterInventoryTable = () => { const { @@ -9,22 +10,150 @@ const MasterInventoryTable = () => { computeMasterQuantities, getItemLocations, setSelectedMasterItem, - selectedMasterItem + selectedMasterItem, + inventoryData } = useInventory(); const [searchQuery, setSearchQuery] = useState(''); const [showAddModal, setShowAddModal] = useState(false); + const [showFilterMenu, setShowFilterMenu] = useState(false); + const [filterType, setFilterType] = useState('location'); // 'location' or 'category', default is 'location' + const [selectedLocations, setSelectedLocations] = useState(new Set()); + const [selectedCategories, setSelectedCategories] = useState(new Set()); + const [availableCategories, setAvailableCategories] = useState([]); + const [categoryIdToName, setCategoryIdToName] = useState(new Map()); + const [categoryNameToId, setCategoryNameToId] = useState(new Map()); + const filterButtonRef = React.useRef(null); const quantities = computeMasterQuantities(); + // Get all available locations from inventoryData + const availableLocations = useMemo(() => { + return Array.from(inventoryData.keys()).sort(); + }, [inventoryData]); + + // Fetch categories when filter menu opens and category filter is selected + useEffect(() => { + if (showFilterMenu && filterType === 'category' && availableCategories.length === 0) { + getCategories() + .then(categories => { + const categoryList = categories.map(c => typeof c === 'string' ? c : c.name); + setAvailableCategories(categoryList); + + // Build ID-to-name and name-to-ID mappings + const idToName = new Map(); + const nameToId = new Map(); + categories.forEach(cat => { + if (typeof cat === 'object' && cat.id && cat.name) { + idToName.set(cat.id, cat.name); + nameToId.set(cat.name, cat.id); + } + }); + setCategoryIdToName(idToName); + setCategoryNameToId(nameToId); + }) + .catch(err => { + console.error('Failed to fetch categories:', err); + setAvailableCategories([]); + setCategoryIdToName(new Map()); + setCategoryNameToId(new Map()); + }); + } + }, [showFilterMenu, filterType, availableCategories.length]); + const filteredItems = useMemo(() => { const itemsArray = Array.from(masterInventoryItems.entries()); - if (!searchQuery.trim()) { - return itemsArray; + + // Filter by search query + let filtered = itemsArray; + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter(([name]) => name.toLowerCase().includes(query)); + } + + // Filter by location OR category (not both) + if (filterType === 'location' && selectedLocations.size > 0) { + filtered = filtered.filter(([itemName]) => { + const itemLocations = getItemLocations(itemName); + // Show item if it appears in at least one selected location + return itemLocations.some(loc => selectedLocations.has(loc)); + }); + } else if (filterType === 'category' && selectedCategories.size > 0) { + filtered = filtered.filter(([itemName, itemData]) => { + if (!itemData.categories || itemData.categories.length === 0) { + return false; // Item has no categories, exclude if categories are selected + } + // Convert item's category IDs to names and check if any match selected categories + const itemCategoryNames = itemData.categories + .map(catId => categoryIdToName.get(catId)) + .filter(name => name !== undefined); + // Show item if it has at least one selected category + return itemCategoryNames.some(catName => selectedCategories.has(catName)); + }); + } + + return filtered; + }, [masterInventoryItems, searchQuery, filterType, selectedLocations, selectedCategories, getItemLocations, categoryIdToName]); + + const handleLocationToggle = (location) => { + setSelectedLocations(prev => { + const next = new Set(prev); + if (next.has(location)) { + next.delete(location); + } else { + next.add(location); + } + return next; + }); + }; + + const handleCategoryToggle = (category) => { + setSelectedCategories(prev => { + const next = new Set(prev); + if (next.has(category)) { + next.delete(category); + } else { + next.add(category); + } + return next; + }); + }; + + const handleClearFilters = () => { + setSelectedLocations(new Set()); + setSelectedCategories(new Set()); + }; + + const handleFilterTypeChange = (type) => { + setFilterType(type); + // Clear the other filter type when switching + if (type === 'location') { + setSelectedCategories(new Set()); + } else if (type === 'category') { + setSelectedLocations(new Set()); } - const query = searchQuery.toLowerCase(); - return itemsArray.filter(([name]) => name.toLowerCase().includes(query)); - }, [masterInventoryItems, searchQuery]); + }; + + // Close filter menu when clicking outside + useEffect(() => { + if (!showFilterMenu) return; + + const handleClickOutside = (event) => { + if (filterButtonRef.current && !filterButtonRef.current.contains(event.target)) { + setShowFilterMenu(false); + } + }; + + // Use a small delay to avoid closing immediately when opening + const timeoutId = setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside); + }, 100); + + return () => { + clearTimeout(timeoutId); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [showFilterMenu]); const sortedItems = useMemo(() => { return [...filteredItems].sort(([nameA], [nameB]) => nameA.localeCompare(nameB)); @@ -52,11 +181,351 @@ const MasterInventoryTable = () => { onChange={(e) => setSearchQuery(e.target.value)} className="master-search-input" /> +
+ + {((filterType === 'location' && selectedLocations.size > 0) || (filterType === 'category' && selectedCategories.size > 0)) && ( + + )} + + {/* Filter Dropdown Menu */} + {showFilterMenu && ( +
e.stopPropagation()} + > +
+

+ Filter +

+ +
+ + {filterType === 'location' ? ( + /* Locations Section */ +
+ {availableLocations.length === 0 ? ( +
+ No locations available +
+ ) : ( + <> +
+ + +
+ +
+ {availableLocations.map(location => ( + + ))} +
+ + )} +
+ ) : ( + /* Categories Section */ +
+ {availableCategories.length === 0 ? ( +
+ No categories available +
+ ) : ( + <> +
+ + +
+ +
+ {availableCategories.map(category => ( + + ))} +
+ + )} +
+ )} + + {/* Status Footer */} +
+ {filterType === 'location' + ? (selectedLocations.size === 0 + ? 'No locations selected - showing all items' + : `${selectedLocations.size} location${selectedLocations.size === 1 ? '' : 's'} selected`) + : (selectedCategories.size === 0 + ? 'No categories selected - showing all items' + : `${selectedCategories.size} categor${selectedCategories.size === 1 ? 'y' : 'ies'} selected`)} +
+
+ )} +
{sortedItems.length === 0 ? (
- {searchQuery ? 'No items found' : 'No Master items. Click "+ Create Item" to create one.'} + {searchQuery || (filterType === 'location' && selectedLocations.size > 0) || (filterType === 'category' && selectedCategories.size > 0) + ? 'No items found matching filters' + : 'No Master items. Click "+ Create Item" to create one.'}
) : ( diff --git a/milventory/src/index.css b/milventory/src/index.css index 4913f81..3440d70 100644 --- a/milventory/src/index.css +++ b/milventory/src/index.css @@ -16,6 +16,17 @@ --files: #b72a2a; /* red file cabinets */ } +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + html, body { height: 100%; margin: 0; } body { background: radial-gradient(1200px 800px at 50% 0%, #0d0f14, #07080c); From d7204529331bd70820f72eb5d755ff92e10619a8 Mon Sep 17 00:00:00 2001 From: willzoo Date: Wed, 4 Mar 2026 22:52:15 -0500 Subject: [PATCH 31/73] wip master filter --- .../src/components/BoxInventoryOverlay.js | 48 ++++++++++++++++++- .../src/components/MasterInventoryTable.js | 14 +++++- milventory/src/context/InventoryContext.js | 4 ++ milventory/src/index.css | 5 ++ 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/milventory/src/components/BoxInventoryOverlay.js b/milventory/src/components/BoxInventoryOverlay.js index ffaa7d7..ef9203d 100644 --- a/milventory/src/components/BoxInventoryOverlay.js +++ b/milventory/src/components/BoxInventoryOverlay.js @@ -3,7 +3,7 @@ import { useInventory } from '../context/InventoryContext'; import { escapeHtml } from '../utils'; const BoxInventoryOverlay = () => { - const { selectedBox, inventoryData, addModeItem } = useInventory(); + const { selectedBox, inventoryData, addModeItem, setMasterFilterLocation } = useInventory(); const boxData = selectedBox ? inventoryData.get(selectedBox) : null; const inventory = boxData ? boxData.inventory : []; @@ -47,6 +47,29 @@ const BoxInventoryOverlay = () => {

{selectedBox}

+
{shelves.map((shelf, idx) => ( shelf.items.length > 0 && ( @@ -95,6 +118,29 @@ const BoxInventoryOverlay = () => {

{selectedBox}

+
diff --git a/milventory/src/components/MasterInventoryTable.js b/milventory/src/components/MasterInventoryTable.js index 25af41e..843f1b4 100644 --- a/milventory/src/components/MasterInventoryTable.js +++ b/milventory/src/components/MasterInventoryTable.js @@ -11,7 +11,9 @@ const MasterInventoryTable = () => { getItemLocations, setSelectedMasterItem, selectedMasterItem, - inventoryData + inventoryData, + masterFilterLocation, + setMasterFilterLocation } = useInventory(); const [searchQuery, setSearchQuery] = useState(''); @@ -32,6 +34,16 @@ const MasterInventoryTable = () => { return Array.from(inventoryData.keys()).sort(); }, [inventoryData]); + // Sync with context filter location + useEffect(() => { + if (masterFilterLocation) { + setFilterType('location'); + setSelectedLocations(new Set([masterFilterLocation])); + // Clear the context filter after applying it + setMasterFilterLocation(null); + } + }, [masterFilterLocation, setMasterFilterLocation]); + // Fetch categories when filter menu opens and category filter is selected useEffect(() => { if (showFilterMenu && filterType === 'category' && availableCategories.length === 0) { diff --git a/milventory/src/context/InventoryContext.js b/milventory/src/context/InventoryContext.js index 3e2be3b..755808a 100644 --- a/milventory/src/context/InventoryContext.js +++ b/milventory/src/context/InventoryContext.js @@ -31,6 +31,7 @@ export const InventoryProvider = ({ children }) => { const [selectedMasterItem, setSelectedMasterItem] = useState(null); const [leftPaneWidth, setLeftPaneWidth] = useState(300); const [leftPaneCollapsed, setLeftPaneCollapsed] = useState(false); + const [masterFilterLocation, setMasterFilterLocation] = useState(null); // Location to filter master table by // Loading and error states const [isLoading, setIsLoading] = useState(true); @@ -218,6 +219,9 @@ export const InventoryProvider = ({ children }) => { if (isDraggingMoveBoxRef.current) return false; // Check if the event target is a move box if (event.target && event.target.dataset && event.target.dataset.moveBox) return false; + // Don't intercept events from interactive HTML elements inside foreignObject + // (e.g. buttons, inputs, selects inside BoxInventoryOverlay) + if (event.target && event.target.closest && event.target.closest('button, input, select, a, textarea')) return false; return true; }) .on('start', () => { diff --git a/milventory/src/index.css b/milventory/src/index.css index 3440d70..0e39c1e 100644 --- a/milventory/src/index.css +++ b/milventory/src/index.css @@ -1137,6 +1137,11 @@ svg.overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-e pointer-events: none; } +/* Re-enable pointer events inside the foreignObject so buttons/scrolling work */ +.box-inventory-overlay foreignObject { + pointer-events: all; +} + .box-inventory-table { background: var(--panel); border: 1px solid rgba(255,255,255,.15); From 906d85abedba216131be685ca771f5234c4d25ae Mon Sep 17 00:00:00 2001 From: willzoo Date: Wed, 4 Mar 2026 23:01:07 -0500 Subject: [PATCH 32/73] fix filter bug --- milventory/src/context/InventoryContext.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/milventory/src/context/InventoryContext.js b/milventory/src/context/InventoryContext.js index 755808a..59a6376 100644 --- a/milventory/src/context/InventoryContext.js +++ b/milventory/src/context/InventoryContext.js @@ -1129,6 +1129,9 @@ export const InventoryProvider = ({ children }) => { clearSelectedMasterItem, reloadMasterItems, reloadSupplyLocations, + // Master Table Filter + masterFilterLocation, + setMasterFilterLocation, // Add Mode addModeItem, addModeQtyPerClick, From 35aff3799c4fca6b428c319e3c41277a589085d3 Mon Sep 17 00:00:00 2001 From: willzoo Date: Thu, 5 Mar 2026 13:30:43 -0500 Subject: [PATCH 33/73] add table sorting --- .../src/components/MasterInventoryTable.js | 145 +++++++++++++++++- 1 file changed, 139 insertions(+), 6 deletions(-) diff --git a/milventory/src/components/MasterInventoryTable.js b/milventory/src/components/MasterInventoryTable.js index 843f1b4..cc1dc39 100644 --- a/milventory/src/components/MasterInventoryTable.js +++ b/milventory/src/components/MasterInventoryTable.js @@ -26,6 +26,10 @@ const MasterInventoryTable = () => { const [categoryIdToName, setCategoryIdToName] = useState(new Map()); const [categoryNameToId, setCategoryNameToId] = useState(new Map()); const filterButtonRef = React.useRef(null); + + // Sorting state - default to lastModified ascending (earliest first) + const [sortColumn, setSortColumn] = useState('lastModified'); + const [sortDirection, setSortDirection] = useState('asc'); const quantities = computeMasterQuantities(); @@ -168,8 +172,48 @@ const MasterInventoryTable = () => { }, [showFilterMenu]); const sortedItems = useMemo(() => { - return [...filteredItems].sort(([nameA], [nameB]) => nameA.localeCompare(nameB)); - }, [filteredItems]); + const items = [...filteredItems]; + + if (items.length === 0) return items; + + return items.sort(([nameA, itemDataA], [nameB, itemDataB]) => { + let comparison = 0; + + switch (sortColumn) { + case 'name': + comparison = nameA.localeCompare(nameB); + break; + case 'qty': + const qtyA = quantities.get(nameA) || 0; + const qtyB = quantities.get(nameB) || 0; + comparison = qtyA - qtyB; + break; + case 'location': + const locsA = getItemLocations(nameA); + const locsB = getItemLocations(nameB); + // Sort by first location name, or by count if no locations + if (locsA.length === 0 && locsB.length === 0) { + comparison = 0; + } else if (locsA.length === 0) { + comparison = 1; // Items with no locations go to end + } else if (locsB.length === 0) { + comparison = -1; + } else { + comparison = locsA[0].localeCompare(locsB[0]); + } + break; + case 'lastModified': + const dateA = itemDataA.lastModified ? new Date(itemDataA.lastModified).getTime() : 0; + const dateB = itemDataB.lastModified ? new Date(itemDataB.lastModified).getTime() : 0; + comparison = dateA - dateB; + break; + default: + comparison = 0; + } + + return sortDirection === 'asc' ? comparison : -comparison; + }); + }, [filteredItems, sortColumn, sortDirection, quantities, getItemLocations]); const handleRowClick = (itemName) => { setSelectedMasterItem(itemName); @@ -179,6 +223,60 @@ const MasterInventoryTable = () => { setShowAddModal(true); }; + const handleSort = (column) => { + if (sortColumn === column) { + // Toggle direction if clicking the same column + setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); + } else { + // Set new column and default to ascending + setSortColumn(column); + setSortDirection('asc'); + } + }; + + const SortIcon = ({ column }) => { + if (sortColumn !== column) { + // Show neutral sort icon when not active + return ( + + + + + ); + } + + // Show active sort icon + return ( + + {sortDirection === 'asc' ? ( + + ) : ( + + )} + + ); + }; + return ( <>
@@ -543,10 +641,45 @@ const MasterInventoryTable = () => {
- - - - + + + + From dce2db811046021e77615cd9691ea8a51b871cef Mon Sep 17 00:00:00 2001 From: willzoo Date: Thu, 5 Mar 2026 13:32:45 -0500 Subject: [PATCH 34/73] wip: scrollbar fix --- milventory/src/components/BoxInventoryOverlay.js | 6 ++++-- milventory/src/index.css | 12 +++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/milventory/src/components/BoxInventoryOverlay.js b/milventory/src/components/BoxInventoryOverlay.js index ef9203d..e16165a 100644 --- a/milventory/src/components/BoxInventoryOverlay.js +++ b/milventory/src/components/BoxInventoryOverlay.js @@ -59,7 +59,8 @@ const BoxInventoryOverlay = () => { border: 'none', borderRadius: '4px', cursor: 'pointer', - fontWeight: '500' + fontWeight: '500', + flexShrink: 0 }} onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.9'; @@ -130,7 +131,8 @@ const BoxInventoryOverlay = () => { border: 'none', borderRadius: '4px', cursor: 'pointer', - fontWeight: '500' + fontWeight: '500', + flexShrink: 0 }} onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.9'; diff --git a/milventory/src/index.css b/milventory/src/index.css index 0e39c1e..716d4b5 100644 --- a/milventory/src/index.css +++ b/milventory/src/index.css @@ -1148,8 +1148,11 @@ svg.overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-e border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,.4); padding: 0.75rem; - max-height: 400px; - overflow-y: auto; + height: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + overflow: hidden; color: var(--text); font-size: 0.85rem; } @@ -1158,6 +1161,7 @@ svg.overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-e margin-bottom: 0.5rem; padding-bottom: 0.5rem; border-bottom: 1px solid rgba(255,255,255,.1); + flex-shrink: 0; } .box-inventory-header h4 { @@ -1168,8 +1172,10 @@ svg.overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-e } .box-inventory-content { - max-height: 350px; + flex: 1; overflow-y: auto; + overflow-x: hidden; + min-height: 0; } .box-inventory-shelf { From a170dd70989f905598489b1c323b90626629a89d Mon Sep 17 00:00:00 2001 From: willzoo Date: Thu, 5 Mar 2026 13:39:02 -0500 Subject: [PATCH 35/73] wip: fix scrollbar --- milventory/src/components/HistoryModal.js | 10 +++++----- milventory/src/context/InventoryContext.js | 3 +++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/milventory/src/components/HistoryModal.js b/milventory/src/components/HistoryModal.js index 7d0f963..66f381f 100644 --- a/milventory/src/components/HistoryModal.js +++ b/milventory/src/components/HistoryModal.js @@ -30,9 +30,9 @@ const HistoryModal = ({ isOpen, onClose }) => { // Fetch both supply history and location history in parallel const [supplyResponse, locationData] = await Promise.all([ api.getSupplyHistory({ - action_type: filters.action_type || undefined, - limit: filters.limit, - offset: filters.offset + action_type: filters.action_type || undefined, + limit: filters.limit, + offset: filters.offset }), locationHistory.getAll({ limit: filters.limit, @@ -107,7 +107,7 @@ const HistoryModal = ({ isOpen, onClose }) => { await reloadSupplyLocations(); } } else { - await api.undoSupplyHistory(historyId); + await api.undoSupplyHistory(historyId); await reloadMasterItems(); if (reloadSupplyLocations) { await reloadSupplyLocations(); @@ -160,7 +160,7 @@ const HistoryModal = ({ isOpen, onClose }) => { - { // Don't intercept events from interactive HTML elements inside foreignObject // (e.g. buttons, inputs, selects inside BoxInventoryOverlay) if (event.target && event.target.closest && event.target.closest('button, input, select, a, textarea')) return false; + // Don't intercept scroll (wheel) or mousedown events inside scrollable containers — + // otherwise D3 steals the scroll and the native scrollbar drag never fires + if (event.target && event.target.closest && event.target.closest('.box-inventory-content')) return false; return true; }) .on('start', () => { From 3ade3280472d2510b1902cade36d4e814b80d039 Mon Sep 17 00:00:00 2001 From: willzoo Date: Thu, 5 Mar 2026 13:44:41 -0500 Subject: [PATCH 36/73] fix last modified to account for add/subtract --- milventory/src/context/InventoryContext.js | 91 ++++++++++++---------- src/api/routes/supplies_location.py | 36 +++++++++ 2 files changed, 85 insertions(+), 42 deletions(-) diff --git a/milventory/src/context/InventoryContext.js b/milventory/src/context/InventoryContext.js index 62c0557..dbe9fbd 100644 --- a/milventory/src/context/InventoryContext.js +++ b/milventory/src/context/InventoryContext.js @@ -417,6 +417,47 @@ export const InventoryProvider = ({ children }) => { } }, [supplyNameToId, reloadSupplyLocations]); + // Function to reload master items from API + const reloadMasterItems = useCallback(async () => { + try { + const supplies = await api.getSupplies(); + + const newMasterItems = new Map(); + const nameToIdMap = new Map(); + + supplies.forEach(supply => { + // Build name to ID mapping + nameToIdMap.set(supply.name, supply.id); + + // Convert API response to Master item format + const locations = (supply.locations || []).map(loc => { + if (loc.shelf !== null && loc.shelf !== undefined) { + return `${loc.location} (Shelf ${loc.shelf})`; + } + return loc.location; + }); + + newMasterItems.set(supply.name, { + name: supply.name, + description: supply.description || '', + image: supply.image || null, + locations: locations, + teams: supply.teams || [], + categories: supply.categories || [], + lastModified: supply.lastModified || null, + last_modified_by: supply.last_modified_by || null, + last_modified_by_name: supply.last_modified_by_name || null, + id: supply.id + }); + }); + + setMasterInventoryItems(newMasterItems); + setSupplyNameToId(nameToIdMap); + } catch (error) { + console.error('Error reloading Master inventory items:', error); + } + }, []); + // Add Mode functions const startAddMode = useCallback((itemName) => { setAddModeItem(itemName); @@ -494,6 +535,9 @@ export const InventoryProvider = ({ children }) => { // Reload supply locations to ensure UI reflects actual server state await reloadSupplyLocations(); + + // Reload master items to update last_modified timestamp + await reloadMasterItems(); // Clear add mode setAddModeItem(null); @@ -511,7 +555,7 @@ export const InventoryProvider = ({ children }) => { } } } - }, [supplyNameToId, reloadSupplyLocations]); + }, [supplyNameToId, reloadSupplyLocations, reloadMasterItems]); const cancelAddMode = useCallback(() => { setAddModeItem(null); @@ -650,6 +694,9 @@ export const InventoryProvider = ({ children }) => { // Reload supply locations to ensure UI reflects actual server state await reloadSupplyLocations(); + + // Reload master items to update last_modified timestamp + await reloadMasterItems(); // Clear subtract mode setSubtractModeItem(null); @@ -667,7 +714,7 @@ export const InventoryProvider = ({ children }) => { } } } - }, [inventoryData, supplyNameToId, reloadSupplyLocations]); + }, [inventoryData, supplyNameToId, reloadSupplyLocations, reloadMasterItems]); const cancelSubtractMode = useCallback(() => { setSubtractModeItem(null); @@ -1032,46 +1079,6 @@ export const InventoryProvider = ({ children }) => { setSelectedMasterItem(null); }, []); - const reloadMasterItems = useCallback(async () => { - try { - const supplies = await api.getSupplies(); - - const newMasterItems = new Map(); - const nameToIdMap = new Map(); - - supplies.forEach(supply => { - // Build name to ID mapping - nameToIdMap.set(supply.name, supply.id); - - // Convert API response to Master item format - const locations = (supply.locations || []).map(loc => { - if (loc.shelf !== null && loc.shelf !== undefined) { - return `${loc.location} (Shelf ${loc.shelf})`; - } - return loc.location; - }); - - newMasterItems.set(supply.name, { - name: supply.name, - description: supply.description || '', - image: supply.image || null, - locations: locations, - teams: supply.teams || [], - categories: supply.categories || [], - lastModified: supply.lastModified || null, - last_modified_by: supply.last_modified_by || null, - last_modified_by_name: supply.last_modified_by_name || null, - id: supply.id - }); - }); - - setMasterInventoryItems(newMasterItems); - setSupplyNameToId(nameToIdMap); - } catch (error) { - console.error('Error reloading Master inventory items:', error); - } - }, []); - const value = { // State inventoryData, diff --git a/src/api/routes/supplies_location.py b/src/api/routes/supplies_location.py index 01830ab..604581a 100644 --- a/src/api/routes/supplies_location.py +++ b/src/api/routes/supplies_location.py @@ -280,6 +280,13 @@ def add_supply_location(current_user_id=None): batch_id=batch_id ) + # Update the master supply's last_modified timestamp + cur.execute(""" + UPDATE supplies + SET last_modified = CURRENT_TIMESTAMP, last_modified_by = %s + WHERE id = %s + """, (current_user_id, supply_id)) + conn.commit() # Fetch the created/updated location @@ -398,6 +405,13 @@ def update_supply_location(location_id, current_user_id=None): batch_id=str(uuid.uuid4()) ) + # Update the master supply's last_modified timestamp + cur.execute(""" + UPDATE supplies + SET last_modified = CURRENT_TIMESTAMP, last_modified_by = %s + WHERE id = %s + """, (current_user_id, old_location['supply_id'])) + conn.commit() # Fetch updated location @@ -469,6 +483,14 @@ def delete_supply_location(location_id, current_user_id=None): cur = conn.cursor() # Switch back to regular cursor cur.execute("DELETE FROM supplies_location WHERE id = %s", (location_id,)) + + # Update the master supply's last_modified timestamp + cur.execute(""" + UPDATE supplies + SET last_modified = CURRENT_TIMESTAMP, last_modified_by = %s + WHERE id = %s + """, (current_user_id, location_data['supply_id'])) + conn.commit() cur.close() conn.close() @@ -621,6 +643,13 @@ def move_supply_locations(current_user_id=None): batch_id=batch_id ) + # Update the master supply's last_modified timestamp + cur.execute(""" + UPDATE supplies + SET last_modified = CURRENT_TIMESTAMP, last_modified_by = %s + WHERE id = %s + """, (current_user_id, supply_id)) + conn.commit() result = { @@ -774,6 +803,13 @@ def bulk_add_supply_locations(current_user_id=None): batch_id=batch_id ) + # Update the master supply's last_modified timestamp + cur.execute(""" + UPDATE supplies + SET last_modified = CURRENT_TIMESTAMP, last_modified_by = %s + WHERE id = %s + """, (current_user_id, supply_id)) + conn.commit() cur.close() From eb1fcaaf65240add4359d31378e50711668577c1 Mon Sep 17 00:00:00 2001 From: willzoo Date: Thu, 5 Mar 2026 13:57:05 -0500 Subject: [PATCH 37/73] word wrapping for inventory table --- milventory/src/components/AdminDashboard.js | 28 +++++++++++++++++++ milventory/src/components/HistoryModal.css | 9 ++++-- milventory/src/components/HistoryModal.js | 6 ++-- milventory/src/components/HistoryTableRow.css | 5 ++++ milventory/src/components/HistoryTableRow.js | 10 +++++-- milventory/src/index.css | 8 ++++++ 6 files changed, 60 insertions(+), 6 deletions(-) diff --git a/milventory/src/components/AdminDashboard.js b/milventory/src/components/AdminDashboard.js index 39c97a6..8fe84f2 100644 --- a/milventory/src/components/AdminDashboard.js +++ b/milventory/src/components/AdminDashboard.js @@ -7,6 +7,7 @@ import AdminActionsPanel from './AdminActionsPanel'; import AddLocationModal from './AddLocationModal'; import LocationPreview from './LocationPreview'; import MoveLocationsModal from './MoveLocationsModal'; +import HistoryModal from './HistoryModal'; const AdminDashboard = () => { const navigate = useNavigate(); @@ -20,6 +21,7 @@ const AdminDashboard = () => { const [selectedLocation, setSelectedLocation] = useState(null); const edgeDragHandlerRef = useRef(null); const [isEditingLocation, setIsEditingLocation] = useState(false); + const [showHistoryModal, setShowHistoryModal] = useState(false); // Move mode state // 'idle' | 'selecting' | 'moving' @@ -132,6 +134,27 @@ const AdminDashboard = () => {
Admin Dashboard +
); diff --git a/milventory/src/components/HistoryModal.css b/milventory/src/components/HistoryModal.css index 2f9ea93..53a97bb 100644 --- a/milventory/src/components/HistoryModal.css +++ b/milventory/src/components/HistoryModal.css @@ -147,10 +147,15 @@ width: 200px; } -.history-th-changes { - /* flex column */ +.history-table .history-item-name { + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; + white-space: normal; + max-width: 200px; } + .history-th-user { width: 200px; } diff --git a/milventory/src/components/HistoryModal.js b/milventory/src/components/HistoryModal.js index 66f381f..8bbe2fe 100644 --- a/milventory/src/components/HistoryModal.js +++ b/milventory/src/components/HistoryModal.js @@ -4,7 +4,7 @@ import { useInventory } from '../context/InventoryContext'; import HistoryTableRow from './HistoryTableRow'; import './HistoryModal.css'; -const HistoryModal = ({ isOpen, onClose }) => { +const HistoryModal = ({ isOpen, onClose, isAdmin = false }) => { const { reloadMasterItems, reloadSupplyLocations } = useInventory(); const [history, setHistory] = useState([]); const [loading, setLoading] = useState(false); @@ -193,10 +193,12 @@ const HistoryModal = ({ isOpen, onClose }) => {
- {history.map(entry => ( + {history.map((entry, index) => ( handleUndo(id, entry.historyType)} /> ))} diff --git a/milventory/src/components/HistoryTableRow.css b/milventory/src/components/HistoryTableRow.css index 9c4b918..16e6fe8 100644 --- a/milventory/src/components/HistoryTableRow.css +++ b/milventory/src/components/HistoryTableRow.css @@ -52,6 +52,11 @@ padding: 0.75rem; font-weight: 500; width: 200px; + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; + white-space: normal; + max-width: 200px; color: var(--text, #e6ebf4); } diff --git a/milventory/src/components/HistoryTableRow.js b/milventory/src/components/HistoryTableRow.js index 9b79ae0..3f34149 100644 --- a/milventory/src/components/HistoryTableRow.js +++ b/milventory/src/components/HistoryTableRow.js @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import './HistoryTableRow.css'; -const HistoryTableRow = ({ entry, onUndo }) => { +const HistoryTableRow = ({ entry, onUndo, index = 0, isAdmin = false }) => { const [showConfirm, setShowConfirm] = useState(false); const formatDate = (dateString) => { @@ -130,9 +130,15 @@ const HistoryTableRow = ({ entry, onUndo }) => { // Determine if entry can be undone // Note: Undone entries are deleted entirely from the database, so we don't need to check undone status - const canUndo = entry.historyType === 'location' + // For users: only last 5 items can be undone + // For admins: all items can be undone + const baseCanUndo = entry.historyType === 'location' ? entry.action_type !== 'CASCADED_SUBTRACT' : entry.can_undo !== false; + + const canUndo = isAdmin + ? baseCanUndo // Admins can undo all items + : baseCanUndo && index < 5; // Users can only undo last 5 items return ( diff --git a/milventory/src/index.css b/milventory/src/index.css index 716d4b5..9ed68be 100644 --- a/milventory/src/index.css +++ b/milventory/src/index.css @@ -838,6 +838,14 @@ svg.overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-e background: rgba(155, 183, 255, 0.1); } +.master-table .name-cell { + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; + white-space: normal; + max-width: 200px; +} + .master-table .qty-cell { width: 60px; text-align: center; From 9bcc07c2eb2c0812b8218f7406f32ccf4a8c92a1 Mon Sep 17 00:00:00 2001 From: willzoo Date: Thu, 5 Mar 2026 14:00:34 -0500 Subject: [PATCH 38/73] increase item preview height --- milventory/src/index.css | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/milventory/src/index.css b/milventory/src/index.css index 9ed68be..2a49bd7 100644 --- a/milventory/src/index.css +++ b/milventory/src/index.css @@ -920,7 +920,7 @@ svg.overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-e .master-preview-pane-overlay .master-preview-pane { width: 400px; - max-height: 300px; + max-height: 450px; overflow-y: auto; } @@ -949,6 +949,13 @@ svg.overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-e margin: 0; font-size: 1rem; font-weight: 600; + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; + white-space: normal; + flex: 1; + min-width: 0; + margin-right: 0.5rem; } .master-preview-pane-close { From 86fa10393a18384944a832e9419658ca3ba41adc Mon Sep 17 00:00:00 2001 From: willzoo Date: Thu, 5 Mar 2026 14:13:02 -0500 Subject: [PATCH 39/73] fix move mode --- .../src/components/MasterItemPreview.js | 3 +- milventory/src/context/InventoryContext.js | 58 ++++++++++++++++++- milventory/src/index.css | 1 + 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/milventory/src/components/MasterItemPreview.js b/milventory/src/components/MasterItemPreview.js index 75db98b..5ebf9b7 100644 --- a/milventory/src/components/MasterItemPreview.js +++ b/milventory/src/components/MasterItemPreview.js @@ -14,6 +14,7 @@ const MasterItemPreview = () => { startAddMode, startMoveMode, startSubtractMode, + finishMoveMode, cancelMoveMode, moveModeItem, leftPaneWidth, @@ -219,7 +220,7 @@ const MasterItemPreview = () => {
{isInMoveMode ? ( <> -
)} + + {/* Column Visibility Button */} +
+ + + {/* Column Visibility Dropdown Menu */} + {showColumnMenu && ( +
e.stopPropagation()} + > +
+ Show Columns +
+
+ + +
+
+ + + + + +
+
+ )} +
@@ -650,36 +1072,66 @@ const MasterInventoryTable = () => { -
- - + {showQtyColumn && ( + + )} + {showLocationColumn && ( + + )} + {showCategoryColumn && ( + + )} + {showTeamColumn && ( + + )} + {showLastModifiedColumn && ( + + )} @@ -690,6 +1142,13 @@ const MasterInventoryTable = () => { itemData={itemData} quantity={quantities.get(itemName) || 0} locations={getItemLocations(itemName)} + categories={getItemCategories(itemName)} + teams={getItemTeams(itemName)} + showQty={showQtyColumn} + showLocation={showLocationColumn} + showCategory={showCategoryColumn} + showTeam={showTeamColumn} + showLastModified={showLastModifiedColumn} isSelected={selectedMasterItem === itemName} onClick={() => handleRowClick(itemName)} /> diff --git a/milventory/src/components/MasterTableRow.js b/milventory/src/components/MasterTableRow.js index 3839dae..c42d355 100644 --- a/milventory/src/components/MasterTableRow.js +++ b/milventory/src/components/MasterTableRow.js @@ -16,7 +16,7 @@ const formatDate = (isoString) => { return `${dateStr}, ${timeStr}`; }; -const MasterTableRow = ({ itemName, itemData, quantity, locations, isSelected, onClick }) => { +const MasterTableRow = ({ itemName, itemData, quantity, locations, categories, teams, showQty, showLocation, showCategory, showTeam, showLastModified, isSelected, onClick }) => { // Build a truncated location string that fits the cell const locationText = locations.length === 0 ? '—' @@ -24,19 +24,49 @@ const MasterTableRow = ({ itemName, itemData, quantity, locations, isSelected, o ? locations[0] : `${locations[0]}, ...+${locations.length - 1}`; + // Build a truncated category string that fits the cell (same strategy as locations) + const categoryText = categories.length === 0 + ? '—' + : categories.length === 1 + ? categories[0] + : `${categories[0]}, ...+${categories.length - 1}`; + + // Build a truncated team string that fits the cell (same strategy as locations) + const teamText = teams.length === 0 + ? '—' + : teams.length === 1 + ? teams[0] + : `${teams[0]}, ...+${teams.length - 1}`; + return ( - - - + {showQty && ( + + )} + {showLocation && ( + + )} + {showCategory && ( + + )} + {showTeam && ( + + )} + {showLastModified && ( + + )} ); }; diff --git a/milventory/src/index.css b/milventory/src/index.css index b3ce844..ede15e1 100644 --- a/milventory/src/index.css +++ b/milventory/src/index.css @@ -860,6 +860,24 @@ svg.overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-e font-size: 0.8rem; } +.master-table .category-cell { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--muted); + font-size: 0.8rem; +} + +.master-table .team-cell { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--muted); + font-size: 0.8rem; +} + .master-table .modified-cell { white-space: nowrap; color: var(--muted); From bb9a69ebcce584bea46e6b98bc6ddff3d90bc37a Mon Sep 17 00:00:00 2001 From: willzoo Date: Sat, 14 Mar 2026 15:04:31 -0400 Subject: [PATCH 41/73] move admin --- milventory/src/App.js | 2 +- milventory/src/components/{ => Admin}/AddLocationModal.js | 2 +- milventory/src/components/{ => Admin}/AdminActionsPanel.js | 0 milventory/src/components/{ => Admin}/AdminDashboard.js | 4 ++-- milventory/src/components/{ => Admin}/AdminLeftPanel.js | 2 +- milventory/src/components/{ => Admin}/AdminMap.js | 4 ++-- milventory/src/components/{ => Admin}/CategoriesTable.js | 2 +- milventory/src/components/{ => Admin}/InventoryBoxesTable.js | 2 +- milventory/src/components/{ => Admin}/LocationPreview.js | 2 +- milventory/src/components/{ => Admin}/MoveLocationsModal.js | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) rename milventory/src/components/{ => Admin}/AddLocationModal.js (99%) rename milventory/src/components/{ => Admin}/AdminActionsPanel.js (100%) rename milventory/src/components/{ => Admin}/AdminDashboard.js (98%) rename milventory/src/components/{ => Admin}/AdminLeftPanel.js (98%) rename milventory/src/components/{ => Admin}/AdminMap.js (99%) rename milventory/src/components/{ => Admin}/CategoriesTable.js (98%) rename milventory/src/components/{ => Admin}/InventoryBoxesTable.js (98%) rename milventory/src/components/{ => Admin}/LocationPreview.js (99%) rename milventory/src/components/{ => Admin}/MoveLocationsModal.js (99%) diff --git a/milventory/src/App.js b/milventory/src/App.js index 707f290..0c7785b 100644 --- a/milventory/src/App.js +++ b/milventory/src/App.js @@ -11,7 +11,7 @@ import Login from './components/Login'; import ErrorToast from './components/ErrorToast'; import ConflictErrorModal from './components/ConflictErrorModal'; import HistoryModal from './components/HistoryModal'; -import AdminDashboard from './components/AdminDashboard'; +import AdminDashboard from './components/Admin/AdminDashboard'; import { auth } from './api'; function App() { diff --git a/milventory/src/components/AddLocationModal.js b/milventory/src/components/Admin/AddLocationModal.js similarity index 99% rename from milventory/src/components/AddLocationModal.js rename to milventory/src/components/Admin/AddLocationModal.js index 868b5af..92b93c7 100644 --- a/milventory/src/components/AddLocationModal.js +++ b/milventory/src/components/Admin/AddLocationModal.js @@ -1,5 +1,5 @@ import React, { useState, useRef } from 'react'; -import { admin } from '../api'; +import { admin } from '../../api'; const LOCATION_TYPES = [ { value: 'drawer', label: 'Drawer' }, diff --git a/milventory/src/components/AdminActionsPanel.js b/milventory/src/components/Admin/AdminActionsPanel.js similarity index 100% rename from milventory/src/components/AdminActionsPanel.js rename to milventory/src/components/Admin/AdminActionsPanel.js diff --git a/milventory/src/components/AdminDashboard.js b/milventory/src/components/Admin/AdminDashboard.js similarity index 98% rename from milventory/src/components/AdminDashboard.js rename to milventory/src/components/Admin/AdminDashboard.js index 8fe84f2..a28f9ea 100644 --- a/milventory/src/components/AdminDashboard.js +++ b/milventory/src/components/Admin/AdminDashboard.js @@ -1,13 +1,13 @@ import React, { useRef, useState, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useInventory } from '../context/InventoryContext'; +import { useInventory } from '../../context/InventoryContext'; import AdminMap from './AdminMap'; import AdminLeftPanel from './AdminLeftPanel'; import AdminActionsPanel from './AdminActionsPanel'; import AddLocationModal from './AddLocationModal'; import LocationPreview from './LocationPreview'; import MoveLocationsModal from './MoveLocationsModal'; -import HistoryModal from './HistoryModal'; +import HistoryModal from '../HistoryModal'; const AdminDashboard = () => { const navigate = useNavigate(); diff --git a/milventory/src/components/AdminLeftPanel.js b/milventory/src/components/Admin/AdminLeftPanel.js similarity index 98% rename from milventory/src/components/AdminLeftPanel.js rename to milventory/src/components/Admin/AdminLeftPanel.js index 400c828..1f90c24 100644 --- a/milventory/src/components/AdminLeftPanel.js +++ b/milventory/src/components/Admin/AdminLeftPanel.js @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { useInventory } from '../context/InventoryContext'; +import { useInventory } from '../../context/InventoryContext'; import InventoryBoxesTable from './InventoryBoxesTable'; import CategoriesTable from './CategoriesTable'; diff --git a/milventory/src/components/AdminMap.js b/milventory/src/components/Admin/AdminMap.js similarity index 99% rename from milventory/src/components/AdminMap.js rename to milventory/src/components/Admin/AdminMap.js index f84fad8..582d38d 100644 --- a/milventory/src/components/AdminMap.js +++ b/milventory/src/components/Admin/AdminMap.js @@ -1,7 +1,7 @@ import React, { forwardRef, useEffect, useRef, useState, useMemo } from 'react'; import * as d3 from 'd3'; -import { useInventory } from '../context/InventoryContext'; -import { admin } from '../api'; +import { useInventory } from '../../context/InventoryContext'; +import { admin } from '../../api'; const AdminMap = forwardRef((props, ref) => { const { diff --git a/milventory/src/components/CategoriesTable.js b/milventory/src/components/Admin/CategoriesTable.js similarity index 98% rename from milventory/src/components/CategoriesTable.js rename to milventory/src/components/Admin/CategoriesTable.js index 94a77e6..a52b165 100644 --- a/milventory/src/components/CategoriesTable.js +++ b/milventory/src/components/Admin/CategoriesTable.js @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { getCategories, admin } from '../api'; +import { getCategories, admin } from '../../api'; const CategoriesTable = () => { const [categories, setCategories] = useState([]); diff --git a/milventory/src/components/InventoryBoxesTable.js b/milventory/src/components/Admin/InventoryBoxesTable.js similarity index 98% rename from milventory/src/components/InventoryBoxesTable.js rename to milventory/src/components/Admin/InventoryBoxesTable.js index 8629269..19bec43 100644 --- a/milventory/src/components/InventoryBoxesTable.js +++ b/milventory/src/components/Admin/InventoryBoxesTable.js @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { admin } from '../api'; +import { admin } from '../../api'; const InventoryBoxesTable = ({ selectedLocation, onLocationSelect }) => { const [locations, setLocations] = useState([]); diff --git a/milventory/src/components/LocationPreview.js b/milventory/src/components/Admin/LocationPreview.js similarity index 99% rename from milventory/src/components/LocationPreview.js rename to milventory/src/components/Admin/LocationPreview.js index 1787772..262e241 100644 --- a/milventory/src/components/LocationPreview.js +++ b/milventory/src/components/Admin/LocationPreview.js @@ -1,5 +1,5 @@ import React, { useRef, useState, useEffect, useCallback } from 'react'; -import { admin } from '../api'; +import { admin } from '../../api'; const LOCATION_TYPES = [ { value: 'drawer', label: 'Drawer' }, diff --git a/milventory/src/components/MoveLocationsModal.js b/milventory/src/components/Admin/MoveLocationsModal.js similarity index 99% rename from milventory/src/components/MoveLocationsModal.js rename to milventory/src/components/Admin/MoveLocationsModal.js index af016ff..3d1d74a 100644 --- a/milventory/src/components/MoveLocationsModal.js +++ b/milventory/src/components/Admin/MoveLocationsModal.js @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { admin } from '../api'; +import { admin } from '../../api'; const MoveLocationsModal = ({ moveMode, From 83c9bdbf4d4a35cd7030259e96df01380e7d31ae Mon Sep 17 00:00:00 2001 From: willzoo Date: Sat, 14 Mar 2026 15:28:27 -0400 Subject: [PATCH 42/73] Move all components into their own folder --- milventory/src/App.js | 20 +++++++++---------- .../src/components/Admin/AdminDashboard.js | 2 +- milventory/src/components/{ => Auth}/Login.js | 2 +- .../src/components/{ => Box}/AddModal.js | 2 +- .../src/components/{ => Box}/EditForm.js | 2 +- .../{ => Common}/ConflictErrorModal.css | 0 .../{ => Common}/ConflictErrorModal.js | 0 .../src/components/{ => Common}/ErrorToast.js | 0 .../components/{ => History}/HistoryModal.css | 0 .../components/{ => History}/HistoryModal.js | 4 ++-- .../components/{ => History}/HistoryTab.js | 4 ++-- .../{ => History}/HistoryTableRow.css | 0 .../{ => History}/HistoryTableRow.js | 0 .../src/components/{ => Layout}/LeftPanel.js | 4 ++-- .../src/components/{ => Map}/AddModeArrow.js | 2 +- .../components/{ => Map}/AddModePreview.js | 2 +- .../components/{ => Map}/ArrowConnections.js | 2 +- .../{ => Map}/BoxInventoryOverlay.js | 4 ++-- milventory/src/components/{ => Map}/Map.js | 4 ++-- .../src/components/{ => Map}/MoveModeBoxes.js | 2 +- .../{ => Map}/SubtractModePreview.js | 2 +- .../src/components/{ => Map}/Tooltip.js | 2 +- .../{ => Master}/MasterCreateModal.js | 4 ++-- .../{ => Master}/MasterEditModal.js | 4 ++-- .../{ => Master}/MasterInventoryTable.js | 4 ++-- .../{ => Master}/MasterItemPreview.js | 4 ++-- .../components/{ => Master}/MasterTableRow.js | 0 27 files changed, 38 insertions(+), 38 deletions(-) rename milventory/src/components/{ => Auth}/Login.js (99%) rename milventory/src/components/{ => Box}/AddModal.js (98%) rename milventory/src/components/{ => Box}/EditForm.js (98%) rename milventory/src/components/{ => Common}/ConflictErrorModal.css (100%) rename milventory/src/components/{ => Common}/ConflictErrorModal.js (100%) rename milventory/src/components/{ => Common}/ErrorToast.js (100%) rename milventory/src/components/{ => History}/HistoryModal.css (100%) rename milventory/src/components/{ => History}/HistoryModal.js (98%) rename milventory/src/components/{ => History}/HistoryTab.js (98%) rename milventory/src/components/{ => History}/HistoryTableRow.css (100%) rename milventory/src/components/{ => History}/HistoryTableRow.js (100%) rename milventory/src/components/{ => Layout}/LeftPanel.js (95%) rename milventory/src/components/{ => Map}/AddModeArrow.js (98%) rename milventory/src/components/{ => Map}/AddModePreview.js (98%) rename milventory/src/components/{ => Map}/ArrowConnections.js (99%) rename milventory/src/components/{ => Map}/BoxInventoryOverlay.js (98%) rename milventory/src/components/{ => Map}/Map.js (99%) rename milventory/src/components/{ => Map}/MoveModeBoxes.js (99%) rename milventory/src/components/{ => Map}/SubtractModePreview.js (98%) rename milventory/src/components/{ => Map}/Tooltip.js (86%) rename milventory/src/components/{ => Master}/MasterCreateModal.js (99%) rename milventory/src/components/{ => Master}/MasterEditModal.js (99%) rename milventory/src/components/{ => Master}/MasterInventoryTable.js (99%) rename milventory/src/components/{ => Master}/MasterItemPreview.js (98%) rename milventory/src/components/{ => Master}/MasterTableRow.js (100%) diff --git a/milventory/src/App.js b/milventory/src/App.js index 0c7785b..bd9ca0f 100644 --- a/milventory/src/App.js +++ b/milventory/src/App.js @@ -1,16 +1,16 @@ import React, { useState, useEffect } from 'react'; import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom'; import { InventoryProvider, useInventory } from './context/InventoryContext'; -import MapComponent from './components/Map'; -import LeftPanel from './components/LeftPanel'; -import Tooltip from './components/Tooltip'; -import AddModal from './components/AddModal'; -import EditModal from './components/EditForm'; -import AddModePreview from './components/AddModePreview'; -import Login from './components/Login'; -import ErrorToast from './components/ErrorToast'; -import ConflictErrorModal from './components/ConflictErrorModal'; -import HistoryModal from './components/HistoryModal'; +import MapComponent from './components/Map/Map'; +import LeftPanel from './components/Layout/LeftPanel'; +import Tooltip from './components/Map/Tooltip'; +import AddModal from './components/Box/AddModal'; +import EditModal from './components/Box/EditForm'; +import AddModePreview from './components/Map/AddModePreview'; +import Login from './components/Auth/Login'; +import ErrorToast from './components/Common/ErrorToast'; +import ConflictErrorModal from './components/Common/ConflictErrorModal'; +import HistoryModal from './components/History/HistoryModal'; import AdminDashboard from './components/Admin/AdminDashboard'; import { auth } from './api'; diff --git a/milventory/src/components/Admin/AdminDashboard.js b/milventory/src/components/Admin/AdminDashboard.js index a28f9ea..11e0f01 100644 --- a/milventory/src/components/Admin/AdminDashboard.js +++ b/milventory/src/components/Admin/AdminDashboard.js @@ -7,7 +7,7 @@ import AdminActionsPanel from './AdminActionsPanel'; import AddLocationModal from './AddLocationModal'; import LocationPreview from './LocationPreview'; import MoveLocationsModal from './MoveLocationsModal'; -import HistoryModal from '../HistoryModal'; +import HistoryModal from '../History/HistoryModal'; const AdminDashboard = () => { const navigate = useNavigate(); diff --git a/milventory/src/components/Login.js b/milventory/src/components/Auth/Login.js similarity index 99% rename from milventory/src/components/Login.js rename to milventory/src/components/Auth/Login.js index f308e90..dd2755a 100644 --- a/milventory/src/components/Login.js +++ b/milventory/src/components/Auth/Login.js @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { auth } from '../api'; +import { auth } from '../../api'; const Login = ({ onLoginSuccess }) => { const [email, setEmail] = useState(''); diff --git a/milventory/src/components/AddModal.js b/milventory/src/components/Box/AddModal.js similarity index 98% rename from milventory/src/components/AddModal.js rename to milventory/src/components/Box/AddModal.js index e928c7a..18c5410 100644 --- a/milventory/src/components/AddModal.js +++ b/milventory/src/components/Box/AddModal.js @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; -import { useInventory } from '../context/InventoryContext'; +import { useInventory } from '../../context/InventoryContext'; const AddModal = () => { const { currentAddingBox, currentAddingIndex, setCurrentAddingBox, setCurrentAddingIndex, inventoryData, updateInventory, masterInventoryItems } = useInventory(); diff --git a/milventory/src/components/EditForm.js b/milventory/src/components/Box/EditForm.js similarity index 98% rename from milventory/src/components/EditForm.js rename to milventory/src/components/Box/EditForm.js index 0f6d539..03c2bf1 100644 --- a/milventory/src/components/EditForm.js +++ b/milventory/src/components/Box/EditForm.js @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; -import { useInventory } from '../context/InventoryContext'; +import { useInventory } from '../../context/InventoryContext'; const EditModal = () => { const { currentEditingBox, currentEditingIndex, inventoryData, setCurrentEditingBox, setCurrentEditingIndex, setLastSelectedIndex, updateInventory, masterInventoryItems, resolveMasterItem } = useInventory(); diff --git a/milventory/src/components/ConflictErrorModal.css b/milventory/src/components/Common/ConflictErrorModal.css similarity index 100% rename from milventory/src/components/ConflictErrorModal.css rename to milventory/src/components/Common/ConflictErrorModal.css diff --git a/milventory/src/components/ConflictErrorModal.js b/milventory/src/components/Common/ConflictErrorModal.js similarity index 100% rename from milventory/src/components/ConflictErrorModal.js rename to milventory/src/components/Common/ConflictErrorModal.js diff --git a/milventory/src/components/ErrorToast.js b/milventory/src/components/Common/ErrorToast.js similarity index 100% rename from milventory/src/components/ErrorToast.js rename to milventory/src/components/Common/ErrorToast.js diff --git a/milventory/src/components/HistoryModal.css b/milventory/src/components/History/HistoryModal.css similarity index 100% rename from milventory/src/components/HistoryModal.css rename to milventory/src/components/History/HistoryModal.css diff --git a/milventory/src/components/HistoryModal.js b/milventory/src/components/History/HistoryModal.js similarity index 98% rename from milventory/src/components/HistoryModal.js rename to milventory/src/components/History/HistoryModal.js index 8bbe2fe..36cdc86 100644 --- a/milventory/src/components/HistoryModal.js +++ b/milventory/src/components/History/HistoryModal.js @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; -import { api, locationHistory } from '../api'; -import { useInventory } from '../context/InventoryContext'; +import { api, locationHistory } from '../../api'; +import { useInventory } from '../../context/InventoryContext'; import HistoryTableRow from './HistoryTableRow'; import './HistoryModal.css'; diff --git a/milventory/src/components/HistoryTab.js b/milventory/src/components/History/HistoryTab.js similarity index 98% rename from milventory/src/components/HistoryTab.js rename to milventory/src/components/History/HistoryTab.js index 657d035..887f330 100644 --- a/milventory/src/components/HistoryTab.js +++ b/milventory/src/components/History/HistoryTab.js @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; -import { locationHistory } from '../api'; -import { useInventory } from '../context/InventoryContext'; +import { locationHistory } from '../../api'; +import { useInventory } from '../../context/InventoryContext'; const HistoryTab = () => { const [history, setHistory] = useState([]); diff --git a/milventory/src/components/HistoryTableRow.css b/milventory/src/components/History/HistoryTableRow.css similarity index 100% rename from milventory/src/components/HistoryTableRow.css rename to milventory/src/components/History/HistoryTableRow.css diff --git a/milventory/src/components/HistoryTableRow.js b/milventory/src/components/History/HistoryTableRow.js similarity index 100% rename from milventory/src/components/HistoryTableRow.js rename to milventory/src/components/History/HistoryTableRow.js diff --git a/milventory/src/components/LeftPanel.js b/milventory/src/components/Layout/LeftPanel.js similarity index 95% rename from milventory/src/components/LeftPanel.js rename to milventory/src/components/Layout/LeftPanel.js index 56dbc34..e1262b3 100644 --- a/milventory/src/components/LeftPanel.js +++ b/milventory/src/components/Layout/LeftPanel.js @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -import { useInventory } from '../context/InventoryContext'; -import MasterInventoryTable from './MasterInventoryTable'; +import { useInventory } from '../../context/InventoryContext'; +import MasterInventoryTable from '../Master/MasterInventoryTable'; const LeftPanel = () => { const { leftPaneWidth, setLeftPaneWidth, leftPaneCollapsed, setLeftPaneCollapsed } = useInventory(); diff --git a/milventory/src/components/AddModeArrow.js b/milventory/src/components/Map/AddModeArrow.js similarity index 98% rename from milventory/src/components/AddModeArrow.js rename to milventory/src/components/Map/AddModeArrow.js index 5d48556..fc9f52a 100644 --- a/milventory/src/components/AddModeArrow.js +++ b/milventory/src/components/Map/AddModeArrow.js @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useCallback } from 'react'; -import { useInventory } from '../context/InventoryContext'; +import { useInventory } from '../../context/InventoryContext'; import * as d3 from 'd3'; const AddModeArrow = () => { diff --git a/milventory/src/components/AddModePreview.js b/milventory/src/components/Map/AddModePreview.js similarity index 98% rename from milventory/src/components/AddModePreview.js rename to milventory/src/components/Map/AddModePreview.js index 6d10d73..f4d77f0 100644 --- a/milventory/src/components/AddModePreview.js +++ b/milventory/src/components/Map/AddModePreview.js @@ -1,5 +1,5 @@ import React from 'react'; -import { useInventory } from '../context/InventoryContext'; +import { useInventory } from '../../context/InventoryContext'; const AddModePreview = () => { const { diff --git a/milventory/src/components/ArrowConnections.js b/milventory/src/components/Map/ArrowConnections.js similarity index 99% rename from milventory/src/components/ArrowConnections.js rename to milventory/src/components/Map/ArrowConnections.js index 0b1afb0..16f17dc 100644 --- a/milventory/src/components/ArrowConnections.js +++ b/milventory/src/components/Map/ArrowConnections.js @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useCallback } from 'react'; -import { useInventory } from '../context/InventoryContext'; +import { useInventory } from '../../context/InventoryContext'; import * as d3 from 'd3'; const SHELF_NAMES = [ diff --git a/milventory/src/components/BoxInventoryOverlay.js b/milventory/src/components/Map/BoxInventoryOverlay.js similarity index 98% rename from milventory/src/components/BoxInventoryOverlay.js rename to milventory/src/components/Map/BoxInventoryOverlay.js index e16165a..80922a2 100644 --- a/milventory/src/components/BoxInventoryOverlay.js +++ b/milventory/src/components/Map/BoxInventoryOverlay.js @@ -1,6 +1,6 @@ import React, { useMemo } from 'react'; -import { useInventory } from '../context/InventoryContext'; -import { escapeHtml } from '../utils'; +import { useInventory } from '../../context/InventoryContext'; +import { escapeHtml } from '../../utils'; const BoxInventoryOverlay = () => { const { selectedBox, inventoryData, addModeItem, setMasterFilterLocation } = useInventory(); diff --git a/milventory/src/components/Map.js b/milventory/src/components/Map/Map.js similarity index 99% rename from milventory/src/components/Map.js rename to milventory/src/components/Map/Map.js index c02da65..0b2a259 100644 --- a/milventory/src/components/Map.js +++ b/milventory/src/components/Map/Map.js @@ -1,6 +1,6 @@ import React, { forwardRef } from 'react'; -import { useInventory } from '../context/InventoryContext'; -import MasterItemPreview from './MasterItemPreview'; +import { useInventory } from '../../context/InventoryContext'; +import MasterItemPreview from '../Master/MasterItemPreview'; import ArrowConnections from './ArrowConnections'; import BoxInventoryOverlay from './BoxInventoryOverlay'; import AddModeArrow from './AddModeArrow'; diff --git a/milventory/src/components/MoveModeBoxes.js b/milventory/src/components/Map/MoveModeBoxes.js similarity index 99% rename from milventory/src/components/MoveModeBoxes.js rename to milventory/src/components/Map/MoveModeBoxes.js index a68615c..2ae45f4 100644 --- a/milventory/src/components/MoveModeBoxes.js +++ b/milventory/src/components/Map/MoveModeBoxes.js @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react'; -import { useInventory } from '../context/InventoryContext'; +import { useInventory } from '../../context/InventoryContext'; const SHELF_NAMES = [ 'Shelf 6 (Top)', diff --git a/milventory/src/components/SubtractModePreview.js b/milventory/src/components/Map/SubtractModePreview.js similarity index 98% rename from milventory/src/components/SubtractModePreview.js rename to milventory/src/components/Map/SubtractModePreview.js index 51e7bb4..7dcc5f2 100644 --- a/milventory/src/components/SubtractModePreview.js +++ b/milventory/src/components/Map/SubtractModePreview.js @@ -1,5 +1,5 @@ import React from 'react'; -import { useInventory } from '../context/InventoryContext'; +import { useInventory } from '../../context/InventoryContext'; const SubtractModePreview = () => { const { diff --git a/milventory/src/components/Tooltip.js b/milventory/src/components/Map/Tooltip.js similarity index 86% rename from milventory/src/components/Tooltip.js rename to milventory/src/components/Map/Tooltip.js index 6dc1499..c77591a 100644 --- a/milventory/src/components/Tooltip.js +++ b/milventory/src/components/Map/Tooltip.js @@ -1,5 +1,5 @@ import React from 'react'; -import { useInventory } from '../context/InventoryContext'; +import { useInventory } from '../../context/InventoryContext'; const Tooltip = () => { const { tooltip, wrapRef } = useInventory(); diff --git a/milventory/src/components/MasterCreateModal.js b/milventory/src/components/Master/MasterCreateModal.js similarity index 99% rename from milventory/src/components/MasterCreateModal.js rename to milventory/src/components/Master/MasterCreateModal.js index c6c3777..bd351ef 100644 --- a/milventory/src/components/MasterCreateModal.js +++ b/milventory/src/components/Master/MasterCreateModal.js @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; -import { useInventory } from '../context/InventoryContext'; -import { getCategories, getTeams } from '../api'; +import { useInventory } from '../../context/InventoryContext'; +import { getCategories, getTeams } from '../../api'; // Levenshtein distance for fuzzy search const levenshteinDistance = (str1, str2) => { diff --git a/milventory/src/components/MasterEditModal.js b/milventory/src/components/Master/MasterEditModal.js similarity index 99% rename from milventory/src/components/MasterEditModal.js rename to milventory/src/components/Master/MasterEditModal.js index baf1ab5..59a6d57 100644 --- a/milventory/src/components/MasterEditModal.js +++ b/milventory/src/components/Master/MasterEditModal.js @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; -import { useInventory } from '../context/InventoryContext'; -import { getCategories, getTeams } from '../api'; +import { useInventory } from '../../context/InventoryContext'; +import { getCategories, getTeams } from '../../api'; // Levenshtein distance for fuzzy search const levenshteinDistance = (str1, str2) => { diff --git a/milventory/src/components/MasterInventoryTable.js b/milventory/src/components/Master/MasterInventoryTable.js similarity index 99% rename from milventory/src/components/MasterInventoryTable.js rename to milventory/src/components/Master/MasterInventoryTable.js index 5a776cc..35606c6 100644 --- a/milventory/src/components/MasterInventoryTable.js +++ b/milventory/src/components/Master/MasterInventoryTable.js @@ -1,8 +1,8 @@ import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'; -import { useInventory } from '../context/InventoryContext'; +import { useInventory } from '../../context/InventoryContext'; import MasterTableRow from './MasterTableRow'; import MasterCreateModal from './MasterCreateModal'; -import { getCategories } from '../api'; +import { getCategories } from '../../api'; const MasterInventoryTable = () => { const { diff --git a/milventory/src/components/MasterItemPreview.js b/milventory/src/components/Master/MasterItemPreview.js similarity index 98% rename from milventory/src/components/MasterItemPreview.js rename to milventory/src/components/Master/MasterItemPreview.js index 5ebf9b7..9a2f478 100644 --- a/milventory/src/components/MasterItemPreview.js +++ b/milventory/src/components/Master/MasterItemPreview.js @@ -1,7 +1,7 @@ import React, { useRef, useState, useEffect } from 'react'; -import { useInventory } from '../context/InventoryContext'; +import { useInventory } from '../../context/InventoryContext'; import MasterEditModal from './MasterEditModal'; -import { getCategories } from '../api'; +import { getCategories } from '../../api'; const MasterItemPreview = () => { const { diff --git a/milventory/src/components/MasterTableRow.js b/milventory/src/components/Master/MasterTableRow.js similarity index 100% rename from milventory/src/components/MasterTableRow.js rename to milventory/src/components/Master/MasterTableRow.js From 5dac7fedb71a424986128bdbd3208b77ac1bd972 Mon Sep 17 00:00:00 2001 From: willzoo Date: Sat, 14 Mar 2026 15:29:48 -0400 Subject: [PATCH 43/73] fix edge cases in history modal --- .../src/components/History/HistoryModal.js | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/milventory/src/components/History/HistoryModal.js b/milventory/src/components/History/HistoryModal.js index 36cdc86..d0a71da 100644 --- a/milventory/src/components/History/HistoryModal.js +++ b/milventory/src/components/History/HistoryModal.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { api, locationHistory } from '../../api'; import { useInventory } from '../../context/InventoryContext'; import HistoryTableRow from './HistoryTableRow'; @@ -40,11 +40,13 @@ const HistoryModal = ({ isOpen, onClose, isAdmin = false }) => { }) ]); - // Mark supply history entries - const supplyHistory = supplyResponse.history.map(entry => ({ - ...entry, - historyType: 'supply' - })); + // Mark supply history entries (guard against missing or non-array response) + const supplyHistory = Array.isArray(supplyResponse?.history) + ? supplyResponse.history.map(entry => ({ + ...entry, + historyType: 'supply' + })) + : []; // Mark location history entries const locHistory = Array.isArray(locationData) ? locationData.map(entry => ({ @@ -120,11 +122,11 @@ const HistoryModal = ({ isOpen, onClose, isAdmin = false }) => { } }; - const handleKeyDown = (e) => { + const handleKeyDown = useCallback((e) => { if (e.key === 'Escape') { onClose(); } - }; + }, [onClose]); useEffect(() => { if (isOpen) { @@ -133,7 +135,7 @@ const HistoryModal = ({ isOpen, onClose, isAdmin = false }) => { document.removeEventListener('keydown', handleKeyDown); }; } - }, [isOpen]); + }, [isOpen, handleKeyDown]); if (!isOpen) return null; From ee96c31fba1fb2d396100479a6993bbfdc670ec2 Mon Sep 17 00:00:00 2001 From: willzoo Date: Sat, 14 Mar 2026 15:32:45 -0400 Subject: [PATCH 44/73] Fix "Clear" button positioning --- milventory/src/components/Master/MasterInventoryTable.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/milventory/src/components/Master/MasterInventoryTable.js b/milventory/src/components/Master/MasterInventoryTable.js index 35606c6..a8f6c9c 100644 --- a/milventory/src/components/Master/MasterInventoryTable.js +++ b/milventory/src/components/Master/MasterInventoryTable.js @@ -399,7 +399,7 @@ const MasterInventoryTable = () => { className="master-search-input" />
-
+
+
{/* Tab content */} @@ -99,6 +106,7 @@ const AdminLeftPanel = ({ selectedLocation, onLocationSelect }) => { /> )} {activeTab === 'categories' && } + {activeTab === 'customfields' && }
diff --git a/milventory/src/components/Admin/CustomFieldsTable.js b/milventory/src/components/Admin/CustomFieldsTable.js new file mode 100644 index 0000000..25a0217 --- /dev/null +++ b/milventory/src/components/Admin/CustomFieldsTable.js @@ -0,0 +1,266 @@ +import React, { useState, useEffect } from 'react'; +import { admin } from '../../api'; + +const TYPES = ['text', 'number', 'date']; + +const CustomFieldsTable = () => { + const [definitions, setDefinitions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showAddForm, setShowAddForm] = useState(false); + const [addName, setAddName] = useState(''); + const [addType, setAddType] = useState('text'); + const [submitting, setSubmitting] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const [editType, setEditType] = useState('text'); + + const loadDefinitions = async () => { + try { + setLoading(true); + setError(null); + const data = await admin.getCustomFieldDefinitions(); + setDefinitions(Array.isArray(data) ? data : []); + } catch (err) { + setError(err.message); + setDefinitions([]); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadDefinitions(); + }, []); + + const handleAdd = async (e) => { + e.preventDefault(); + if (!addName.trim()) { + setError('Name is required'); + return; + } + try { + setSubmitting(true); + setError(null); + await admin.createCustomFieldDefinition({ + name: addName.trim(), + type: addType + }); + setAddName(''); + setAddType('text'); + setShowAddForm(false); + await loadDefinitions(); + } catch (err) { + setError(err.message); + } finally { + setSubmitting(false); + } + }; + + const startEdit = (def) => { + setEditingId(def.id); + setEditName(def.name); + setEditType(def.type); + }; + + const cancelEdit = () => { + setEditingId(null); + setEditName(''); + setEditType('text'); + }; + + const handleUpdate = async () => { + if (editingId == null) return; + try { + setError(null); + await admin.updateCustomFieldDefinition(editingId, { name: editName.trim(), type: editType }); + setEditingId(null); + await loadDefinitions(); + } catch (err) { + setError(err.message); + } + }; + + const handleDelete = async (id) => { + if (!window.confirm('Delete this custom field definition? This will also remove this field and its value from all items that have it.')) return; + try { + setError(null); + await admin.deleteCustomFieldDefinition(id); + await loadDefinitions(); + } catch (err) { + setError(err.message); + } + }; + + if (loading) { + return
Loading custom fields...
; + } + + return ( + <> + {error && ( +
+ {error} +
+ )} + + {showAddForm && ( +
+
+ setAddName(e.target.value)} + className="master-search-input" + disabled={submitting} + style={{ marginBottom: '0.5rem', display: 'block', width: '100%' }} + /> + +
+ + +
+ +
+ )} + + {definitions.length === 0 ? ( +
+ {showAddForm ? 'Enter name and type above' : 'No custom fields. Click "+ Add Custom Field" to create one.'} +
+ ) : ( +
NameQtyLocationLast Modified handleSort('name')} + > + + Name + + + handleSort('qty')} + > + + Qty + + + handleSort('location')} + > + + Location + + + handleSort('lastModified')} + > + + Last Modified + + +
handleSort('qty')} - > - - Qty - - - handleSort('location')} - > - - Location - - - handleSort('lastModified')} - > - - Last Modified - - - handleSort('qty')} + > + + Qty + + + handleSort('location')} + > + + Location + + + handleSort('category')} + > + + Category + + + handleSort('team')} + > + + Team + + + handleSort('lastModified')} + > + + Last Modified + + +
{itemName}{quantity} - {locationText} - - {formatDate(itemData.lastModified)} - {quantity} + {locationText} + + {categoryText} + + {teamText} + + {formatDate(itemData.lastModified)} +
+ + + + + + + + + {definitions.map(def => ( + + {editingId === def.id ? ( + <> + + + + + ) : ( + <> + + + + + )} + + ))} + +
NameTypeActions
+ setEditName(e.target.value)} + className="master-search-input" + style={{ width: '100%', padding: '0.25rem' }} + /> + + + + + + {def.name}{def.type} + + +
+ )} + + {!showAddForm && ( +
+ +
+ )} + + ); +}; + +export default CustomFieldsTable; diff --git a/milventory/src/components/Box/AddModal.js b/milventory/src/components/Box/AddModal.js index 18c5410..1358466 100644 --- a/milventory/src/components/Box/AddModal.js +++ b/milventory/src/components/Box/AddModal.js @@ -120,7 +120,7 @@ const AddModal = () => { setSelectedItemName(e.target.value)} - className="modal-select" + className="styled-select" > {filteredMasterItems.map(itemName => ( diff --git a/milventory/src/components/Box/EditForm.js b/milventory/src/components/Box/EditForm.js index 03c2bf1..72fb1b8 100644 --- a/milventory/src/components/Box/EditForm.js +++ b/milventory/src/components/Box/EditForm.js @@ -77,7 +77,7 @@ const EditModal = () => { ref={nameInputRef} value={selectedItemName} onChange={(e) => setSelectedItemName(e.target.value)} - className="modal-select" + className="styled-select" > {Array.from(masterInventoryItems.keys()).map(itemName => ( diff --git a/milventory/src/components/Master/MasterCreateModal.js b/milventory/src/components/Master/MasterCreateModal.js index bd351ef..789d869 100644 --- a/milventory/src/components/Master/MasterCreateModal.js +++ b/milventory/src/components/Master/MasterCreateModal.js @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { useInventory } from '../../context/InventoryContext'; -import { getCategories, getTeams } from '../../api'; +import { getCategories, getTeams, api } from '../../api'; // Levenshtein distance for fuzzy search const levenshteinDistance = (str1, str2) => { @@ -28,6 +28,18 @@ const levenshteinDistance = (str1, str2) => { return dp[m][n]; }; +// Validate that all number-type custom fields have a valid number (or are empty) +const areNumberCustomFieldsValid = (customFields, customFieldDefinitions) => { + const numberDefs = customFieldDefinitions.filter(d => d.type === 'number'); + for (const d of numberDefs) { + const value = customFields[d.name]; + if (value === undefined || value === null || value === '') continue; + const n = Number(value); + if (Number.isNaN(n) || !Number.isFinite(n)) return false; + } + return true; +}; + // Reusable tag dropdown with fuzzy search const TagDropdown = ({ placeholder, selectedItems, availableItems, onSelect, onRemove, maxResults = 5, capitalize = false, onSearchChange }) => { const [isOpen, setIsOpen] = useState(false); @@ -73,10 +85,10 @@ const TagDropdown = ({ placeholder, selectedItems, availableItems, onSelect, onR return (
- {/* Selected tags */} + {/* Selected tags — 120% */}
{selectedItems.length === 0 && ( @@ -86,9 +98,9 @@ const TagDropdown = ({ placeholder, selectedItems, availableItems, onSelect, onR )} {selectedItems.map(item => ( {item} @@ -101,13 +113,14 @@ const TagDropdown = ({ placeholder, selectedItems, availableItems, onSelect, onR ))}
- {/* Search input / dropdown trigger */} + {/* Search input / dropdown trigger — 132% (120% + 10%) */}
{ setIsOpen(true); inputRef.current?.focus(); }} style={{ - width: '100%', padding: '0.5rem', background: 'rgba(0,0,0,.3)', - border: '1px solid rgba(255,255,255,.1)', borderRadius: '4px', - color: 'var(--text)', fontSize: '0.9rem', cursor: 'pointer', + width: '100%', padding: '0.615rem 0.879rem', minHeight: '2.2rem', + background: 'rgba(0,0,0,.3)', border: '1px solid rgba(255,255,255,.1)', borderRadius: '4px', + color: 'var(--text)', fontSize: '0.85rem', cursor: 'pointer', display: 'flex', alignItems: 'center', boxSizing: 'border-box' }} > @@ -121,10 +134,10 @@ const TagDropdown = ({ placeholder, selectedItems, availableItems, onSelect, onR onClick={(e) => e.stopPropagation()} style={{ background: 'transparent', border: 'none', color: 'var(--text)', - fontSize: '0.9rem', outline: 'none', width: '100%', cursor: 'pointer' + fontSize: '0.85rem', outline: 'none', width: '100%', cursor: 'pointer', lineHeight: 1.35, padding: 0, margin: 0 }} /> - +
{/* Dropdown list */} @@ -169,11 +182,28 @@ const MasterCreateModal = ({ isOpen, onClose }) => { const [availableCategories, setAvailableCategories] = useState([]); const [availableTeams, setAvailableTeams] = useState([]); const [categoryNameToId, setCategoryNameToId] = useState(new Map()); + const [customFields, setCustomFields] = useState({}); + const [customFieldDefinitions, setCustomFieldDefinitions] = useState([]); + const [addFieldDropdownOpen, setAddFieldDropdownOpen] = useState(false); + const addFieldDropdownRef = useRef(null); const nameInputRef = useRef(null); - // Fetch categories and teams on mount and when modal opens + useEffect(() => { + const handler = (e) => { + if (addFieldDropdownRef.current && !addFieldDropdownRef.current.contains(e.target)) { + setAddFieldDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + // Fetch categories, teams, and custom field definitions when modal opens useEffect(() => { if (isOpen) { + api.getCustomFieldDefinitions() + .then(setCustomFieldDefinitions) + .catch(() => setCustomFieldDefinitions([])); getCategories() .then(categories => { // Categories now come as objects with {id, name} @@ -216,6 +246,8 @@ const MasterCreateModal = ({ isOpen, onClose }) => { setSelectedTeams([]); setSelectedCategories([]); setCategorySearchQuery(''); + setCustomFields({}); + setAddFieldDropdownOpen(false); setTimeout(() => nameInputRef.current?.focus(), 0); } }, [isOpen]); @@ -264,6 +296,10 @@ const MasterCreateModal = ({ isOpen, onClose }) => { alert('An item with this name already exists. Please use a different name.'); return; } + if (!areNumberCustomFieldsValid(customFields, customFieldDefinitions)) { + alert('Please enter a valid number in all number fields (or leave them empty).'); + return; + } // Convert category names to IDs const categoryIds = selectedCategories @@ -276,6 +312,7 @@ const MasterCreateModal = ({ isOpen, onClose }) => { image: image || null, teams: selectedTeams.length > 0 ? selectedTeams : undefined, categories: categoryIds.length > 0 ? categoryIds : undefined, + custom_fields: Object.keys(customFields).length > 0 ? customFields : undefined, locations: [] }; @@ -346,6 +383,105 @@ const MasterCreateModal = ({ isOpen, onClose }) => { onSearchChange={setCategorySearchQuery} /> + {/* Custom fields: dropdown to add; each added field is a full-width row with gray X to remove */} +
+ {Object.entries(customFields).map(([key, value]) => { + const def = customFieldDefinitions.find(d => d.name === key); + const fieldType = def ? def.type : 'text'; + const displayValue = value === undefined || value === null ? '' : String(value); + return ( +
+ {fieldType === 'text' && ( + setCustomFields(prev => ({ ...prev, [key]: e.target.value }))} + style={{ flex: 1, minWidth: 0 }} + /> + )} + {fieldType === 'number' && ( + setCustomFields(prev => ({ ...prev, [key]: e.target.value === '' ? '' : Number(e.target.value) }))} + style={{ flex: 1, minWidth: 0 }} + /> + )} + {fieldType === 'date' && ( + setCustomFields(prev => ({ ...prev, [key]: e.target.value }))} + style={{ flex: 1, minWidth: 0 }} + /> + )} + +
+ ); + })} +
+ + {addFieldDropdownOpen && ( +
+ {customFieldDefinitions + .filter(d => !(d.name in customFields)) + .map(d => ( + + ))} + {customFieldDefinitions.filter(d => !(d.name in customFields)).length === 0 && ( +
+ No more fields to add +
+ )} +
+ )} +
+
+
diff --git a/milventory/src/components/Master/MasterEditModal.js b/milventory/src/components/Master/MasterEditModal.js index 59a6d57..1c18598 100644 --- a/milventory/src/components/Master/MasterEditModal.js +++ b/milventory/src/components/Master/MasterEditModal.js @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { useInventory } from '../../context/InventoryContext'; -import { getCategories, getTeams } from '../../api'; +import { getCategories, getTeams, api } from '../../api'; // Levenshtein distance for fuzzy search const levenshteinDistance = (str1, str2) => { @@ -28,6 +28,18 @@ const levenshteinDistance = (str1, str2) => { return dp[m][n]; }; +// Validate that all number-type custom fields have a valid number (or are empty) +const areNumberCustomFieldsValid = (customFields, customFieldDefinitions) => { + const numberDefs = customFieldDefinitions.filter(d => d.type === 'number'); + for (const d of numberDefs) { + const value = customFields[d.name]; + if (value === undefined || value === null || value === '') continue; + const n = Number(value); + if (Number.isNaN(n) || !Number.isFinite(n)) return false; + } + return true; +}; + // Reusable tag dropdown with fuzzy search const TagDropdown = ({ placeholder, selectedItems, availableItems, onSelect, onRemove, maxResults = 5, capitalize = false, onSearchChange }) => { const [isOpen, setIsOpen] = useState(false); @@ -72,8 +84,8 @@ const TagDropdown = ({ placeholder, selectedItems, availableItems, onSelect, onR return (
{selectedItems.length === 0 && ( @@ -83,9 +95,9 @@ const TagDropdown = ({ placeholder, selectedItems, availableItems, onSelect, onR )} {selectedItems.map(item => ( {item} @@ -99,11 +111,12 @@ const TagDropdown = ({ placeholder, selectedItems, availableItems, onSelect, onR
{ setIsOpen(true); inputRef.current?.focus(); }} style={{ - width: '100%', padding: '0.5rem', background: 'rgba(0,0,0,.3)', - border: '1px solid rgba(255,255,255,.1)', borderRadius: '4px', - color: 'var(--text)', fontSize: '0.9rem', cursor: 'pointer', + width: '100%', padding: '0.615rem 0.879rem', minHeight: '2.2rem', + background: 'rgba(0,0,0,.3)', border: '1px solid rgba(255,255,255,.1)', borderRadius: '4px', + color: 'var(--text)', fontSize: '0.85rem', cursor: 'pointer', display: 'flex', alignItems: 'center', boxSizing: 'border-box' }} > @@ -117,10 +130,10 @@ const TagDropdown = ({ placeholder, selectedItems, availableItems, onSelect, onR onClick={(e) => e.stopPropagation()} style={{ background: 'transparent', border: 'none', color: 'var(--text)', - fontSize: '0.9rem', outline: 'none', width: '100%', cursor: 'pointer' + fontSize: '0.85rem', outline: 'none', width: '100%', cursor: 'pointer', lineHeight: 1.35, padding: 0, margin: 0 }} /> - +
{isOpen && displayItems.length > 0 && ( @@ -164,13 +177,30 @@ const MasterEditModal = ({ isOpen, onClose, itemName }) => { const [availableTeams, setAvailableTeams] = useState([]); const [categoryNameToId, setCategoryNameToId] = useState(new Map()); const [categoryIdToName, setCategoryIdToName] = useState(new Map()); + const [customFields, setCustomFields] = useState({}); + const [customFieldDefinitions, setCustomFieldDefinitions] = useState([]); + const [addFieldDropdownOpen, setAddFieldDropdownOpen] = useState(false); + const addFieldDropdownRef = useRef(null); const nameInputRef = useRef(null); const originalItem = itemName ? resolveMasterItem(itemName) : null; - // Fetch categories and teams when modal opens + useEffect(() => { + const handler = (e) => { + if (addFieldDropdownRef.current && !addFieldDropdownRef.current.contains(e.target)) { + setAddFieldDropdownOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + // Fetch categories, teams, and custom field definitions when modal opens useEffect(() => { if (isOpen) { + api.getCustomFieldDefinitions() + .then(setCustomFieldDefinitions) + .catch(() => setCustomFieldDefinitions([])); getCategories() .then(categories => { const categoryList = categories.map(c => typeof c === 'string' ? c : c.name); @@ -210,10 +240,10 @@ const MasterEditModal = ({ isOpen, onClose, itemName }) => { setDescription(originalItem.description || ''); setImage(originalItem.image || null); setImagePreview(originalItem.image || null); - + setCustomFields(originalItem.custom_fields || {}); + setAddFieldDropdownOpen(false); // Load teams (already lowercase from API) setSelectedTeams(originalItem.teams || []); - setTimeout(() => nameInputRef.current?.focus(), 0); } }, [isOpen, originalItem]); @@ -280,6 +310,10 @@ const MasterEditModal = ({ isOpen, onClose, itemName }) => { alert('An item with this name already exists. Please use a different name.'); return; } + if (!areNumberCustomFieldsValid(customFields, customFieldDefinitions)) { + alert('Please enter a valid number in all number fields (or leave them empty).'); + return; + } // Convert category names to IDs const categoryIds = selectedCategories @@ -292,6 +326,7 @@ const MasterEditModal = ({ isOpen, onClose, itemName }) => { image: image || null, teams: selectedTeams.length > 0 ? selectedTeams : [], categories: categoryIds.length > 0 ? categoryIds : [], + custom_fields: customFields, locations: originalItem?.locations || [] }; @@ -361,6 +396,105 @@ const MasterEditModal = ({ isOpen, onClose, itemName }) => { maxResults={5} /> + {/* Custom fields: dropdown to add; each added field is a full-width row with gray X to remove */} +
+ {Object.entries(customFields).map(([key, value]) => { + const def = customFieldDefinitions.find(d => d.name === key); + const fieldType = def ? def.type : 'text'; + const displayValue = value === undefined || value === null ? '' : String(value); + return ( +
+ {fieldType === 'text' && ( + setCustomFields(prev => ({ ...prev, [key]: e.target.value }))} + style={{ flex: 1, minWidth: 0 }} + /> + )} + {fieldType === 'number' && ( + setCustomFields(prev => ({ ...prev, [key]: e.target.value === '' ? '' : Number(e.target.value) }))} + style={{ flex: 1, minWidth: 0 }} + /> + )} + {fieldType === 'date' && ( + setCustomFields(prev => ({ ...prev, [key]: e.target.value }))} + style={{ flex: 1, minWidth: 0 }} + /> + )} + +
+ ); + })} +
+ + {addFieldDropdownOpen && ( +
+ {customFieldDefinitions + .filter(d => !(d.name in customFields)) + .map(d => ( + + ))} + {customFieldDefinitions.filter(d => !(d.name in customFields)).length === 0 && ( +
+ No more fields to add +
+ )} +
+ )} +
+
+
diff --git a/milventory/src/components/Master/MasterInventoryTable.js b/milventory/src/components/Master/MasterInventoryTable.js index a8f6c9c..168f74b 100644 --- a/milventory/src/components/Master/MasterInventoryTable.js +++ b/milventory/src/components/Master/MasterInventoryTable.js @@ -2,7 +2,7 @@ import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react' import { useInventory } from '../../context/InventoryContext'; import MasterTableRow from './MasterTableRow'; import MasterCreateModal from './MasterCreateModal'; -import { getCategories } from '../../api'; +import { getCategories, api } from '../../api'; const MasterInventoryTable = () => { const { @@ -35,6 +35,8 @@ const MasterInventoryTable = () => { const [showTeamColumn, setShowTeamColumn] = useState(false); const [showLastModifiedColumn, setShowLastModifiedColumn] = useState(true); const [showColumnMenu, setShowColumnMenu] = useState(false); + const [customFieldDefinitions, setCustomFieldDefinitions] = useState([]); + const [visibleCustomColumns, setVisibleCustomColumns] = useState(new Set()); // Sorting state - default to lastModified ascending (earliest first) const [sortColumn, setSortColumn] = useState('lastModified'); @@ -110,6 +112,22 @@ const MasterInventoryTable = () => { } }, [availableCategories.length]); + // Fetch custom field definitions for column options + useEffect(() => { + api.getCustomFieldDefinitions() + .then(setCustomFieldDefinitions) + .catch(() => setCustomFieldDefinitions([])); + }, []); + + const toggleCustomColumn = useCallback((fieldName) => { + setVisibleCustomColumns(prev => { + const next = new Set(prev); + if (next.has(fieldName)) next.delete(fieldName); + else next.add(fieldName); + return next; + }); + }, []); + const filteredItems = useMemo(() => { const itemsArray = Array.from(masterInventoryItems.entries()); @@ -289,13 +307,34 @@ const MasterInventoryTable = () => { const dateB = itemDataB.lastModified ? new Date(itemDataB.lastModified).getTime() : 0; comparison = dateA - dateB; break; - default: - comparison = 0; + default: { + const customDef = customFieldDefinitions.find(d => d.name === sortColumn); + if (customDef) { + const valA = itemDataA.custom_fields?.[sortColumn]; + const valB = itemDataB.custom_fields?.[sortColumn]; + if (customDef.type === 'number') { + const nA = valA !== undefined && valA !== null && valA !== '' ? Number(valA) : NaN; + const nB = valB !== undefined && valB !== null && valB !== '' ? Number(valB) : NaN; + comparison = (Number.isNaN(nA) ? 1 : 0) - (Number.isNaN(nB) ? 1 : 0) || nA - nB; + } else if (customDef.type === 'date') { + const tA = valA ? new Date(valA).getTime() : 0; + const tB = valB ? new Date(valB).getTime() : 0; + comparison = tA - tB; + } else { + const sA = valA != null ? String(valA) : ''; + const sB = valB != null ? String(valB) : ''; + comparison = sA.localeCompare(sB); + } + } else { + comparison = 0; + } + break; + } } return sortDirection === 'asc' ? comparison : -comparison; }); - }, [filteredItems, sortColumn, sortDirection, quantities, getItemLocations, getItemCategories, getItemTeams]); + }, [filteredItems, sortColumn, sortDirection, quantities, getItemLocations, getItemCategories, getItemTeams, customFieldDefinitions]); const handleRowClick = (itemName) => { setSelectedMasterItem(itemName); @@ -322,6 +361,7 @@ const MasterInventoryTable = () => { setShowCategoryColumn(true); setShowTeamColumn(true); setShowLastModifiedColumn(true); + setVisibleCustomColumns(new Set(customFieldDefinitions.map(d => d.name))); }; const handleHideAllColumns = () => { @@ -330,6 +370,7 @@ const MasterInventoryTable = () => { setShowCategoryColumn(false); setShowTeamColumn(false); setShowLastModifiedColumn(false); + setVisibleCustomColumns(new Set()); }; // Count visible columns (excluding Name which is always visible) @@ -339,7 +380,7 @@ const MasterInventoryTable = () => { showCategoryColumn, showTeamColumn, showLastModifiedColumn - ].filter(Boolean).length; + ].filter(Boolean).length + visibleCustomColumns.size; const SortIcon = ({ column }) => { if (sortColumn !== column) { @@ -747,9 +788,9 @@ const MasterInventoryTable = () => { style={{ padding: '0.4rem', fontSize: '1rem', - background: visibleColumnCount < 5 ? 'var(--accent)' : 'transparent', + background: visibleColumnCount < (5 + customFieldDefinitions.length) ? 'var(--accent)' : 'transparent', border: '1px solid var(--stroke)', - color: visibleColumnCount < 5 ? 'white' : 'var(--text)', + color: visibleColumnCount < (5 + customFieldDefinitions.length) ? 'white' : 'var(--text)', borderRadius: '4px', cursor: 'pointer', display: 'flex', @@ -762,12 +803,12 @@ const MasterInventoryTable = () => { }} title="Show/hide columns" onMouseEnter={(e) => { - if (visibleColumnCount === 5) { + if (visibleColumnCount === 5 + customFieldDefinitions.length) { e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)'; } }} onMouseLeave={(e) => { - if (visibleColumnCount === 5) { + if (visibleColumnCount === 5 + customFieldDefinitions.length) { e.currentTarget.style.background = 'transparent'; } }} @@ -784,7 +825,7 @@ const MasterInventoryTable = () => { > - {visibleColumnCount < 5 && ( + {visibleColumnCount < 5 + customFieldDefinitions.length && ( { fontWeight: 'bold', border: '2px solid var(--panel, #0e1116)' }}> - {5 - visibleColumnCount} + {5 + customFieldDefinitions.length - visibleColumnCount} )} @@ -1046,6 +1087,50 @@ const MasterInventoryTable = () => { /> Last Modified + {customFieldDefinitions.length > 0 && ( + <> +
+ Custom fields +
+ {customFieldDefinitions.map(d => ( + + ))} + + )}
)} @@ -1132,6 +1217,19 @@ const MasterInventoryTable = () => { )} + {customFieldDefinitions.filter(d => visibleCustomColumns.has(d.name)).map(d => ( + handleSort(d.name)} + > + + {d.name} + + + + ))} @@ -1149,6 +1247,8 @@ const MasterInventoryTable = () => { showCategory={showCategoryColumn} showTeam={showTeamColumn} showLastModified={showLastModifiedColumn} + visibleCustomColumns={visibleCustomColumns} + customFieldDefinitions={customFieldDefinitions} isSelected={selectedMasterItem === itemName} onClick={() => handleRowClick(itemName)} /> diff --git a/milventory/src/components/Master/MasterTableRow.js b/milventory/src/components/Master/MasterTableRow.js index c42d355..6e36e27 100644 --- a/milventory/src/components/Master/MasterTableRow.js +++ b/milventory/src/components/Master/MasterTableRow.js @@ -16,7 +16,20 @@ const formatDate = (isoString) => { return `${dateStr}, ${timeStr}`; }; -const MasterTableRow = ({ itemName, itemData, quantity, locations, categories, teams, showQty, showLocation, showCategory, showTeam, showLastModified, isSelected, onClick }) => { +const formatCustomValue = (value, type) => { + if (value === undefined || value === null || value === '') return '—'; + if (type === 'date') { + try { + const d = new Date(value); + return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleDateString(); + } catch { + return String(value); + } + } + return String(value); +}; + +const MasterTableRow = ({ itemName, itemData, quantity, locations, categories, teams, showQty, showLocation, showCategory, showTeam, showLastModified, visibleCustomColumns, customFieldDefinitions, isSelected, onClick }) => { // Build a truncated location string that fits the cell const locationText = locations.length === 0 ? '—' @@ -67,6 +80,11 @@ const MasterTableRow = ({ itemName, itemData, quantity, locations, categories, t {formatDate(itemData.lastModified)} )} + {customFieldDefinitions?.filter(d => visibleCustomColumns?.has(d.name)).map(d => ( + + {formatCustomValue(itemData.custom_fields?.[d.name], d.type)} + + ))} ); }; diff --git a/milventory/src/context/InventoryContext.js b/milventory/src/context/InventoryContext.js index 848b3f5..323dba5 100644 --- a/milventory/src/context/InventoryContext.js +++ b/milventory/src/context/InventoryContext.js @@ -446,6 +446,7 @@ export const InventoryProvider = ({ children }) => { locations: locations, teams: supply.teams || [], categories: supply.categories || [], + custom_fields: supply.custom_fields || {}, lastModified: supply.lastModified || null, last_modified_by: supply.last_modified_by || null, last_modified_by_name: supply.last_modified_by_name || null, @@ -976,7 +977,8 @@ export const InventoryProvider = ({ children }) => { description: item.description || '', image: item.image || null, teams: item.teams || [], - categories: item.categories || [] + categories: item.categories || [], + custom_fields: item.custom_fields && Object.keys(item.custom_fields).length > 0 ? item.custom_fields : undefined }); // Update local state @@ -989,6 +991,7 @@ export const InventoryProvider = ({ children }) => { locations: created.locations || [], teams: created.teams || [], categories: created.categories || [], + custom_fields: created.custom_fields || {}, lastModified: created.lastModified || null, last_modified_by: created.last_modified_by || null, last_modified_by_name: created.last_modified_by_name || null, @@ -1030,7 +1033,8 @@ export const InventoryProvider = ({ children }) => { description: newItem.description || '', image: newItem.image || null, teams: newItem.teams || [], - categories: newItem.categories || [] + categories: newItem.categories || [], + custom_fields: newItem.custom_fields && Object.keys(newItem.custom_fields).length > 0 ? newItem.custom_fields : {} }); // Update local state @@ -1046,6 +1050,7 @@ export const InventoryProvider = ({ children }) => { locations: updated.locations || [], teams: updated.teams || [], categories: updated.categories || [], + custom_fields: updated.custom_fields || {}, lastModified: updated.lastModified || null, last_modified_by: updated.last_modified_by || null, last_modified_by_name: updated.last_modified_by_name || null, diff --git a/milventory/src/index.css b/milventory/src/index.css index ede15e1..c0da087 100644 --- a/milventory/src/index.css +++ b/milventory/src/index.css @@ -441,6 +441,7 @@ body { .modal input[type="text"], .modal input[type="number"], +.modal input[type="date"], .modal textarea, .modal select { background: rgba(0,0,0,.3); @@ -455,6 +456,30 @@ body { font-family: inherit; } +/* Hide number input up/down spinners in modals and elsewhere */ +.modal input[type="number"]::-webkit-inner-spin-button, +.modal input[type="number"]::-webkit-outer-spin-button, +input[type="number"].no-spinner::-webkit-inner-spin-button, +input[type="number"].no-spinner::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} +.modal input[type="number"], +input[type="number"].no-spinner { + -moz-appearance: textfield; + appearance: textfield; +} + +/* Date input: match app style; tone down native calendar icon */ +.modal input[type="date"]::-webkit-calendar-picker-indicator { + filter: invert(0.7); + cursor: pointer; + opacity: 0.8; +} +.modal input[type="date"]::-webkit-datetime-edit { + color: var(--text); +} + .modal select { cursor: pointer; } @@ -464,13 +489,49 @@ body { background: rgba(0,0,0,.4); } +/* Reusable select styling (modals, admin panel, etc.) */ +.styled-select, +select.styled-select { + background: rgba(0,0,0,.3); + border: 1px solid rgba(255,255,255,.1); + color: var(--text); + padding: 0.5rem; + border-radius: 6px; + font-size: 0.85rem; + outline: none; + width: 100%; + box-sizing: border-box; + font-family: inherit; + cursor: pointer; +} +.styled-select:focus, +select.styled-select:focus { + border-color: var(--accent); + background: rgba(0,0,0,.4); +} + .modal input[type="text"]:focus, .modal input[type="number"]:focus, +.modal input[type="date"]:focus, .modal textarea:focus { border-color: var(--accent); background: rgba(0,0,0,.4); } +/* Tag/category dropdown: compact trigger (override .modal input padding) */ +.tag-dropdown-trigger input { + padding: 0 !important; + margin: 0; + border: none !important; + background: transparent !important; + line-height: 1.35; + min-height: 0; +} +.tag-dropdown-trigger input:focus { + border: none !important; + background: transparent !important; +} + .modal textarea { resize: none; min-height: 60px; @@ -797,12 +858,13 @@ svg.overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-e .master-table-content { flex: 1; overflow-y: auto; - overflow-x: hidden; + overflow-x: auto; min-height: 0; } .master-table { width: 100%; + min-width: max-content; border-collapse: collapse; font-size: 0.85rem; } diff --git a/src/api/app.py b/src/api/app.py index f2bef3c..01be39a 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -17,6 +17,7 @@ from src.api.routes.auth import auth_bp from src.api.routes.categories import categories_bp from src.api.routes.teams import teams_bp +from src.api.routes.custom_field_definitions import custom_field_definitions_bp # Import helpers for schema initialization from src.scripts.helpers import ( @@ -42,6 +43,7 @@ app.register_blueprint(auth_bp, url_prefix='/api/auth') app.register_blueprint(categories_bp, url_prefix='/api') app.register_blueprint(teams_bp, url_prefix='/api') +app.register_blueprint(custom_field_definitions_bp, url_prefix='/api/custom-field-definitions') def initialize_schema(): @@ -141,6 +143,11 @@ def initialize_schema(): migrate_locations_schema() except Exception as e: print(f"⚠ Warning: Could not run migrations: {e}") +try: + from src.scripts.migrate_supplies_custom_fields import migrate_supplies_custom_fields + migrate_supplies_custom_fields() +except Exception as e: + print(f"⚠ Warning: Could not run supplies custom_fields migration: {e}") # Seed test user, teams, categories, and locations try: diff --git a/src/api/models/custom_field_definition.py b/src/api/models/custom_field_definition.py new file mode 100644 index 0000000..8699ef6 --- /dev/null +++ b/src/api/models/custom_field_definition.py @@ -0,0 +1,30 @@ +""" +Custom field definition model (admin-defined field types). +""" +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class CustomFieldDefinition: + """Custom field definition: name (display and key), type (text|number|date).""" + id: Optional[int] = None + name: str = "" + type: str = "text" # text, number, date + + @classmethod + def from_db_row(cls, row): + """Create from database row (id, name, type).""" + return cls( + id=row[0], + name=row[1], + type=row[2] + ) + + def to_dict(self): + """Convert to dictionary for JSON response.""" + return { + 'id': self.id, + 'name': self.name, + 'type': self.type + } diff --git a/src/api/routes/custom_field_definitions.py b/src/api/routes/custom_field_definitions.py new file mode 100644 index 0000000..dda085f --- /dev/null +++ b/src/api/routes/custom_field_definitions.py @@ -0,0 +1,178 @@ +""" +Custom field definitions API. Public GET for dropdown; admin CRUD for create/update/delete. +""" +import sys +import json +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from flask import Blueprint, request, jsonify +import mysql.connector +from src.api.db import get_db +from src.api.models.custom_field_definition import CustomFieldDefinition +from src.api.middleware.auth import require_auth, require_leader + +custom_field_definitions_bp = Blueprint('custom_field_definitions', __name__) + +VALID_TYPES = ('text', 'number', 'date') + + +@custom_field_definitions_bp.route('', methods=['GET']) +@require_auth +def get_custom_field_definitions(current_user_id=None): + """ + GET /api/custom-field-definitions + Get all custom field definitions (for dropdown in Create/Edit item modal). + """ + try: + conn = get_db() + cur = conn.cursor() + cur.execute(""" + SELECT id, name, type + FROM custom_field_definitions + ORDER BY name + """) + rows = cur.fetchall() + definitions = [CustomFieldDefinition.from_db_row(row).to_dict() for row in rows] + cur.close() + conn.close() + return jsonify(definitions), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@custom_field_definitions_bp.route('', methods=['POST']) +@require_leader +def create_custom_field_definition(current_user_id=None): + """ + POST /api/custom-field-definitions + Create a new custom field definition. Admin only. + Body: { "name": "Part ID", "type": "text" | "number" | "date" } + """ + try: + data = request.json + if not data: + return jsonify({'error': 'Request body is required'}), 400 + name = (data.get('name') or '').strip() + type_val = (data.get('type') or 'text').strip().lower() + if not name: + return jsonify({'error': 'Name is required'}), 400 + if type_val not in VALID_TYPES: + return jsonify({'error': f'Type must be one of: {", ".join(VALID_TYPES)}'}), 400 + + conn = get_db() + cur = conn.cursor() + cur.execute(""" + INSERT INTO custom_field_definitions (name, type) + VALUES (%s, %s) + """, (name, type_val)) + conn.commit() + definition_id = cur.lastrowid + cur.execute("SELECT id, name, type FROM custom_field_definitions WHERE id = %s", (definition_id,)) + row = cur.fetchone() + cur.close() + conn.close() + return jsonify(CustomFieldDefinition.from_db_row(row).to_dict()), 201 + except mysql.connector.IntegrityError as e: + if 'Duplicate entry' in str(e) or 'unique' in str(e).lower(): + return jsonify({'error': 'A custom field with this name already exists'}), 400 + return jsonify({'error': str(e)}), 400 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@custom_field_definitions_bp.route('/', methods=['PUT']) +@require_leader +def update_custom_field_definition(definition_id, current_user_id=None): + """ + PUT /api/custom-field-definitions/ + Update a custom field definition. Admin only. + """ + try: + data = request.json + if not data: + return jsonify({'error': 'Request body is required'}), 400 + name = (data.get('name') or '').strip() if 'name' in data else None + type_val = (data.get('type') or '').strip().lower() if 'type' in data else None + if type_val is not None and type_val not in VALID_TYPES: + return jsonify({'error': f'Type must be one of: {", ".join(VALID_TYPES)}'}), 400 + + conn = get_db() + cur = conn.cursor() + cur.execute("SELECT id FROM custom_field_definitions WHERE id = %s", (definition_id,)) + if not cur.fetchone(): + cur.close() + conn.close() + return jsonify({'error': 'Custom field definition not found'}), 404 + + updates = [] + values = [] + if 'name' in data and name: + updates.append("name = %s") + values.append(name) + if 'type' in data and type_val: + updates.append("type = %s") + values.append(type_val) + if updates: + values.append(definition_id) + cur.execute( + "UPDATE custom_field_definitions SET " + ", ".join(updates) + " WHERE id = %s", + values + ) + conn.commit() + + cur.execute("SELECT id, name, type FROM custom_field_definitions WHERE id = %s", (definition_id,)) + row = cur.fetchone() + cur.close() + conn.close() + return jsonify(CustomFieldDefinition.from_db_row(row).to_dict()), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@custom_field_definitions_bp.route('/', methods=['DELETE']) +@require_leader +def delete_custom_field_definition(definition_id, current_user_id=None): + """ + DELETE /api/custom-field-definitions/ + Delete a custom field definition and remove that field from all supplies. Admin only. + """ + try: + conn = get_db() + cur = conn.cursor(dictionary=True) + + # Get definition name before deleting (need it to wipe from supplies) + cur.execute("SELECT name FROM custom_field_definitions WHERE id = %s", (definition_id,)) + row = cur.fetchone() + if not row: + cur.close() + conn.close() + return jsonify({'error': 'Custom field definition not found'}), 404 + field_name = row['name'] + + # Delete the definition (commit after we've also wiped supplies) + cur.execute("DELETE FROM custom_field_definitions WHERE id = %s", (definition_id,)) + + # Wipe this field from all supplies' custom_fields + cur.execute("SELECT id, custom_fields FROM supplies WHERE custom_fields IS NOT NULL") + for row in cur.fetchall(): + cf = row['custom_fields'] + if isinstance(cf, str): + try: + cf = json.loads(cf) + except (TypeError, ValueError): + continue + if not isinstance(cf, dict) or field_name not in cf: + continue + del cf[field_name] + new_json = json.dumps(cf) if cf else None + cur.execute("UPDATE supplies SET custom_fields = %s WHERE id = %s", (new_json, row['id'])) + + conn.commit() + + cur.close() + conn.close() + return '', 204 + except Exception as e: + return jsonify({'error': str(e)}), 500 diff --git a/src/api/routes/supplies.py b/src/api/routes/supplies.py index 99c9d78..0bb2b50 100644 --- a/src/api/routes/supplies.py +++ b/src/api/routes/supplies.py @@ -2,6 +2,7 @@ Supply API routes (catalog/reference table). """ import sys +import json from pathlib import Path # Add src to path for imports (must be before other imports) @@ -44,6 +45,7 @@ def get_supplies(current_user_id=None): s.name, s.description, s.image, + s.custom_fields, s.last_order_date, s.last_modified, s.last_modified_by, @@ -51,7 +53,7 @@ def get_supplies(current_user_id=None): COALESCE(SUM(sl.amount), 0) as totalQty FROM supplies s LEFT JOIN supplies_location sl ON s.id = sl.supply_id - GROUP BY s.id, s.name, s.description, s.image, s.last_order_date, s.last_modified, s.last_modified_by, s.created_at + GROUP BY s.id, s.name, s.description, s.image, s.custom_fields, s.last_order_date, s.last_modified, s.last_modified_by, s.created_at ORDER BY s.name """) @@ -91,11 +93,20 @@ def get_supplies(current_user_id=None): """, (row['id'],)) category_ids = [cat_row['category_id'] for cat_row in cur.fetchall()] + cf = row.get('custom_fields') + if isinstance(cf, str) and cf: + try: + cf = json.loads(cf) + except (TypeError, ValueError): + cf = {} + elif cf is None: + cf = {} supply_dict = { 'id': row['id'], 'name': row['name'], 'description': row['description'], 'image': row['image'], + 'custom_fields': cf, 'lastModified': row['last_modified'].isoformat() if row['last_modified'] else None, 'last_modified_by': row['last_modified_by'], 'totalQty': int(row['totalQty']), @@ -150,6 +161,7 @@ def get_supply(supply_id, current_user_id=None): s.name, s.description, s.image, + s.custom_fields, s.last_order_date, s.last_modified, s.last_modified_by, @@ -158,7 +170,7 @@ def get_supply(supply_id, current_user_id=None): FROM supplies s LEFT JOIN supplies_location sl ON s.id = sl.supply_id WHERE s.id = %s - GROUP BY s.id, s.name, s.description, s.image, s.last_order_date, s.last_modified, s.last_modified_by, s.created_at + GROUP BY s.id, s.name, s.description, s.image, s.custom_fields, s.last_order_date, s.last_modified, s.last_modified_by, s.created_at """, (supply_id,)) row = cur.fetchone() @@ -167,6 +179,15 @@ def get_supply(supply_id, current_user_id=None): conn.close() return jsonify({'error': 'Supply not found'}), 404 + cf = row.get('custom_fields') + if isinstance(cf, str) and cf: + try: + cf = json.loads(cf) + except (TypeError, ValueError): + cf = {} + elif cf is None: + cf = {} + # Get locations for this supply cur.execute(""" SELECT location_name, shelf, amount @@ -206,6 +227,7 @@ def get_supply(supply_id, current_user_id=None): 'name': row['name'], 'description': row['description'], 'image': row['image'], + 'custom_fields': cf, 'lastModified': row['last_modified'].isoformat() if row['last_modified'] else None, 'last_modified_by': row['last_modified_by'], 'totalQty': int(row['totalQty']), @@ -235,6 +257,27 @@ def get_supply(supply_id, current_user_id=None): return jsonify({'error': str(e)}), 500 +def _get_allowed_custom_field_names(cur): + """Return set of custom field definition names for validation.""" + try: + cur.execute("SELECT name FROM custom_field_definitions") + return {r['name'] for r in cur.fetchall()} + except Exception: + return set() + + +def _validate_custom_fields(custom_fields, allowed_names): + """Validate custom_fields keys and optionally value types. Returns (ok, error_message).""" + if not custom_fields: + return True, None + if not isinstance(custom_fields, dict): + return False, 'custom_fields must be an object' + for key in custom_fields: + if key not in allowed_names: + return False, f'Unknown custom field: {key}' + return True, None + + @supplies_bp.route('', methods=['POST']) @require_auth def create_supply(current_user_id=None): @@ -273,6 +316,15 @@ def create_supply(current_user_id=None): conn = get_db() cur = conn.cursor(dictionary=True) + # Validate custom_fields if provided + custom_fields = data.get('custom_fields') + allowed = _get_allowed_custom_field_names(cur) + ok, err = _validate_custom_fields(custom_fields, allowed) + if not ok: + cur.close() + conn.close() + return jsonify({'error': err}), 400 + # Check if supply with this name already exists cur.execute("SELECT id FROM supplies WHERE name = %s", (data['name'].strip(),)) if cur.fetchone(): @@ -280,14 +332,16 @@ def create_supply(current_user_id=None): conn.close() return jsonify({'error': 'Supply with this name already exists'}), 400 + cf_json = json.dumps(custom_fields) if custom_fields else None # Insert new supply cur.execute(""" - INSERT INTO supplies (name, description, image, last_order_date, last_modified_by) - VALUES (%s, %s, %s, %s, %s) + INSERT INTO supplies (name, description, image, custom_fields, last_order_date, last_modified_by) + VALUES (%s, %s, %s, %s, %s, %s) """, ( data['name'].strip(), data.get('description', '').strip() or None, data.get('image') or None, + cf_json, data.get('last_order_date') or None, current_user_id )) @@ -363,11 +417,19 @@ def create_supply(current_user_id=None): # Fetch the created supply with teams and categories cur.execute(""" - SELECT id, name, description, image, last_order_date, last_modified, last_modified_by, created_at + SELECT id, name, description, image, custom_fields, last_order_date, last_modified, last_modified_by, created_at FROM supplies WHERE id = %s """, (supply_id,)) row = cur.fetchone() + cf = row.get('custom_fields') + if isinstance(cf, str) and cf: + try: + cf = json.loads(cf) + except (TypeError, ValueError): + cf = {} + else: + cf = cf or {} # Get teams cur.execute(""" @@ -383,6 +445,7 @@ def create_supply(current_user_id=None): # Convert row dict to Supply object supply = Supply.from_dict(row).to_dict() + supply['custom_fields'] = cf supply['totalQty'] = 0 supply['locations'] = [] supply['teams'] = teams @@ -436,6 +499,15 @@ def update_supply(supply_id, current_user_id=None): conn = get_db() cur = conn.cursor(dictionary=True) + # Validate custom_fields if provided + if 'custom_fields' in data: + allowed = _get_allowed_custom_field_names(cur) + ok, err = _validate_custom_fields(data.get('custom_fields'), allowed) + if not ok: + cur.close() + conn.close() + return jsonify({'error': err}), 400 + # Check if supply exists (with conflict detection) cur.execute("SELECT id, name FROM supplies WHERE id = %s", (supply_id,)) supply_check = cur.fetchone() @@ -484,6 +556,10 @@ def update_supply(supply_id, current_user_id=None): if 'last_order_date' in data: updates.append("last_order_date = %s") values.append(data['last_order_date'] or None) + if 'custom_fields' in data: + updates.append("custom_fields = %s") + cf = data.get('custom_fields') + values.append(json.dumps(cf) if cf else None) # Always update last_modified_by updates.append("last_modified_by = %s") @@ -568,12 +644,21 @@ def update_supply(supply_id, current_user_id=None): # Fetch updated supply cur.execute(""" - SELECT id, name, description, image, last_order_date, last_modified, last_modified_by, created_at + SELECT id, name, description, image, custom_fields, last_order_date, last_modified, last_modified_by, created_at FROM supplies WHERE id = %s """, (supply_id,)) row = cur.fetchone() + cf = row.get('custom_fields') + if isinstance(cf, str) and cf: + try: + cf = json.loads(cf) + except (TypeError, ValueError): + cf = {} + else: + cf = cf or {} supply = Supply.from_dict(row).to_dict() + supply['custom_fields'] = cf # Get computed quantities cur.execute(""" diff --git a/src/scripts/migrate_supplies_custom_fields.py b/src/scripts/migrate_supplies_custom_fields.py new file mode 100644 index 0000000..02a1084 --- /dev/null +++ b/src/scripts/migrate_supplies_custom_fields.py @@ -0,0 +1,65 @@ +""" +Migration script to add custom_fields JSON column to supplies table. +Run once to update existing database schema. +""" +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +import mysql.connector +from helpers import parse_database_url + + +def check_column_exists(cur, table_name, column_name): + """Check if a column exists in a table.""" + cur.execute(""" + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = %s + AND COLUMN_NAME = %s + """, (table_name, column_name)) + return cur.fetchone()[0] > 0 + + +def migrate_supplies_custom_fields(): + """Add custom_fields JSON column to supplies if it doesn't exist.""" + try: + database_url = os.getenv("DATABASE_URL", "mysql://mysqluser:mysqlpassword@db:3306/mydb") + db_params = parse_database_url(database_url) + + print("🔄 Migrating supplies table (custom_fields)...") + + conn = mysql.connector.connect(**db_params) + cur = conn.cursor() + + if not check_column_exists(cur, 'supplies', 'custom_fields'): + cur.execute("ALTER TABLE supplies ADD COLUMN custom_fields JSON DEFAULT NULL AFTER image") + conn.commit() + print("✓ Added supplies.custom_fields column") + else: + print("✓ supplies.custom_fields already exists") + + # Drop label from custom_field_definitions if present (use name only) + cur.execute(""" + SELECT COUNT(*) FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'custom_field_definitions' + """) + if cur.fetchone()[0] > 0 and check_column_exists(cur, 'custom_field_definitions', 'label'): + cur.execute("ALTER TABLE custom_field_definitions DROP COLUMN label") + conn.commit() + print("✓ Dropped custom_field_definitions.label column") + + cur.close() + conn.close() + + except mysql.connector.Error as e: + print(f"⚠ Supplies custom_fields migration warning: {e}") + except Exception as e: + print(f"⚠ Supplies custom_fields migration warning: {e}") + + +if __name__ == "__main__": + migrate_supplies_custom_fields() diff --git a/src/sql/custom_field_definitions/table_custom_field_definitions.sql b/src/sql/custom_field_definitions/table_custom_field_definitions.sql new file mode 100644 index 0000000..1e8ca09 --- /dev/null +++ b/src/sql/custom_field_definitions/table_custom_field_definitions.sql @@ -0,0 +1,6 @@ +CREATE TABLE custom_field_definitions ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(200) NOT NULL UNIQUE COMMENT 'display name and key', + type VARCHAR(20) NOT NULL COMMENT 'text, number, or date', + CONSTRAINT chk_type CHECK (type IN ('text', 'number', 'date')) +); From 3bb27d42c6d9c753cf0f19d5bebbebf936b27f5a Mon Sep 17 00:00:00 2001 From: willzoo Date: Mon, 23 Mar 2026 14:16:06 -0400 Subject: [PATCH 46/73] fix custom fields not pushing to db --- .../components/Master/MasterItemPreview.js | 26 ++++++++++++++++++- .../src/components/Master/MasterTableRow.js | 2 +- milventory/src/context/InventoryContext.js | 1 + milventory/src/index.css | 10 +++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/milventory/src/components/Master/MasterItemPreview.js b/milventory/src/components/Master/MasterItemPreview.js index 9a2f478..52d4c75 100644 --- a/milventory/src/components/Master/MasterItemPreview.js +++ b/milventory/src/components/Master/MasterItemPreview.js @@ -1,7 +1,8 @@ import React, { useRef, useState, useEffect } from 'react'; import { useInventory } from '../../context/InventoryContext'; import MasterEditModal from './MasterEditModal'; -import { getCategories } from '../../api'; +import { getCategories, api } from '../../api'; +import { formatCustomValue } from './MasterTableRow'; const MasterItemPreview = () => { const { @@ -29,6 +30,7 @@ const MasterItemPreview = () => { const previewRef = useRef(null); const [editingItem, setEditingItem] = useState(null); const [categoryIdToName, setCategoryIdToName] = useState(new Map()); + const [customFieldDefinitions, setCustomFieldDefinitions] = useState([]); // Fetch category mapping for display useEffect(() => { @@ -45,6 +47,12 @@ const MasterItemPreview = () => { .catch(console.error); }, []); + useEffect(() => { + api.getCustomFieldDefinitions() + .then(data => setCustomFieldDefinitions(Array.isArray(data) ? data : [])) + .catch(() => setCustomFieldDefinitions([])); + }, []); + const item = selectedMasterItem ? resolveMasterItem(selectedMasterItem) : null; const locations = selectedMasterItem ? getItemLocations(selectedMasterItem) : []; @@ -188,6 +196,22 @@ const MasterItemPreview = () => {
)} + {item.custom_fields && Object.keys(item.custom_fields).length > 0 && ( +
+ Custom fields: +
+ {Object.entries(item.custom_fields).map(([fieldName, value]) => { + const def = customFieldDefinitions.find(d => d.name === fieldName); + return ( +
+ {fieldName}:{' '} + {formatCustomValue(value, def?.type || 'text')} +
+ ); + })} +
+
+ )}
Locations: {locationDetails.length === 0 ? ( diff --git a/milventory/src/components/Master/MasterTableRow.js b/milventory/src/components/Master/MasterTableRow.js index 6e36e27..c329a1c 100644 --- a/milventory/src/components/Master/MasterTableRow.js +++ b/milventory/src/components/Master/MasterTableRow.js @@ -16,7 +16,7 @@ const formatDate = (isoString) => { return `${dateStr}, ${timeStr}`; }; -const formatCustomValue = (value, type) => { +export const formatCustomValue = (value, type) => { if (value === undefined || value === null || value === '') return '—'; if (type === 'date') { try { diff --git a/milventory/src/context/InventoryContext.js b/milventory/src/context/InventoryContext.js index 323dba5..0da2bd4 100644 --- a/milventory/src/context/InventoryContext.js +++ b/milventory/src/context/InventoryContext.js @@ -280,6 +280,7 @@ export const InventoryProvider = ({ children }) => { locations: locations, teams: supply.teams || [], categories: supply.categories || [], + custom_fields: supply.custom_fields || {}, lastModified: supply.lastModified || null, last_modified_by: supply.last_modified_by || null, last_modified_by_name: supply.last_modified_by_name || null, diff --git a/milventory/src/index.css b/milventory/src/index.css index c0da087..9419f08 100644 --- a/milventory/src/index.css +++ b/milventory/src/index.css @@ -1072,6 +1072,16 @@ svg.overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-e line-height: 1.5; } +.master-preview-custom-fields strong { + display: block; + margin-bottom: 0.25rem; + color: var(--muted); + font-size: 0.8rem; + font-family: inherit; + text-transform: uppercase; + letter-spacing: 0.5px; +} + .master-preview-locations { margin-top: 0.75rem; } From 2d6bcd4faba84af029da1932058d62cda0eedcc8 Mon Sep 17 00:00:00 2001 From: willzoo Date: Mon, 23 Mar 2026 14:32:06 -0400 Subject: [PATCH 47/73] replace default dialog with custom dialog --- milventory/src/App.js | 3 + .../src/components/Admin/CustomFieldsTable.js | 8 +- .../src/components/Admin/LocationPreview.js | 14 +- .../src/components/Common/BlockingDialog.css | 39 +++++ .../Common/BlockingDialogContext.js | 136 ++++++++++++++++++ .../src/components/History/HistoryTab.js | 4 +- .../src/components/History/HistoryTableRow.js | 4 +- .../components/Master/MasterCreateModal.js | 19 +-- .../src/components/Master/MasterEditModal.js | 19 +-- .../components/Master/MasterItemPreview.js | 9 +- src/api/helpers/history.py | 20 +++ src/api/routes/supplies.py | 12 +- src/api/routes/supplies_location_history.py | 12 +- 13 files changed, 269 insertions(+), 30 deletions(-) create mode 100644 milventory/src/components/Common/BlockingDialog.css create mode 100644 milventory/src/components/Common/BlockingDialogContext.js diff --git a/milventory/src/App.js b/milventory/src/App.js index bd9ca0f..edef6d3 100644 --- a/milventory/src/App.js +++ b/milventory/src/App.js @@ -10,6 +10,7 @@ import AddModePreview from './components/Map/AddModePreview'; import Login from './components/Auth/Login'; import ErrorToast from './components/Common/ErrorToast'; import ConflictErrorModal from './components/Common/ConflictErrorModal'; +import { BlockingDialogProvider } from './components/Common/BlockingDialogContext'; import HistoryModal from './components/History/HistoryModal'; import AdminDashboard from './components/Admin/AdminDashboard'; import { auth } from './api'; @@ -55,6 +56,7 @@ function App() { return ( + } /> + ); } diff --git a/milventory/src/components/Admin/CustomFieldsTable.js b/milventory/src/components/Admin/CustomFieldsTable.js index 25a0217..97a8f72 100644 --- a/milventory/src/components/Admin/CustomFieldsTable.js +++ b/milventory/src/components/Admin/CustomFieldsTable.js @@ -1,9 +1,11 @@ import React, { useState, useEffect } from 'react'; import { admin } from '../../api'; +import { useBlockingDialog } from '../Common/BlockingDialogContext'; const TYPES = ['text', 'number', 'date']; const CustomFieldsTable = () => { + const { showConfirm } = useBlockingDialog(); const [definitions, setDefinitions] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -82,7 +84,11 @@ const CustomFieldsTable = () => { }; const handleDelete = async (id) => { - if (!window.confirm('Delete this custom field definition? This will also remove this field and its value from all items that have it.')) return; + const ok = await showConfirm( + 'Delete this custom field definition? This will also remove this field and its value from all items that have it.', + { title: 'Delete custom field', danger: true, confirmLabel: 'Delete', cancelLabel: 'Cancel' } + ); + if (!ok) return; try { setError(null); await admin.deleteCustomFieldDefinition(id); diff --git a/milventory/src/components/Admin/LocationPreview.js b/milventory/src/components/Admin/LocationPreview.js index 262e241..56090c7 100644 --- a/milventory/src/components/Admin/LocationPreview.js +++ b/milventory/src/components/Admin/LocationPreview.js @@ -1,5 +1,6 @@ import React, { useRef, useState, useEffect, useCallback } from 'react'; import { admin } from '../../api'; +import { useBlockingDialog } from '../Common/BlockingDialogContext'; const LOCATION_TYPES = [ { value: 'drawer', label: 'Drawer' }, @@ -12,6 +13,7 @@ const LOCATION_TYPES = [ ]; const LocationPreview = ({ location, onClose, onDelete, leftPaneWidth, leftPaneCollapsed, onEditStart, onEditEnd, onPreviewUpdate, onEdgeDrag }) => { + const { showAlert, showConfirm } = useBlockingDialog(); const previewRef = useRef(null); const [deleting, setDeleting] = useState(false); const [editing, setEditing] = useState(false); @@ -187,15 +189,17 @@ const LocationPreview = ({ location, onClose, onDelete, leftPaneWidth, leftPaneC if (!location) return; if (isProtected) { - alert( + await showAlert( `This location is protected and is a permanent inventory location. ` + - `To delete it, you must edit the database directly to set protected = FALSE.` + `To delete it, you must edit the database directly to set protected = FALSE.`, + { title: 'Protected location' } ); return; } - const confirmed = window.confirm( - `Are you sure you want to delete "${location.name}"? This action cannot be undone.` + const confirmed = await showConfirm( + `Are you sure you want to delete "${location.name}"? This action cannot be undone.`, + { title: 'Delete location', danger: true, confirmLabel: 'Delete', cancelLabel: 'Cancel' } ); if (!confirmed) return; @@ -213,7 +217,7 @@ const LocationPreview = ({ location, onClose, onDelete, leftPaneWidth, leftPaneC window.location.reload(); }, 300); } catch (err) { - alert(`Failed to delete location: ${err.message}`); + await showAlert(`Failed to delete location: ${err.message}`, { title: 'Error' }); setDeleting(false); } }; diff --git a/milventory/src/components/Common/BlockingDialog.css b/milventory/src/components/Common/BlockingDialog.css new file mode 100644 index 0000000..c9fa239 --- /dev/null +++ b/milventory/src/components/Common/BlockingDialog.css @@ -0,0 +1,39 @@ +.blocking-dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(4px); + z-index: 11000; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + box-sizing: border-box; +} + +.blocking-dialog.modal { + max-width: 420px; + width: 100%; + margin: 0; +} + +.blocking-dialog-message { + margin: 0; + color: var(--text); + font-size: 0.9rem; + line-height: 1.5; + white-space: pre-wrap; +} + +.blocking-dialog .modal-actions { + margin-top: 1rem; +} + +.modal button.blocking-dialog-confirm-danger { + background: #c53030; + color: #fff; +} + +.modal button.blocking-dialog-confirm-danger:hover { + opacity: 0.92; +} diff --git a/milventory/src/components/Common/BlockingDialogContext.js b/milventory/src/components/Common/BlockingDialogContext.js new file mode 100644 index 0000000..cdcae6e --- /dev/null +++ b/milventory/src/components/Common/BlockingDialogContext.js @@ -0,0 +1,136 @@ +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect, +} from 'react'; +import './BlockingDialog.css'; + +const BlockingDialogContext = createContext(null); + +/** + * App-wide blocking modal replacements for alert() / confirm(). + * showAlert(message, { title?, confirmLabel? }) -> Promise + * showConfirm(message, { title?, confirmLabel?, cancelLabel?, danger? }) -> Promise + */ +export function BlockingDialogProvider({ children }) { + const [dialog, setDialog] = useState(null); + + const showAlert = useCallback((message, options = {}) => { + const text = typeof message === 'string' ? message : String(message); + return new Promise((resolve) => { + setDialog({ + kind: 'alert', + heading: options.title != null && options.title !== '' ? options.title : null, + message: text, + confirmLabel: options.confirmLabel || 'OK', + onConfirm: () => { + setDialog(null); + resolve(); + }, + }); + }); + }, []); + + const showConfirm = useCallback((message, options = {}) => { + const text = typeof message === 'string' ? message : String(message); + return new Promise((resolve) => { + setDialog({ + kind: 'confirm', + heading: options.title != null && options.title !== '' ? options.title : 'Confirm', + message: text, + confirmLabel: options.confirmLabel || 'OK', + cancelLabel: options.cancelLabel || 'Cancel', + danger: Boolean(options.danger), + onConfirm: () => { + setDialog(null); + resolve(true); + }, + onCancel: () => { + setDialog(null); + resolve(false); + }, + }); + }); + }, []); + + useEffect(() => { + if (!dialog) return undefined; + const onKey = (e) => { + if (e.key === 'Escape') { + e.stopPropagation(); + if (dialog.kind === 'confirm') dialog.onCancel(); + else dialog.onConfirm(); + } + }; + document.addEventListener('keydown', onKey, true); + return () => document.removeEventListener('keydown', onKey, true); + }, [dialog]); + + const handleOverlayMouseDown = (e) => { + if (e.target !== e.currentTarget) return; + if (dialog.kind === 'confirm') dialog.onCancel(); + else dialog.onConfirm(); + }; + + return ( + + {children} + {dialog && ( +
+
e.stopPropagation()} + > + {dialog.heading && ( +

{dialog.heading}

+ )} +

+ {dialog.message} +

+
+ {dialog.kind === 'confirm' && ( + + )} + +
+
+
+ )} +
+ ); +} + +export function useBlockingDialog() { + const ctx = useContext(BlockingDialogContext); + if (!ctx) { + throw new Error('useBlockingDialog must be used within BlockingDialogProvider'); + } + return ctx; +} diff --git a/milventory/src/components/History/HistoryTab.js b/milventory/src/components/History/HistoryTab.js index 887f330..36b9225 100644 --- a/milventory/src/components/History/HistoryTab.js +++ b/milventory/src/components/History/HistoryTab.js @@ -1,8 +1,10 @@ import React, { useState, useEffect } from 'react'; import { locationHistory } from '../../api'; import { useInventory } from '../../context/InventoryContext'; +import { useBlockingDialog } from '../Common/BlockingDialogContext'; const HistoryTab = () => { + const { showAlert } = useBlockingDialog(); const [history, setHistory] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -41,7 +43,7 @@ const HistoryTab = () => { await reloadSupplyLocations(); } } catch (err) { - alert(err.message || 'Failed to undo action'); + await showAlert(err.message || 'Failed to undo action', { title: 'Undo failed' }); } finally { setUndoing(prev => { const next = new Set(prev); diff --git a/milventory/src/components/History/HistoryTableRow.js b/milventory/src/components/History/HistoryTableRow.js index 3f34149..df91fa6 100644 --- a/milventory/src/components/History/HistoryTableRow.js +++ b/milventory/src/components/History/HistoryTableRow.js @@ -130,7 +130,7 @@ const HistoryTableRow = ({ entry, onUndo, index = 0, isAdmin = false }) => { // Determine if entry can be undone // Note: Undone entries are deleted entirely from the database, so we don't need to check undone status - // For users: only last 5 items can be undone + // For users: only the first row (most recent on this page) can be undone // For admins: all items can be undone const baseCanUndo = entry.historyType === 'location' ? entry.action_type !== 'CASCADED_SUBTRACT' @@ -138,7 +138,7 @@ const HistoryTableRow = ({ entry, onUndo, index = 0, isAdmin = false }) => { const canUndo = isAdmin ? baseCanUndo // Admins can undo all items - : baseCanUndo && index < 5; // Users can only undo last 5 items + : baseCanUndo && index < 1; // Users can only undo the top row return ( diff --git a/milventory/src/components/Master/MasterCreateModal.js b/milventory/src/components/Master/MasterCreateModal.js index 789d869..59c1d77 100644 --- a/milventory/src/components/Master/MasterCreateModal.js +++ b/milventory/src/components/Master/MasterCreateModal.js @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { useInventory } from '../../context/InventoryContext'; import { getCategories, getTeams, api } from '../../api'; +import { useBlockingDialog } from '../Common/BlockingDialogContext'; // Levenshtein distance for fuzzy search const levenshteinDistance = (str1, str2) => { @@ -171,6 +172,7 @@ const TagDropdown = ({ placeholder, selectedItems, availableItems, onSelect, onR const MasterCreateModal = ({ isOpen, onClose }) => { const { createMasterItem, masterInventoryItems } = useInventory(); + const { showAlert } = useBlockingDialog(); const [name, setName] = useState(''); const [description, setDescription] = useState(''); @@ -252,7 +254,7 @@ const MasterCreateModal = ({ isOpen, onClose }) => { } }, [isOpen]); - const handleImageChange = (e) => { + const handleImageChange = async (e) => { const file = e.target.files[0]; if (!file) { setImage(null); @@ -261,13 +263,13 @@ const MasterCreateModal = ({ isOpen, onClose }) => { } if (file.size > 10 * 1024 * 1024) { - alert('Image file size must be less than 10MB'); + await showAlert('Image file size must be less than 10MB'); e.target.value = ''; return; } if (!file.type.startsWith('image/')) { - alert('Please select an image file'); + await showAlert('Please select an image file'); e.target.value = ''; return; } @@ -279,8 +281,9 @@ const MasterCreateModal = ({ isOpen, onClose }) => { setImagePreview(base64Data); }; reader.onerror = () => { - alert('Error reading image file'); - e.target.value = ''; + showAlert('Error reading image file').then(() => { + e.target.value = ''; + }); }; reader.readAsDataURL(file); }; @@ -290,14 +293,14 @@ const MasterCreateModal = ({ isOpen, onClose }) => { setImagePreview(null); }; - const handleSave = () => { + const handleSave = async () => { if (name.trim()) { if (masterInventoryItems.has(name.trim())) { - alert('An item with this name already exists. Please use a different name.'); + await showAlert('An item with this name already exists. Please use a different name.'); return; } if (!areNumberCustomFieldsValid(customFields, customFieldDefinitions)) { - alert('Please enter a valid number in all number fields (or leave them empty).'); + await showAlert('Please enter a valid number in all number fields (or leave them empty).'); return; } diff --git a/milventory/src/components/Master/MasterEditModal.js b/milventory/src/components/Master/MasterEditModal.js index 1c18598..2ae940b 100644 --- a/milventory/src/components/Master/MasterEditModal.js +++ b/milventory/src/components/Master/MasterEditModal.js @@ -1,6 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { useInventory } from '../../context/InventoryContext'; import { getCategories, getTeams, api } from '../../api'; +import { useBlockingDialog } from '../Common/BlockingDialogContext'; // Levenshtein distance for fuzzy search const levenshteinDistance = (str1, str2) => { @@ -166,6 +167,7 @@ const TagDropdown = ({ placeholder, selectedItems, availableItems, onSelect, onR const MasterEditModal = ({ isOpen, onClose, itemName }) => { const { updateMasterItem, resolveMasterItem, masterInventoryItems } = useInventory(); + const { showAlert } = useBlockingDialog(); const [name, setName] = useState(''); const [description, setDescription] = useState(''); @@ -263,7 +265,7 @@ const MasterEditModal = ({ isOpen, onClose, itemName }) => { } }, [isOpen, originalItem, categoryIdToName]); - const handleImageChange = (e) => { + const handleImageChange = async (e) => { const file = e.target.files[0]; if (!file) { // Keep existing image if no new file selected @@ -272,14 +274,14 @@ const MasterEditModal = ({ isOpen, onClose, itemName }) => { // Validate file size (10MB max) if (file.size > 10 * 1024 * 1024) { - alert('Image file size must be less than 10MB'); + await showAlert('Image file size must be less than 10MB'); e.target.value = ''; return; } // Validate file type if (!file.type.startsWith('image/')) { - alert('Please select an image file'); + await showAlert('Please select an image file'); e.target.value = ''; return; } @@ -292,8 +294,9 @@ const MasterEditModal = ({ isOpen, onClose, itemName }) => { setImagePreview(base64Data); }; reader.onerror = () => { - alert('Error reading image file'); - e.target.value = ''; + showAlert('Error reading image file').then(() => { + e.target.value = ''; + }); }; reader.readAsDataURL(file); }; @@ -303,15 +306,15 @@ const MasterEditModal = ({ isOpen, onClose, itemName }) => { setImagePreview(null); }; - const handleSave = () => { + const handleSave = async () => { if (name.trim() && itemName) { // Check if name changed and new name already exists if (name.trim() !== itemName && masterInventoryItems.has(name.trim())) { - alert('An item with this name already exists. Please use a different name.'); + await showAlert('An item with this name already exists. Please use a different name.'); return; } if (!areNumberCustomFieldsValid(customFields, customFieldDefinitions)) { - alert('Please enter a valid number in all number fields (or leave them empty).'); + await showAlert('Please enter a valid number in all number fields (or leave them empty).'); return; } diff --git a/milventory/src/components/Master/MasterItemPreview.js b/milventory/src/components/Master/MasterItemPreview.js index 52d4c75..4cbbb76 100644 --- a/milventory/src/components/Master/MasterItemPreview.js +++ b/milventory/src/components/Master/MasterItemPreview.js @@ -3,8 +3,10 @@ import { useInventory } from '../../context/InventoryContext'; import MasterEditModal from './MasterEditModal'; import { getCategories, api } from '../../api'; import { formatCustomValue } from './MasterTableRow'; +import { useBlockingDialog } from '../Common/BlockingDialogContext'; const MasterItemPreview = () => { + const { showConfirm } = useBlockingDialog(); const { selectedMasterItem, resolveMasterItem, @@ -86,10 +88,11 @@ const MasterItemPreview = () => { const positionX = leftPaneActualWidth + 20; const positionY = 20; - const handleDeleteItem = () => { + const handleDeleteItem = async () => { if (locations.length > 0) { - const confirmed = window.confirm( - `This item is used in ${locations.length} box(es). Delete from all boxes?` + const confirmed = await showConfirm( + `This item is used in ${locations.length} box(es). Delete from all boxes?`, + { title: 'Delete item', danger: true, confirmLabel: 'Delete all', cancelLabel: 'Cancel' } ); if (!confirmed) return; } diff --git a/src/api/helpers/history.py b/src/api/helpers/history.py index c2daaba..aff5f7b 100644 --- a/src/api/helpers/history.py +++ b/src/api/helpers/history.py @@ -265,3 +265,23 @@ def snapshot_supply_locations_before_delete(conn, supply_id, supply_name, change return batch_id # return so caller can attach to the supplies_history row too finally: cur.close() + + +def is_latest_global_history_timestamp(cur, changed_at): + """ + True if changed_at equals the latest timestamp across location + supply history. + Non-leaders may only undo that row; leaders skip this check in routes. + """ + if changed_at is None: + return False + cur.execute(""" + SELECT GREATEST( + COALESCE((SELECT MAX(changed_at) FROM supplies_location_history), '1970-01-01 00:00:00'), + COALESCE((SELECT MAX(changed_at) FROM supplies_history), '1970-01-01 00:00:00') + ) AS latest + """) + row = cur.fetchone() + latest = row['latest'] if row else None + if latest is None: + return False + return changed_at == latest diff --git a/src/api/routes/supplies.py b/src/api/routes/supplies.py index 0bb2b50..dfa5688 100644 --- a/src/api/routes/supplies.py +++ b/src/api/routes/supplies.py @@ -18,7 +18,8 @@ log_team_changes, log_category_changes, get_supply_current_state, - snapshot_supply_locations_before_delete + snapshot_supply_locations_before_delete, + is_latest_global_history_timestamp, ) supplies_bp = Blueprint('supplies', __name__) @@ -962,6 +963,15 @@ def undo_supply_history(history_id, current_user_id=None): conn.close() return jsonify({'error': 'History entry not found'}), 404 + if not session.get('is_leader', False): + if not is_latest_global_history_timestamp(cur, history['changed_at']): + cur.close() + conn.close() + return jsonify({ + 'error': 'Only the most recent action can be undone.', + 'error_type': 'UNDO_NOT_LATEST', + }), 403 + # For DELETE actions, supply_id might be NULL due to ON DELETE SET NULL # We need to find the original supply_id by looking at the old_name and matching # with any existing supplies, or we can try to extract it from the history entry diff --git a/src/api/routes/supplies_location_history.py b/src/api/routes/supplies_location_history.py index 4bbb0cb..4927499 100644 --- a/src/api/routes/supplies_location_history.py +++ b/src/api/routes/supplies_location_history.py @@ -7,10 +7,11 @@ # Add src to path for imports (must be before other imports) sys.path.insert(0, str(Path(__file__).parent.parent.parent)) -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, session import mysql.connector from src.api.db import get_db from src.api.middleware.auth import require_auth +from src.api.helpers.history import is_latest_global_history_timestamp supplies_location_history_bp = Blueprint('supplies_location_history', __name__) @@ -154,6 +155,15 @@ def undo_location_history(history_id, current_user_id=None): conn.close() return jsonify({'error': 'History entry not found'}), 404 + if not session.get('is_leader', False): + if not is_latest_global_history_timestamp(cur, history['changed_at']): + cur.close() + conn.close() + return jsonify({ + 'error': 'Only the most recent action can be undone.', + 'error_type': 'UNDO_NOT_LATEST', + }), 403 + # No need to check undone status - if entry exists, it can be undone action_type = history['action_type'] From 545d1eb027c191f03cf9a489e67e664d2829f9af Mon Sep 17 00:00:00 2001 From: willzoo Date: Mon, 23 Mar 2026 14:34:03 -0400 Subject: [PATCH 48/73] error handling for malformed undos --- milventory/src/api.js | 80 ++++++++++++++++--- .../src/components/History/HistoryModal.js | 36 ++++++++- .../src/components/History/HistoryTab.js | 27 ++++++- src/api/routes/supplies.py | 53 +++++++++++- src/api/routes/supplies_location_history.py | 75 ++++++++++++++++- 5 files changed, 247 insertions(+), 24 deletions(-) diff --git a/milventory/src/api.js b/milventory/src/api.js index 7a29637..f0c37fa 100644 --- a/milventory/src/api.js +++ b/milventory/src/api.js @@ -76,6 +76,23 @@ const authHeaders = () => ({ 'Content-Type': 'application/json', }); +/** Attach status + error_type from JSON error body (for history undo/discard UX). */ +const apiJsonError = (r, data) => { + const error = new Error(data.error || 'Request failed'); + error.response = r; + error.status = r.status; + if (data.error_type != null) error.error_type = data.error_type; + return error; +}; + +/** True when undo failed in a way that allows "remove from history only". */ +export function historyUndoAllowsDiscard(err) { + if (!err || err.error_type == null) return false; + return ['LOCATION_DELETED', 'SUPPLY_DELETED', 'UNDO_IMPOSSIBLE', 'MOVE_PAIR_MISSING'].includes( + err.error_type + ); +} + // Helper to detect and handle conflict errors export const handleApiError = async (error) => { if (error.response) { @@ -267,11 +284,11 @@ export const api = { }); }, - undoSupplyHistory: (historyId) => - fetch(`${API_BASE}/supplies/history/${historyId}/undo`, { - method: 'POST', - credentials: 'include', - headers: authHeaders() + undoSupplyHistory: (historyId) => + fetch(`${API_BASE}/supplies/history/${historyId}/undo`, { + method: 'POST', + credentials: 'include', + headers: authHeaders() }).then(r => { if (r.status === 401) { const error = new Error('Authentication required'); @@ -280,9 +297,26 @@ export const api = { } if (!r.ok) { return r.json().then(data => { - const error = new Error(data.error || 'Request failed'); - error.response = r; - throw error; + throw apiJsonError(r, data); + }); + } + return r.json(); + }), + + discardSupplyHistory: (historyId) => + fetch(`${API_BASE}/supplies/history/${historyId}/discard`, { + method: 'POST', + credentials: 'include', + headers: authHeaders() + }).then(r => { + if (r.status === 401) { + const error = new Error('Authentication required'); + error.response = { status: 401 }; + throw error; + } + if (!r.ok) { + return r.json().then(data => { + throw apiJsonError(r, data); }); } return r.json(); @@ -764,9 +798,33 @@ export const locationHistory = { } if (!r.ok) { return r.json().then(data => { - const error = new Error(data.error || 'Request failed'); - error.response = r; - throw error; + throw apiJsonError(r, data); + }); + } + return r.json(); + }).catch(err => { + if (err instanceof TypeError && err.message.includes('fetch')) { + const networkError = new Error('Network error: Unable to connect to server. Please check if the server is running.'); + networkError.response = { status: 0 }; + throw networkError; + } + throw err; + }), + + discard: (historyId) => + fetch(`${API_BASE}/supplies-location-history/${historyId}/discard`, { + method: 'POST', + credentials: 'include', + headers: authHeaders() + }).then(r => { + if (r.status === 401) { + const error = new Error('Authentication required'); + error.response = { status: 401 }; + throw error; + } + if (!r.ok) { + return r.json().then(data => { + throw apiJsonError(r, data); }); } return r.json(); diff --git a/milventory/src/components/History/HistoryModal.js b/milventory/src/components/History/HistoryModal.js index d0a71da..6ddf98d 100644 --- a/milventory/src/components/History/HistoryModal.js +++ b/milventory/src/components/History/HistoryModal.js @@ -1,10 +1,12 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { api, locationHistory } from '../../api'; +import { api, locationHistory, historyUndoAllowsDiscard } from '../../api'; import { useInventory } from '../../context/InventoryContext'; +import { useBlockingDialog } from '../Common/BlockingDialogContext'; import HistoryTableRow from './HistoryTableRow'; import './HistoryModal.css'; const HistoryModal = ({ isOpen, onClose, isAdmin = false }) => { + const { showConfirm } = useBlockingDialog(); const { reloadMasterItems, reloadSupplyLocations } = useInventory(); const [history, setHistory] = useState([]); const [loading, setLoading] = useState(false); @@ -109,16 +111,42 @@ const HistoryModal = ({ isOpen, onClose, isAdmin = false }) => { await reloadSupplyLocations(); } } else { - await api.undoSupplyHistory(historyId); + await api.undoSupplyHistory(historyId); await reloadMasterItems(); if (reloadSupplyLocations) { await reloadSupplyLocations(); } } - // Reload history after undo await loadHistory(); } catch (err) { - setError(err.message || 'Failed to undo action'); + if (historyUndoAllowsDiscard(err)) { + const remove = await showConfirm( + `${err.message}\n\nRemove this history entry from the log only? Inventory and catalog will stay as they are now.`, + { + title: 'Cannot undo', + confirmLabel: 'Remove from history', + cancelLabel: 'Close', + danger: true + } + ); + if (remove) { + try { + if (historyType === 'location') { + await locationHistory.discard(historyId); + if (reloadSupplyLocations) await reloadSupplyLocations(); + } else { + await api.discardSupplyHistory(historyId); + await reloadMasterItems(); + if (reloadSupplyLocations) await reloadSupplyLocations(); + } + await loadHistory(); + } catch (e2) { + setError(e2.message || 'Failed to remove history entry'); + } + } + } else { + setError(err.message || 'Failed to undo action'); + } } }; diff --git a/milventory/src/components/History/HistoryTab.js b/milventory/src/components/History/HistoryTab.js index 36b9225..e55352b 100644 --- a/milventory/src/components/History/HistoryTab.js +++ b/milventory/src/components/History/HistoryTab.js @@ -1,10 +1,10 @@ import React, { useState, useEffect } from 'react'; -import { locationHistory } from '../../api'; +import { locationHistory, historyUndoAllowsDiscard } from '../../api'; import { useInventory } from '../../context/InventoryContext'; import { useBlockingDialog } from '../Common/BlockingDialogContext'; const HistoryTab = () => { - const { showAlert } = useBlockingDialog(); + const { showAlert, showConfirm } = useBlockingDialog(); const [history, setHistory] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -43,7 +43,28 @@ const HistoryTab = () => { await reloadSupplyLocations(); } } catch (err) { - await showAlert(err.message || 'Failed to undo action', { title: 'Undo failed' }); + if (historyUndoAllowsDiscard(err)) { + const remove = await showConfirm( + `${err.message}\n\nRemove this history entry from the log only? Inventory will stay as it is now.`, + { + title: 'Cannot undo', + confirmLabel: 'Remove from history', + cancelLabel: 'Close', + danger: true + } + ); + if (remove) { + try { + await locationHistory.discard(historyId); + await loadHistory(); + if (reloadSupplyLocations) await reloadSupplyLocations(); + } catch (e2) { + await showAlert(e2.message || 'Failed to remove history entry', { title: 'Error' }); + } + } + } else { + await showAlert(err.message || 'Failed to undo action', { title: 'Undo failed' }); + } } finally { setUndoing(prev => { const next = new Set(prev); diff --git a/src/api/routes/supplies.py b/src/api/routes/supplies.py index dfa5688..fcc7f4c 100644 --- a/src/api/routes/supplies.py +++ b/src/api/routes/supplies.py @@ -1015,14 +1015,20 @@ def undo_supply_history(history_id, current_user_id=None): if not history['supply_id']: cur.close() conn.close() - return jsonify({'error': 'Cannot undo: supply no longer exists'}), 400 + return jsonify({ + 'error': 'Cannot undo: supply no longer exists', + 'error_type': 'UNDO_IMPOSSIBLE', + }), 400 # Check supply still exists cur.execute("SELECT id FROM supplies WHERE id = %s", (history['supply_id'],)) if not cur.fetchone(): cur.close() conn.close() - return jsonify({'error': 'Cannot undo: supply no longer exists'}), 400 + return jsonify({ + 'error': 'Cannot undo: supply no longer exists', + 'error_type': 'UNDO_IMPOSSIBLE', + }), 400 # Restore old values updates = [] @@ -1185,7 +1191,48 @@ def undo_supply_history(history_id, current_user_id=None): return jsonify(response_data), 200 except mysql.connector.IntegrityError as e: conn.rollback() - return jsonify({'error': str(e)}), 400 + return jsonify({'error': str(e), 'error_type': 'UNDO_IMPOSSIBLE'}), 400 except Exception as e: conn.rollback() return jsonify({'error': str(e)}), 500 + + +@supplies_bp.route('/history//discard', methods=['POST']) +@require_auth +def discard_supply_history(history_id, current_user_id=None): + """ + POST /api/supplies/history//discard + Delete a supply history row (and CASCADE team/category rows) without changing supplies data. + Same permission rules as undo for non-leaders. + """ + try: + conn = get_db() + cur = conn.cursor(dictionary=True) + + cur.execute(""" + SELECT id, changed_at FROM supplies_history WHERE id = %s + """, (history_id,)) + row = cur.fetchone() + if not row: + cur.close() + conn.close() + return jsonify({'error': 'History entry not found'}), 404 + + if not session.get('is_leader', False): + if not is_latest_global_history_timestamp(cur, row['changed_at']): + cur.close() + conn.close() + return jsonify({ + 'error': 'Only the most recent action can be undone.', + 'error_type': 'UNDO_NOT_LATEST', + }), 403 + + cur = conn.cursor() + cur.execute("DELETE FROM supplies_history WHERE id = %s", (history_id,)) + conn.commit() + cur.close() + conn.close() + + return jsonify({'success': True, 'discarded_id': history_id}), 200 + except Exception as e: + return jsonify({'error': str(e)}), 500 diff --git a/src/api/routes/supplies_location_history.py b/src/api/routes/supplies_location_history.py index 4927499..a62a239 100644 --- a/src/api/routes/supplies_location_history.py +++ b/src/api/routes/supplies_location_history.py @@ -306,7 +306,10 @@ def undo_location_history(history_id, current_user_id=None): if not paired: cur.close() conn.close() - return jsonify({'error': 'Paired MOVE entry not found'}), 400 + return jsonify({ + 'error': 'Paired MOVE entry not found', + 'error_type': 'MOVE_PAIR_MISSING', + }), 400 # Reverse both legs: undo REMOVE by restoring source, undo ADD by removing from dest if history['action_type'] == 'REMOVE': @@ -368,8 +371,74 @@ def undo_location_history(history_id, current_user_id=None): return jsonify({'success': True, 'deleted_id': history_id}), 200 except mysql.connector.IntegrityError as e: if 'foreign key constraint' in str(e).lower(): - return jsonify({'error': 'Supply or location does not exist'}), 400 - return jsonify({'error': str(e)}), 400 + return jsonify({ + 'error': 'Supply or location does not exist', + 'error_type': 'UNDO_IMPOSSIBLE', + }), 400 + return jsonify({'error': str(e), 'error_type': 'UNDO_IMPOSSIBLE'}), 400 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@supplies_location_history_bp.route('//discard', methods=['POST']) +@require_auth +def discard_location_history(history_id, current_user_id=None): + """ + POST /api/supplies-location-history//discard + Delete a location history row (and paired MOVE leg) without changing inventory. + Same permission rules as undo (non-leaders: only the latest global history timestamp). + """ + try: + conn = get_db() + cur = conn.cursor(dictionary=True) + + cur.execute(""" + SELECT id, action_type, batch_id, changed_at + FROM supplies_location_history + WHERE id = %s + """, (history_id,)) + history = cur.fetchone() + if not history: + cur.close() + conn.close() + return jsonify({'error': 'History entry not found'}), 404 + + if not session.get('is_leader', False): + if not is_latest_global_history_timestamp(cur, history['changed_at']): + cur.close() + conn.close() + return jsonify({ + 'error': 'Only the most recent action can be undone.', + 'error_type': 'UNDO_NOT_LATEST', + }), 403 + + if history['action_type'] == 'CASCADED_SUBTRACT': + cur.close() + conn.close() + return jsonify({ + 'error': 'Cannot discard cascaded subtract entries individually.', + 'error_type': 'CASCADED_SUBTRACT_ENTRY', + }), 400 + + cur = conn.cursor() + paired_id = None + if history['action_type'] == 'MOVE' and history.get('batch_id'): + cur.execute(""" + SELECT id FROM supplies_location_history + WHERE batch_id = %s AND id != %s + """, (history['batch_id'], history_id)) + prow = cur.fetchone() + if prow: + paired_id = prow[0] + + if paired_id is not None: + cur.execute("DELETE FROM supplies_location_history WHERE id = %s", (paired_id,)) + cur.execute("DELETE FROM supplies_location_history WHERE id = %s", (history_id,)) + conn.commit() + cur.close() + conn.close() + + return jsonify({'success': True, 'discarded_id': history_id}), 200 except Exception as e: return jsonify({'error': str(e)}), 500 From c54244796552a99e268aa513ccfe4f82e048caf4 Mon Sep 17 00:00:00 2001 From: willzoo Date: Mon, 23 Mar 2026 14:46:07 -0400 Subject: [PATCH 49/73] fix undo exceptions --- .../src/components/History/HistoryModal.js | 20 ++- .../src/components/History/HistoryTableRow.js | 10 +- src/api/routes/supplies.py | 130 +++++++++--------- 3 files changed, 88 insertions(+), 72 deletions(-) diff --git a/milventory/src/components/History/HistoryModal.js b/milventory/src/components/History/HistoryModal.js index 6ddf98d..668c73b 100644 --- a/milventory/src/components/History/HistoryModal.js +++ b/milventory/src/components/History/HistoryModal.js @@ -103,7 +103,23 @@ const HistoryModal = ({ isOpen, onClose, isAdmin = false }) => { } }; - const handleUndo = async (historyId, historyType) => { + const handleUndo = async (entry) => { + const historyId = entry.id; + const historyType = entry.historyType; + + if (historyType === 'supply' && entry.undo_removes_log_only) { + const ok = await showConfirm( + 'The catalog item for this history line is no longer linked (for example, the item was deleted). This will only remove this row from the history log. Inventory and the catalog will not be changed.', + { + title: 'Remove history entry only', + confirmLabel: 'Remove from history', + cancelLabel: 'Cancel', + danger: true + } + ); + if (!ok) return; + } + try { if (historyType === 'location') { await locationHistory.undo(historyId); @@ -229,7 +245,7 @@ const HistoryModal = ({ isOpen, onClose, isAdmin = false }) => { entry={entry} index={index} isAdmin={isAdmin} - onUndo={(id) => handleUndo(id, entry.historyType)} + onUndo={() => handleUndo(entry)} /> ))} diff --git a/milventory/src/components/History/HistoryTableRow.js b/milventory/src/components/History/HistoryTableRow.js index df91fa6..e084351 100644 --- a/milventory/src/components/History/HistoryTableRow.js +++ b/milventory/src/components/History/HistoryTableRow.js @@ -121,7 +121,7 @@ const HistoryTableRow = ({ entry, onUndo, index = 0, isAdmin = false }) => { const handleUndoClick = () => { if (showConfirm) { - onUndo(entry.id); + onUndo(entry); setShowConfirm(false); } else { setShowConfirm(true); @@ -140,6 +140,12 @@ const HistoryTableRow = ({ entry, onUndo, index = 0, isAdmin = false }) => { ? baseCanUndo // Admins can undo all items : baseCanUndo && index < 1; // Users can only undo the top row + const undoButtonTitle = !canUndo + ? 'Cannot undo' + : entry.historyType === 'supply' && entry.undo_removes_log_only + ? 'Removes this log line only — catalog item is not linked' + : 'Undo this action'; + return ( {formatDate(entry.changed_at)} @@ -185,7 +191,7 @@ const HistoryTableRow = ({ entry, onUndo, index = 0, isAdmin = false }) => { className="history-undo-btn" onClick={handleUndoClick} disabled={!canUndo} - title={!canUndo ? 'Cannot undo' : 'Undo this action'} + title={undoButtonTitle} > Undo diff --git a/src/api/routes/supplies.py b/src/api/routes/supplies.py index fcc7f4c..ea83872 100644 --- a/src/api/routes/supplies.py +++ b/src/api/routes/supplies.py @@ -889,17 +889,20 @@ def get_supply_history(current_user_id=None): category_changes = [{'category_id': c['category_id'], 'action': c['action']} for c in cur.fetchall()] - # Check if can be undone (supply still exists or was deleted) + # Check if can be undone can_undo = False + undo_removes_log_only = False if row['action_type'] == 'DELETE': # DELETE can always be undone (recreate) can_undo = True elif row['action_type'] == 'CREATE': - # CREATE can be undone if supply still exists - can_undo = row['supply_id'] is not None + # CREATE: delete supply if it exists, else drop history row only + can_undo = True + undo_removes_log_only = row['supply_id'] is None elif row['action_type'] == 'UPDATE': - # UPDATE can be undone if supply still exists - can_undo = row['supply_id'] is not None + # UPDATE: restore supply if linked; else drop history row only + can_undo = True + undo_removes_log_only = row['supply_id'] is None history_entry = { 'id': row['id'], @@ -919,6 +922,7 @@ def get_supply_history(current_user_id=None): 'changed_by_email': user['uf_email'] if user else None, 'changed_at': row['changed_at'].isoformat() if row['changed_at'] else None, 'can_undo': can_undo, + 'undo_removes_log_only': undo_removes_log_only, 'team_changes': team_changes, 'category_changes': category_changes } @@ -1011,69 +1015,59 @@ def undo_supply_history(history_id, current_user_id=None): cur.execute("DELETE FROM supplies WHERE id = %s", (history['supply_id'],)) elif history['action_type'] == 'UPDATE': - # Undo UPDATE: Restore old values - if not history['supply_id']: - cur.close() - conn.close() - return jsonify({ - 'error': 'Cannot undo: supply no longer exists', - 'error_type': 'UNDO_IMPOSSIBLE', - }), 400 - - # Check supply still exists - cur.execute("SELECT id FROM supplies WHERE id = %s", (history['supply_id'],)) - if not cur.fetchone(): - cur.close() - conn.close() - return jsonify({ - 'error': 'Cannot undo: supply no longer exists', - 'error_type': 'UNDO_IMPOSSIBLE', - }), 400 - - # Restore old values - updates = [] - values = [] - - if history['old_name']: - updates.append("name = %s") - values.append(history['old_name']) - if history['old_description'] is not None: - updates.append("description = %s") - values.append(history['old_description']) - if history['old_image'] is not None: - updates.append("image = %s") - values.append(history['old_image']) - if history['old_last_order_date'] is not None: - updates.append("last_order_date = %s") - values.append(history['old_last_order_date']) - - updates.append("last_modified_by = %s") - values.append(current_user_id) - values.append(history['supply_id']) - - if updates: - query = f"UPDATE supplies SET {', '.join(updates)} WHERE id = %s" - cur.execute(query, values) - - # Restore teams: Remove current, add back old teams - cur.execute("DELETE FROM supplies_teams WHERE supply_id = %s", (history['supply_id'],)) - for team_change in team_changes: - if team_change['action'] == 'REMOVED': - # This team was removed in the update, so restore it - cur.execute(""" - INSERT IGNORE INTO supplies_teams (supply_id, team_name) - VALUES (%s, %s) - """, (history['supply_id'], team_change['team_name'])) - - # Restore categories: Remove current, add back old categories - cur.execute("DELETE FROM supplies_categories WHERE supply_id = %s", (history['supply_id'],)) - for cat_change in category_changes: - if cat_change['action'] == 'REMOVED': - # This category was removed in the update, so restore it - cur.execute(""" - INSERT IGNORE INTO supplies_categories (supply_id, category_id) - VALUES (%s, %s) - """, (history['supply_id'], cat_change['category_id'])) + # Undo UPDATE: restore catalog row, or only remove this log row if supply_id is NULL + if history['supply_id']: + cur.execute("SELECT id FROM supplies WHERE id = %s", (history['supply_id'],)) + if not cur.fetchone(): + cur.close() + conn.close() + return jsonify({ + 'error': 'Cannot undo: supply no longer exists', + 'error_type': 'UNDO_IMPOSSIBLE', + }), 400 + + # Restore old values + updates = [] + values = [] + + if history['old_name']: + updates.append("name = %s") + values.append(history['old_name']) + if history['old_description'] is not None: + updates.append("description = %s") + values.append(history['old_description']) + if history['old_image'] is not None: + updates.append("image = %s") + values.append(history['old_image']) + if history['old_last_order_date'] is not None: + updates.append("last_order_date = %s") + values.append(history['old_last_order_date']) + + updates.append("last_modified_by = %s") + values.append(current_user_id) + values.append(history['supply_id']) + + if updates: + query = f"UPDATE supplies SET {', '.join(updates)} WHERE id = %s" + cur.execute(query, values) + + # Restore teams: Remove current, add back old teams + cur.execute("DELETE FROM supplies_teams WHERE supply_id = %s", (history['supply_id'],)) + for team_change in team_changes: + if team_change['action'] == 'REMOVED': + cur.execute(""" + INSERT IGNORE INTO supplies_teams (supply_id, team_name) + VALUES (%s, %s) + """, (history['supply_id'], team_change['team_name'])) + + # Restore categories: Remove current, add back old categories + cur.execute("DELETE FROM supplies_categories WHERE supply_id = %s", (history['supply_id'],)) + for cat_change in category_changes: + if cat_change['action'] == 'REMOVED': + cur.execute(""" + INSERT IGNORE INTO supplies_categories (supply_id, category_id) + VALUES (%s, %s) + """, (history['supply_id'], cat_change['category_id'])) elif history['action_type'] == 'DELETE': # Undo DELETE: Recreate the supply From 09eabd1fbf52a9def40b98e24694745a1168d739 Mon Sep 17 00:00:00 2001 From: willzoo Date: Mon, 23 Mar 2026 15:25:19 -0400 Subject: [PATCH 50/73] WIP - Item types --- milventory/src/api.js | 74 ++ .../src/components/Admin/AdminLeftPanel.js | 10 +- .../src/components/Admin/ItemTypesTable.js | 646 ++++++++++++++++++ .../components/Master/MasterCreateModal.js | 202 +++++- .../src/components/Master/MasterEditModal.js | 250 ++++++- .../components/Master/MasterInventoryTable.js | 253 ++++++- .../src/components/Master/MasterTableRow.js | 7 +- milventory/src/context/InventoryContext.js | 19 +- src/api/app.py | 7 + src/api/helpers/unique_type_qty.py | 71 ++ src/api/routes/supplies.py | 208 +++++- src/api/routes/supplies_location.py | 28 + src/api/routes/supplies_location_history.py | 13 + src/api/routes/supply_types.py | 262 +++++++ src/scripts/migrate_supply_types.py | 88 +++ src/sql/supply_types/table_supply_types.sql | 15 + 16 files changed, 2037 insertions(+), 116 deletions(-) create mode 100644 milventory/src/components/Admin/ItemTypesTable.js create mode 100644 src/api/helpers/unique_type_qty.py create mode 100644 src/api/routes/supply_types.py create mode 100644 src/scripts/migrate_supply_types.py create mode 100644 src/sql/supply_types/table_supply_types.sql diff --git a/milventory/src/api.js b/milventory/src/api.js index f0c37fa..daa03ef 100644 --- a/milventory/src/api.js +++ b/milventory/src/api.js @@ -253,6 +253,46 @@ export const api = { return r.json(); }), + getSupplyTypes: () => + fetch(`${API_BASE}/supply-types`, { + credentials: 'include', + headers: authHeaders() + }).then(r => { + if (r.status === 401) { + const error = new Error('Authentication required'); + error.response = { status: 401 }; + throw error; + } + if (!r.ok) { + return r.json().then(data => { + const error = new Error(data.error || 'Request failed'); + error.response = r; + throw error; + }); + } + return r.json(); + }), + + getSupplyType: (id) => + fetch(`${API_BASE}/supply-types/${id}`, { + credentials: 'include', + headers: authHeaders() + }).then(r => { + if (r.status === 401) { + const error = new Error('Authentication required'); + error.response = { status: 401 }; + throw error; + } + if (!r.ok) { + return r.json().then(data => { + const error = new Error(data.error || 'Request failed'); + error.response = r; + throw error; + }); + } + return r.json(); + }), + // History getSupplyHistory: (filters = {}) => { const params = new URLSearchParams(); @@ -732,6 +772,40 @@ export const admin = { if (!r.ok) return r.json().then(d => { throw new Error(d.error || 'Request failed'); }); return r.json(); }), + + createSupplyType: (body) => + fetch(`${API_BASE}/supply-types`, { + method: 'POST', + credentials: 'include', + headers: authHeaders(), + body: JSON.stringify(body) + }).then(r => { + if (r.status === 403) throw new Error('Leader access required'); + if (!r.ok) return r.json().then(d => { throw new Error(d.error || 'Request failed'); }); + return r.json(); + }), + updateSupplyType: (id, body) => + fetch(`${API_BASE}/supply-types/${id}`, { + method: 'PUT', + credentials: 'include', + headers: authHeaders(), + body: JSON.stringify(body) + }).then(r => { + if (r.status === 403) throw new Error('Leader access required'); + if (!r.ok) return r.json().then(d => { throw new Error(d.error || 'Request failed'); }); + return r.json(); + }), + deleteSupplyType: (id) => + fetch(`${API_BASE}/supply-types/${id}`, { + method: 'DELETE', + credentials: 'include', + headers: authHeaders() + }).then(r => { + if (r.status === 403) throw new Error('Leader access required'); + if (r.status === 204) return null; + if (!r.ok) return r.json().then(d => { throw new Error(d.error || 'Request failed'); }); + return r.json(); + }), }; // Location History API diff --git a/milventory/src/components/Admin/AdminLeftPanel.js b/milventory/src/components/Admin/AdminLeftPanel.js index 5a7aa17..c66e10c 100644 --- a/milventory/src/components/Admin/AdminLeftPanel.js +++ b/milventory/src/components/Admin/AdminLeftPanel.js @@ -3,11 +3,12 @@ import { useInventory } from '../../context/InventoryContext'; import InventoryBoxesTable from './InventoryBoxesTable'; import CategoriesTable from './CategoriesTable'; import CustomFieldsTable from './CustomFieldsTable'; +import ItemTypesTable from './ItemTypesTable'; const AdminLeftPanel = ({ selectedLocation, onLocationSelect }) => { const { leftPaneWidth, setLeftPaneWidth, leftPaneCollapsed, setLeftPaneCollapsed } = useInventory(); const [isResizing, setIsResizing] = useState(false); - const [activeTab, setActiveTab] = useState('boxes'); // 'boxes' | 'categories' | 'customfields' + const [activeTab, setActiveTab] = useState('boxes'); // 'boxes' | 'categories' | 'customfields' | 'itemtypes' const leftPaneRef = React.useRef(null); const resizeRef = React.useRef(null); @@ -95,6 +96,12 @@ const AdminLeftPanel = ({ selectedLocation, onLocationSelect }) => { > Custom Fields +
{/* Tab content */} @@ -107,6 +114,7 @@ const AdminLeftPanel = ({ selectedLocation, onLocationSelect }) => { )} {activeTab === 'categories' && } {activeTab === 'customfields' && } + {activeTab === 'itemtypes' && } diff --git a/milventory/src/components/Admin/ItemTypesTable.js b/milventory/src/components/Admin/ItemTypesTable.js new file mode 100644 index 0000000..8c894d6 --- /dev/null +++ b/milventory/src/components/Admin/ItemTypesTable.js @@ -0,0 +1,646 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { admin, api } from '../../api'; +import { useBlockingDialog } from '../Common/BlockingDialogContext'; + +const areNumberCustomFieldsValid = (customFields, customFieldDefinitions) => { + const numberDefs = customFieldDefinitions.filter(d => d.type === 'number'); + for (const d of numberDefs) { + const value = customFields[d.name]; + if (value === undefined || value === null || value === '') continue; + const n = Number(value); + if (Number.isNaN(n) || !Number.isFinite(n)) return false; + } + return true; +}; + +/** Build API payload: every key in typeCustomFields is required; defaults only include nonempty presets. */ +const buildCustomFieldsPayload = (typeCustomFields, definitions) => { + const locked_custom_field_keys = Object.keys(typeCustomFields).sort(); + const default_custom_fields = {}; + for (const k of locked_custom_field_keys) { + const v = typeCustomFields[k]; + const def = definitions.find(d => d.name === k); + const t = def ? def.type : 'text'; + if (t === 'number') { + if (v !== '' && v !== null && v !== undefined) { + const n = Number(v); + if (!Number.isNaN(n) && Number.isFinite(n)) default_custom_fields[k] = n; + } + } else if (v !== '' && v !== null && v !== undefined) { + default_custom_fields[k] = v; + } + } + return { default_custom_fields, locked_custom_field_keys }; +}; + +const typeCustomFieldsFromTypeRow = (t) => { + const locked = Array.isArray(t.locked_custom_field_keys) ? t.locked_custom_field_keys : []; + const defs = t.default_custom_fields || {}; + const cf = {}; + for (const k of locked) { + const raw = Object.prototype.hasOwnProperty.call(defs, k) ? defs[k] : undefined; + if (raw === undefined || raw === null) cf[k] = ''; + else if (typeof raw === 'number') cf[k] = raw; + else cf[k] = String(raw); + } + return cf; +}; + +const emptyForm = () => ({ + name: '', + template_description: '', + item_name_prefix: '', + item_description_prefix: '', + image: null, + typeCustomFields: {}, + is_unique: false +}); + +const TypeFormBody = ({ + form, + setForm, + imageLabel, + onImagePick, + customFieldDefinitions, + addFieldDropdownOpen, + setAddFieldDropdownOpen, + addFieldDropdownRef +}) => ( + <> + setForm((f) => ({ ...f, name: e.target.value }))} + /> +