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:
isPrimaryflag 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
HttpVersionedEntityApifor standard CRUD operations - Symmetric operations (link from either side of the relationship)
- Bulk operations (
linkEquipmentswith array of IDs) - Metadata support (
isPrimaryflag 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
- Many-to-Many Pattern: Use junction table with metadata (
is_primary, audit fields) - Bidirectional Layouts: Provide navigation from both sides of relationship
- Entity Picker: Multi-select dialog for bulk assignment operations
- Confirmation Flows: Always confirm destructive actions (unassign)
- Computed Properties: Add business logic to entity models (
isCalibrationDue) - Visual Indicators: Use color, icons, and badges for status communication
- Hierarchical Data: Support parent-child relationships (area → parent area)
- Audit Metadata: Track who assigned equipment and when
- Reference Fields: Link related entities in forms
- 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.