Equipment & Areas

Equipment tracking with many-to-many area assignments and maintenance scheduling

A complete equipment management system demonstrating complex many-to-many relationships, junction tables, and bidirectional navigation.

Overview

This example showcases:

  • Equipment and Area entities with hierarchical relationships
  • Many-to-many relationship via junction table (equipment_area_mapping)
  • Bidirectional layouts (equipment → areas, area → equipment)
  • Primary location designation with metadata
  • Maintenance and calibration tracking
  • Interactive assignment/unassignment workflows

Entity Design

Equipment Entity

// packages/elog_entities/lib/equipment.dart
import 'package:json_annotation/json_annotation.dart';
import 'package:vyuh_entity_system/vyuh_entity_system.dart';

part 'equipment.g.dart';

@JsonSerializable(
  createToJson: true,
  fieldRename: FieldRename.snake,
  includeIfNull: false,
)
class Equipment extends VersionedEntity {
  static const schemaName = 'elog.equipments';

  static final typeDescriptor = TypeDescriptor(
    schemaType: schemaName,
    title: 'Equipment',
    fromJson: Equipment.fromJson,
  );

  // Core fields
  final String code;
  final String? description;
  final String status; // operational, maintenance, out_of_service, calibration
  final DateTime? calibrationDue;
  final String? areaId; // Primary area

  Equipment({
    required super.id,
    required super.name,
    required this.code,
    this.description,
    this.status = 'operational',
    this.calibrationDue,
    this.areaId,
    super.versionNumber = 1,
    super.isActive = true,
    super.createdAt,
    super.updatedAt,
    super.createdBy,
    super.updatedBy,
    super.layout,
    super.modifiers,
  }) : super(schemaType: schemaName);

  factory Equipment.fromJson(Map<String, dynamic> json) =>
      _$EquipmentFromJson(json);

  @override
  Map<String, dynamic> toJson() => _$EquipmentToJson(this);

  // Helper: Check if calibration is due
  bool get isCalibrationDue {
    if (calibrationDue == null) return false;
    return DateTime.now().isAfter(calibrationDue!);
  }
}

Area Entity

// packages/elog_entities/lib/area.dart
@JsonSerializable(
  createToJson: true,
  fieldRename: FieldRename.snake,
  includeIfNull: false,
)
class Area extends VersionedEntity {
  static const schemaName = 'elog.areas';

  static final typeDescriptor = TypeDescriptor(
    schemaType: schemaName,
    title: 'Area',
    fromJson: Area.fromJson,
  );

  // Core fields
  final String code;
  final String? description;
  final String? parentAreaId;
  final int? capacity;

  Area({
    required super.id,
    required super.name,
    required this.code,
    this.description,
    this.parentAreaId,
    this.capacity,
    super.versionNumber = 1,
    super.isActive = true,
    super.createdAt,
    super.updatedAt,
    super.createdBy,
    super.updatedBy,
    super.layout,
    super.modifiers,
  }) : super(schemaType: schemaName);

  factory Area.fromJson(Map<String, dynamic> json) => _$AreaFromJson(json);

  @override
  Map<String, dynamic> toJson() => _$AreaToJson(this);

}

Key Features:

  • Computed property for business logic (isCalibrationDue)
  • Optional hierarchical structure (area → parent area via parentAreaId)
  • Primary area reference in Equipment (areaId)
  • Status enum for equipment states

Many-to-Many Relationship

Mapping Model

// features/feature_elog/lib/models/equipment_area_mapping.dart
@JsonSerializable(createToJson: true, fieldRename: FieldRename.snake)
class EquipmentAreaMapping {
  final String id;
  final String equipmentId;
  final String areaId;
  final bool isPrimary;
  final String? assignedBy;
  final DateTime? assignedAt;

  // Populated entities
  @JsonKey(includeToJson: false)
  final Area? area;

  @JsonKey(includeToJson: false)
  final Equipment? equipment;

  EquipmentAreaMapping({
    required this.id,
    required this.equipmentId,
    required this.areaId,
    this.isPrimary = false,
    this.assignedBy,
    this.assignedAt,
    this.area,
    this.equipment,
  });

  factory EquipmentAreaMapping.fromJson(Map<String, dynamic> json) =>
      _$EquipmentAreaMappingFromJson(json);

  Map<String, dynamic> toJson() => _$EquipmentAreaMappingToJson(this);
}

Mapping Features:

  • isPrimary flag for designating primary location
  • Audit fields (assignedBy, assignedAt)
  • Both entity references populated by API

Layouts

Equipment → Areas Layout

// features/feature_elog/lib/equipment/layouts/details/equipment_areas_layout.dart
class EquipmentAreasLayout extends StatelessWidget {
  final Equipment equipment;

