Add custom fields + warranty plugin, rework settings into two-pane shell

Feature work from the 2026-07 session:

Settings IA
- Replace the flat 27-card settings hub with a persistent two-pane shell
  (SettingsLayout.vue): grouped, searchable left rail + content pane.
- Nest all settings/* routes under the shell via router post-processing;
  shared nav catalog in settingsNav.js. Group by asset class (PCs, Printers,
  Equipment, Network) so per-type settings stop scattering.

Custom fields (core)
- customfields + customfieldvalues tables (migration 7d14), CRUD API at
  /api/customfields, per-asset value get/save.
- Settings management page + reusable CustomFieldsSection (detail) and
  CustomFieldsInputs (form) wired into all four asset types.

Warranty (new plugin)
- plugins/warranty: warranties + warrantyassets (migration 7d15), derived
  coverage status, provider abstraction (manual now; Dell/Lenovo/HP stubs).
- API CRUD + per-asset panel + report buckets; WarrantyPanel on all four
  detail pages; Warranties management page; Warranty report + Reports card.
- Seed warranty.* permissions.

Printer drivers
- printerdrivers table (migration 7d13) linked to printer models; drivers now
  surface on the matching printer's detail page.

Other
- PCDetail rebalanced (Network + Status + Warranty + custom fields on the right).
- Rename PCs list "Features" column to "Remote Access"; fix badge hover underline.
- Drop equipment islocationonly field.
- Centralize asset-type label/route maps into utils/assetTypes.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-09 15:37:21 -04:00
parent 419f26107d
commit 78a0ee8d83
154 changed files with 9479 additions and 1098 deletions

View File

@@ -7,6 +7,8 @@ from shopdb.api import db, Asset, AssetType, Vendor, Model, AuditLog, success_re
from ..models import Equipment, EquipmentType
from shopdb.api import require_permission, require_role
equipment_bp = Blueprint('equipment', __name__)
@@ -54,6 +56,7 @@ def get_equipment_type(type_id: int):
@equipment_bp.route('/types', methods=['POST'])
@jwt_required()
@require_permission('equipment.create')
def create_equipment_type():
"""Create a new equipment type."""
data = request.get_json()
@@ -61,7 +64,15 @@ def create_equipment_type():
if not data or not data.get('equipmenttype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'equipmenttype is required')
if EquipmentType.query.filter_by(equipmenttype=data['equipmenttype']).first():
existing = EquipmentType.query.filter_by(equipmenttype=data['equipmenttype']).first()
if existing:
if not existing.isactive:
existing.isactive = True
for key in ('description', 'icon', 'color'):
if data.get(key) is not None:
setattr(existing, key, data[key])
db.session.commit()
return success_response(existing.to_dict(), message='Reactivated existing type')
return error_response(
ErrorCodes.CONFLICT,
f"Equipment type '{data['equipmenttype']}' already exists",
@@ -71,7 +82,7 @@ def create_equipment_type():
t = EquipmentType(
equipmenttype=data['equipmenttype'],
description=data.get('description'),
icon=data.get('icon')
icon=data.get('icon'), color=data.get('color')
)
db.session.add(t)
@@ -82,6 +93,7 @@ def create_equipment_type():
@equipment_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_permission('equipment.edit')
def update_equipment_type(type_id: int):
"""Update an equipment type."""
t = EquipmentType.query.get(type_id)
@@ -105,7 +117,7 @@ def update_equipment_type(type_id: int):
http_code=409
)
for key in ['equipmenttype', 'description', 'icon', 'isactive']:
for key in ['equipmenttype', 'description', 'icon', 'color', 'isactive']:
if key in data:
setattr(t, key, data[key])
@@ -113,6 +125,23 @@ def update_equipment_type(type_id: int):
return success_response(t.to_dict(), message='Equipment type updated')
@equipment_bp.route('/types/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_permission('equipment.delete')
def delete_equipment_type(type_id: int):
"""Delete an equipment type. Refused if any asset still uses it."""
t = EquipmentType.query.get(type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Equipment type not found', http_code=404)
inuse = Equipment.query.filter_by(equipmenttypeid=type_id).count()
if inuse:
return error_response(ErrorCodes.CONFLICT,
f"Cannot delete: {inuse} asset(s) still use this type", http_code=409)
db.session.delete(t)
db.session.commit()
return success_response(message='Equipment type deleted')
# =============================================================================
# Equipment CRUD
# =============================================================================
@@ -232,6 +261,7 @@ def get_equipment_by_asset(asset_id: int):
@equipment_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('equipment.create')
def create_equipment():
"""
Create new equipment (creates both Asset and Equipment records).
@@ -321,6 +351,7 @@ def create_equipment():
@equipment_bp.route('/<int:equipment_id>', methods=['PUT'])
@jwt_required()
@require_permission('equipment.edit')
def update_equipment(equipment_id: int):
"""Update equipment (both Asset and Equipment records)."""
equip = Equipment.query.get(equipment_id)
@@ -391,6 +422,7 @@ def update_equipment(equipment_id: int):
@equipment_bp.route('/<int:equipment_id>', methods=['DELETE'])
@jwt_required()
@require_permission('equipment.delete')
def delete_equipment(equipment_id: int):
"""Delete (soft delete) equipment."""
equip = Equipment.query.get(equipment_id)