  const EquipmentAreasLayout({required this.equipment});

  @override
  Widget build(BuildContext context) {
    return StandardRelatedEntitiesTab<Equipment, Area>(
      parent: equipment,
      relationSegment: 'areas',
      emptyMessage: 'This equipment is not assigned to any areas',
      addButtonLabel: 'Assign to Area',
      displayConfig: RelatedEntityDisplayConfig(
        cardBuilder: (context, area, mapping) => _buildAreaCard(
          context,
          area,
          mapping['is_primary'] == true,
        ),
      ),
    );
  }

  Widget _buildAreaCard(BuildContext context, Area area, bool isPrimary) {
    return Card(
      child: ListTile(
        leading: Icon(
          Icons.location_on,
          color: isPrimary ? Colors.blue : Colors.grey,
        ),
        title: Text(area.name),
        subtitle: Text(area.code),
        trailing: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            if (isPrimary)
              const Chip(
                label: Text('Primary'),
                backgroundColor: Colors.blue,
                labelStyle: TextStyle(color: Colors.white, fontSize: 12),
              ),
            IconButton(
              icon: const Icon(Icons.remove_circle_outline),
              onPressed: () => _unassignArea(context, area),
            ),
          ],
        ),
        onTap: () => context.go('/areas/${area.id}'),
      ),
    );
  }

  Future<void> _unassignArea(BuildContext context, Area area) async {
    final confirmed = await showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Unassign Area'),
        content: Text('Remove ${equipment.name} from ${area.name}?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, false),
            child: const Text('Cancel'),
          ),
          ElevatedButton(
            onPressed: () => Navigator.pop(context, true),
            child: const Text('Remove'),
          ),
        ],
      ),
    );

    if (confirmed == true) {
      final api = context.read<EquipmentApi>();
      await api.unassignFromArea(equipment.id, area.id);
    }
  }
}

Area → Equipment Layout

// features/feature_elog/lib/area/layouts/details/area_equipment_layout.dart
class AreaEquipmentLayout extends StatelessWidget {
  final Area area;

  const AreaEquipmentLayout({required this.area});

  @override
  Widget build(BuildContext context) {
    return RelatedEntitiesLayout<Area, Equipment>(
      parent: area,
      title: 'Equipment in ${area.name}',
      emptyMessage: 'No equipment assigned to this area',

      // Fetch related equipment
      fetchRelated: (area) async {
        final api = context.read<AreaApi>();
        return api.getLinkedEquipments(area.id);
      },

      // Actions configuration
      actions: RelatedEntitiesActionsConfig(
        // Add equipment to area
        addConfig: EntityPickerConfig<Equipment>(
          title: 'Assign Equipment',
          multiSelect: true,
          onPicked: (context, selectedEquipment) async {
            final api = context.read<AreaApi>();
            final equipmentIds = selectedEquipment.map((e) => e.id).toList();
            await api.linkEquipments(area.id, equipmentIds);
          },
        ),

        // Remove equipment from area
        removeConfig: RelatedRemoveConfig(
          confirmTitle: 'Remove Equipment',
          confirmMessage: (equipment) =>
              'Remove ${equipment.name} from ${area.name}?',
          onRemove: (context, equipment) async {
            final api = context.read<AreaApi>();
            await api.unlinkEquipments(area.id, [equipment.id]);
          },
        ),
      ),

      // Display configuration
      displayConfig: RelatedEntityDisplayConfig(
        layoutType: RelatedLayoutType.grid,
        gridColumns: 3,
        cardBuilder: (context, equipment, _) => EquipmentCard(
          equipment: equipment,
        ),
      ),
    );
  }
}

Layout Features:

  • Bidirectional: Navigate from equipment to areas or vice versa
  • Entity Picker: Multi-select dialog for bulk assignment
  • Confirmation Dialogs: Prevent accidental unassignment
  • Primary Indicator: Visual distinction for primary location
  • Custom Card Builders: Rich display with equipment status, calibration alerts

API Implementation

Area API

// features/feature_elog/lib/area/area_api.dart
class AreaApi extends HttpVersionedEntityApi<Area> {
  AreaApi()
      : super(
          entityType: 'areas',
          schemaType: Area.schemaName,
          pathBuilder: VersionedEndpointBuilder(prefix: 'elog/areas'),
          fromJson: Area.fromJson,
        );

  // Link multiple equipment to area
  Future<void> linkEquipments(String areaId, List<String> equipmentIds) async {
    final uri = pathBuilder.buildUri('/$areaId/equipments/link');
    final response = await vyuh.network.post(
      uri,
      headers: defaultHeaders,
      body: jsonEncode({'equipment_ids': equipmentIds}),
    );

    if (response.statusCode != 200) {
      throw Exception('Failed to link equipment: ${response.statusCode}');
    }
  }

  // Unlink equipment from area
  Future<void> unlinkEquipments(String areaId, List<String> equipmentIds) async {
    final uri = pathBuilder.buildUri('/$areaId/equipments/unlink');
    final response = await vyuh.network.post(
      uri,
      headers: defaultHeaders,
      body: jsonEncode({'equipment_ids': equipmentIds}),
    );

    if (response.statusCode != 200) {
      throw Exception('Failed to unlink equipment: ${response.statusCode}');
    }
  }

  // Get all equipment in area
  Future<List<Equipment>> getLinkedEquipments(String areaId) async {
    final uri = pathBuilder.buildUri('/$areaId/equipments');
    final response = await vyuh.network.get(uri, headers: defaultHeaders);

    if (response.statusCode != 200) {
      throw Exception('Failed to load equipment: ${response.statusCode}');
    }

    final data = jsonDecode(response.body);
    return (data as List)
        .map((json) => Equipment.fromJson(json))
        .toList();
  }
}

Equipment API

// features/feature_elog/lib/equipment/equipment_api.dart
class EquipmentApi extends HttpVersionedEntityApi<Equipment> {
  EquipmentApi()
      : super(
          entityType: 'equipments',
          schemaType: Equipment.schemaName,
          pathBuilder: VersionedEndpointBuilder(prefix: 'elog/equipments'),
          fromJson: Equipment.fromJson,
        );

  // Get all areas where equipment is located (with mapping metadata)
  Future<List<EquipmentAreaMapping>> getAreas(String equipmentId) async {
    final uri = pathBuilder.buildUri('/$equipmentId/areas');
    final response = await vyuh.network.get(uri, headers: defaultHeaders);

    if (response.statusCode != 200) {
      throw Exception('Failed to load areas: ${response.statusCode}');
    }

    final data = jsonDecode(response.body);
    return (data as List)
        .map((json) => EquipmentAreaMapping.fromJson(json))
        .toList();
  }

  // Assign equipment to area
  Future<void> assignToArea(
    String equipmentId,
    String areaId, {
    bool isPrimary = false,
  }) async {
    final uri = pathBuilder.buildUri('/$equipmentId/areas');
    final response = await vyuh.network.post(
      uri,
      headers: defaultHeaders,
      body: jsonEncode({
        'area_id': areaId,
        'is_primary': isPrimary,
      }),
    );

    if (response.statusCode != 200) {
      throw Exception('Failed to assign area: ${response.statusCode}');
    }
  }

  // Remove equipment from area
  Future<void> unassignFromArea(String equipmentId, String areaId) async {
    final uri = pathBuilder.buildUri('/$equipmentId/areas/$areaId');
    final response = await vyuh.network.delete(uri, headers: defaultHeaders);

    if (response.statusCode != 200) {
      throw Exception('Failed to unassign area: ${response.statusCode}');
    }
  }

  // Update primary designation
  Future<void> setPrimaryArea(String equipmentId, String areaId) async {
    final uri = pathBuilder.buildUri('/$equipmentId/areas/$areaId/primary');
    final response = await vyuh.network.put(uri, headers: defaultHeaders);

    if (response.statusCode != 200) {
      throw Exception('Failed to set primary area: ${response.statusCode}');
    }
  }
}

API Features:

  • Extends HttpVersionedEntityApi for standard CRUD operations
  • Symmetric operations (link from either side of the relationship)
  • Bulk operations (linkEquipments with array of IDs)
  • Metadata support (isPrimary flag for primary location designation)
  • Uses pathBuilder.buildUri() to construct endpoint paths
  • Access network via vyuh.network.get/post/delete
  • Proper error handling with status code checks

Note: This assumes API endpoints are available at a base URL (e.g., http://localhost:3000 or your cloud endpoint) that will service these requests.


Equipment Card Component

// features/feature_elog/lib/equipment/widgets/equipment_card.dart
class EquipmentCard extends StatelessWidget {
  final Equipment equipment;

  const EquipmentCard({required this.equipment});

  @override
  Widget build(BuildContext context) {
    return Card(
      child: InkWell(
        onTap: () => context.go('/equipment/${equipment.id}'),
        borderRadius: BorderRadius.circular(12),
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              // Header with icon and status
              Row(
                children: [
                  Icon(
                    _getStatusIcon(equipment.status),
                    size: 32,
                    color: _getStatusColor(equipment.status),
                  ),
                  const Spacer(),
                  _StatusBadge(status: equipment.status),
                ],
              ),

              const SizedBox(height: 12),

              // Equipment name and code
              Text(
                equipment.name,
                style: Theme.of(context).textTheme.titleMedium,
                maxLines: 2,
                overflow: TextOverflow.ellipsis,
              ),
              Text(
                equipment.code,
                style: Theme.of(context).textTheme.bodySmall,
              ),

              const Spacer(),

              // Calibration alert
              if (equipment.calibrationDue != null)
                Row(
                  children: [
                    Icon(
                      Icons.schedule,
                      size: 16,
                      color: equipment.isCalibrationDue
                          ? Colors.red
                          : Colors.orange,
                    ),
                    const SizedBox(width: 4),
                    Text(
                      'Cal: ${_formatDate(equipment.calibrationDue!)}',
                      style: Theme.of(context).textTheme.bodySmall?.copyWith(
                            color: equipment.isCalibrationDue
                                ? Colors.red
                                : Colors.orange,
                          ),
                    ),
                  ],
                ),
            ],
          ),
        ),
      ),
    );
  }

  IconData _getStatusIcon(String status) {
    switch (status) {
      case 'operational':
        return Icons.check_circle;
      case 'maintenance':
        return Icons.build;
      case 'out_of_service':
        return Icons.cancel;
      case 'calibration':
        return Icons.tune;
      default:
        return Icons.help;
    }
  }

  Color _getStatusColor(String status) {
    switch (status) {
      case 'operational':
        return Colors.green;
      case 'maintenance':
        return Colors.orange;
      case 'out_of_service':
        return Colors.red;
      case 'calibration':
        return Colors.blue;
      default:
        return Colors.grey;
    }
  }

  String _formatDate(DateTime date) {
    return DateFormat('MMM d').format(date);
  }
}

Form Configuration

// features/feature_elog/lib/equipment/equipment_config.dart (excerpt)
form: EntityFormDescriptor<Equipment>(
  builder: (context, entity) => vf.Form(
    title: entity == null ? 'Register Equipment' : 'Edit Equipment',
    autovalidateMode: AutovalidateMode.onUserInteraction,
    layout: vf.ColumnFormLayout(columns: 2, spacing: 24.0, runSpacing: 24.0),
    items: [
      // Code (immutable)
      vf.TextField(
        name: 'code',
        label: 'Equipment Code',
        initialValue: entity?.code,
        validators: [vf.requiredValidator],
        enabled: entity == null,
      ),

      // Name
      vf.TextField(
        name: 'name',
        label: 'Equipment Name',
        initialValue: entity?.name,
        validators: [vf.requiredValidator],
      ),

      // Status dropdown
      vf.SelectField<String>(
        name: 'status',
        label: 'Status',
        initialValue: entity?.status ?? 'operational',
        options: [
          vf.SelectOption(value: 'operational', label: 'Operational'),
          vf.SelectOption(value: 'maintenance', label: 'Maintenance'),
          vf.SelectOption(value: 'out_of_service', label: 'Out of Service'),
          vf.SelectOption(value: 'calibration', label: 'Calibration Required'),
        ],
      ),

      // Manufacturer
      vf.TextField(
        name: 'manufacturer',
        label: 'Manufacturer',
        initialValue: entity?.manufacturer,
      ),

      // Model number
      vf.TextField(
        name: 'model_number',
        label: 'Model Number',
        initialValue: entity?.modelNumber,
      ),

      // Serial number
      vf.TextField(
        name: 'serial_number',
        label: 'Serial Number',
        initialValue: entity?.serialNumber,
      ),

      // Primary area (reference field)
      vf.ReferenceField<Area>(
        name: 'area_id',
        label: 'Primary Area',
        initialValue: entity?.areaId,
        provider: EntityReferenceProvider<Area>(
          schemaType: Area.schemaName,
        ),
      ),

      // Calibration due date
      vf.DateTimeField(
        name: 'calibration_due',
        label: 'Calibration Due',
        initialValue: entity?.calibrationDue,
        mode: DateTimeFieldMode.date,
      ),

      // Next maintenance
      vf.DateTimeField(
        name: 'next_maintenance',
        label: 'Next Maintenance',
        initialValue: entity?.nextMaintenance,
      ),
    ],
  ),
)

Key Takeaways

  1. Many-to-Many Pattern: Use junction table with metadata (is_primary, audit fields)
  2. Bidirectional Layouts: Provide navigation from both sides of relationship
  3. Entity Picker: Multi-select dialog for bulk assignment operations
  4. Confirmation Flows: Always confirm destructive actions (unassign)
  5. Computed Properties: Add business logic to entity models (isCalibrationDue)
  6. Visual Indicators: Use color, icons, and badges for status communication
  7. Hierarchical Data: Support parent-child relationships (area → parent area)
  8. Audit Metadata: Track who assigned equipment and when
  9. Reference Fields: Link related entities in forms
  10. Custom Cards: Build rich, domain-specific UI components

This pattern is essential for asset management, resource allocation, and any scenario requiring many-to-many relationships with additional metadata.

On this page