diff --git a/lib/api_form.dart b/lib/api_form.dart index d8268989..13e0c927 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -8,19 +8,17 @@ import "package:flutter/material.dart"; import "package:inventree/api.dart"; import "package:inventree/app_colors.dart"; -import "package:inventree/barcode/barcode.dart"; import "package:inventree/helpers.dart"; import "package:inventree/l10.dart"; - +import "package:inventree/barcode/barcode.dart"; +import "package:inventree/inventree/build.dart"; import "package:inventree/inventree/company.dart"; import "package:inventree/inventree/part.dart"; import "package:inventree/inventree/project_code.dart"; import "package:inventree/inventree/purchase_order.dart"; import "package:inventree/inventree/sales_order.dart"; import "package:inventree/inventree/stock.dart"; - import "package:inventree/inventree/sentry.dart"; - import "package:inventree/widget/dialogs.dart"; import "package:inventree/widget/fields.dart"; import "package:inventree/widget/progress.dart"; @@ -297,9 +295,9 @@ class APIFormField { return _constructBooleanFilter(); case "related field": return _constructRelatedField(); - case "integer": - case "float": case "decimal": + case "float": + case "integer": return _constructFloatField(); case "choice": return _constructChoiceField(); @@ -759,6 +757,30 @@ class APIFormField { ) : null, ); + case InvenTreeBuildOrder.MODEL_TYPE: + var order = InvenTreeBuildOrder.fromJson(data); + + return ListTile( + title: Text(order.reference), + subtitle: Text(order.description), + ); + + case InvenTreePurchaseOrder.MODEL_TYPE: + var order = InvenTreePurchaseOrder.fromJson(data); + + return ListTile( + title: Text(order.reference), + subtitle: Text(order.description), + trailing: Text(order.supplier?.name ?? ""), + ); + case InvenTreeSalesOrder.MODEL_TYPE: + var order = InvenTreeSalesOrder.fromJson(data); + + return ListTile( + title: Text(order.reference), + subtitle: Text(order.description), + trailing: Text(order.customer?.name ?? ""), + ); case InvenTreeSalesOrderShipment.MODEL_TYPE: var shipment = InvenTreeSalesOrderShipment.fromJson(data); @@ -792,27 +814,6 @@ class APIFormField { subtitle: Text(project_code.description), leading: Icon(TablerIcons.list), ); - case InvenTreeSalesOrder.MODEL_TYPE: - var so = InvenTreeSalesOrder.fromJson(data); - return ListTile( - title: Text(so.reference), - subtitle: Text(so.description), - leading: InvenTreeAPI().getThumbnail( - so.customer?.thumbnail ?? so.customer?.image ?? "", - ), - ); - case "labeltemplate": - return ListTile( - title: Text((data["name"] ?? "").toString()), - subtitle: Text((data["description"] ?? "").toString()), - ); - case "pluginconfig": - return ListTile( - title: Text( - (data["meta"]?["human_name"] ?? data["name"] ?? "").toString(), - ), - subtitle: Text((data["meta"]?["description"] ?? "").toString()), - ); default: return ListTile( title: Text( diff --git a/lib/inventree/build.dart b/lib/inventree/build.dart new file mode 100644 index 00000000..a3dda17e --- /dev/null +++ b/lib/inventree/build.dart @@ -0,0 +1,416 @@ +/* + * Models representing build orders + */ + +import "dart:async"; + +import "package:flutter/material.dart"; +import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; + +import "package:inventree/api.dart"; +import "package:inventree/inventree/model.dart"; +import "package:inventree/inventree/orders.dart"; +import "package:inventree/inventree/part.dart"; +import "package:inventree/inventree/stock.dart"; +import "package:inventree/widget/build/build_detail.dart"; + +/* + * Class representing a Build Order + */ +class InvenTreeBuildOrder extends InvenTreeOrder { + InvenTreeBuildOrder() : super(); + + InvenTreeBuildOrder.fromJson(Map json) + : super.fromJson(json); + + @override + InvenTreeModel createFromJson(Map json) => + InvenTreeBuildOrder.fromJson(json); + + // API endpoint URL + @override + String get URL => "build/"; + + // Return the "reference field" for the attachment API + String get REFERENCE_FIELD => "build"; + + // Return the reference field for the modern attachment API + String get REF_MODEL_TYPE => "build"; + + static const String MODEL_TYPE = "build"; + + @override + List get rolesRequired => ["build"]; + + // Return icon for this model + static IconData get icon => TablerIcons.hammer; + + @override + String get description => getString("title"); + + // Part to be built + int get partId => getInt("part"); + + // Part detail information + InvenTreePart? get partDetail { + dynamic part_detail = jsondata["part_detail"]; + + if (part_detail == null) { + return null; + } else { + return InvenTreePart.fromJson(part_detail as Map); + } + } + + // Build quantity + double get quantity => getDouble("quantity"); + + // Completed quantity + double get completed => getDouble("completed"); + + // Progress as a percentage + double get progressPercent { + if (quantity <= 0) return 0.0; + return (completed / quantity * 100).clamp(0.0, 100.0); + } + + // Remaining quantity to be built + double get remaining => quantity - completed; + + // Is the build order a parent build? + bool get isParentBuild => getInt("parent") > 0; + + // Parent build ID + int get parentBuildId => getInt("parent"); + + // External build + bool get external => getBool("external"); + + // Return the location where the items will be sourced from + int? get sourceLocationId => getInt("take_from"); + + // Return the location where the completed items will be stored + int? get destinationId => getInt("destination"); + + String get destinationName => getString("name", subKey: "destination_detail"); + + // Line item information + @override + int get lineItemCount => getInt("item_count", backup: 0); + + // Allocated line item count + int get allocatedLineItemCount => getInt("allocated_line_count", backup: 0); + + // All line items are allocated + bool get areAllLinesAllocated => + lineItemCount > 0 && lineItemCount == allocatedLineItemCount; + + // Output count + int get outputCount => getInt("output_count", backup: 0); + + // Status code handling + // Note: These map to BuildStatus in backend/status_codes.py + bool get isWaitingForBuild => status == BuildOrderStatus.PENDING; + bool get isInProgress => status == BuildOrderStatus.PRODUCTION; + bool get isComplete => status == BuildOrderStatus.COMPLETE; + bool get isCancelled => status == BuildOrderStatus.CANCELLED; + bool get isOnHold => status == BuildOrderStatus.ON_HOLD; + + // Can this build order be completed? + bool get canCompleteOrder { + return isInProgress && outputCount > 0; + } + + // Can this build order be issued? + bool get canIssue { + return isWaitingForBuild || isOnHold; + } + + // Can this build order be put on hold? + bool get canHold { + return isWaitingForBuild || isInProgress; + } + + // Can this build order be cancelled? + bool get canCancel { + return !isComplete && !isCancelled; + } + + // Override form fields + @override + Map> formFields() { + return { + "reference": {"required": true}, + "part": {"required": true}, + "title": {}, + "quantity": {"required": true}, + "priority": {}, + "parent": {}, + "sales_order": {}, + "batch": {}, + "start_date": {}, + "target_date": {}, + "take_from": {}, + "destination": {}, + "project_code": {}, + "link": {}, + "external": {}, + "responsible": {}, + }; + } + + // Issue a build order + Future issue() async { + return await InvenTreeAPI().post( + "${URL}${pk}/issue/", + body: {}, + expectedStatusCode: 201, + ); + } + + // Complete a build order + Future completeOrder({ + bool acceptIncomplete = false, + bool acceptUnallocated = false, + bool acceptOverallocated = false, + }) async { + Map data = { + "accept_incomplete": acceptIncomplete.toString(), + "accept_unallocated": acceptUnallocated.toString(), + "accept_overallocated": acceptOverallocated.toString(), + }; + + return await InvenTreeAPI().post( + "${URL}${pk}/complete/", + body: data, + expectedStatusCode: 201, + ); + } + + // Put a build order on hold + Future hold() async { + return await InvenTreeAPI().post( + "${URL}${pk}/hold/", + body: {}, + expectedStatusCode: 201, + ); + } + + // Cancel a build order + Future cancel() async { + return await InvenTreeAPI().post( + "${URL}${pk}/cancel/", + body: {}, + expectedStatusCode: 201, + ); + } + + // Auto-allocate stock items for this build order + Future autoAllocate() async { + return await InvenTreeAPI().post( + "${URL}${pk}/auto-allocate/", + body: {}, + expectedStatusCode: 201, + ); + } + + // Unallocate all stock from this build order + Future unallocateAll() async { + return await InvenTreeAPI().post( + "${URL}${pk}/unallocate/", + body: {}, + expectedStatusCode: 201, + ); + } + + @override + Future goToDetailPage(BuildContext context) async { + return Navigator.push( + context, + MaterialPageRoute(builder: (context) => BuildOrderDetailWidget(this)), + ); + } +} + +/* + * Class representing a build line + */ +class InvenTreeBuildLine extends InvenTreeOrderLine { + InvenTreeBuildLine() : super(); + + InvenTreeBuildLine.fromJson(Map json) : super.fromJson(json); + + @override + InvenTreeModel createFromJson(Map json) => + InvenTreeBuildLine.fromJson(json); + + // API endpoint URL + @override + String get URL => "build/line/"; + + @override + Map defaultFilters() { + return {"part_detail": "true"}; + } + + @override + List get rolesRequired => ["build"]; + + // Build order reference + int get buildId => getInt("build"); + + // BOM item reference + int get bomItemId => getInt("bom_item"); + + // Required quantity + double get requiredQuantity => getDouble("quantity"); + + // Allocated quantity + double get allocatedQuantity => getDouble("allocated"); + + // Reference the BOM item detail + String get bomReference => getString("reference", subKey: "bom_item_detail"); + + // Is this line fully allocated? + bool get isFullyAllocated { + // Allow for floating point comparison + return allocatedQuantity >= (requiredQuantity - 0.0001); + } + + // Is this line overallocated? + bool get isOverallocated => allocatedQuantity > requiredQuantity; + + // Allocation progress as percentage + double get progressPercent { + if (requiredQuantity <= 0) return 0.0; + return (allocatedQuantity / requiredQuantity * 100).clamp(0.0, 100.0); + } +} + +/* + * Class representing a build item (stock allocation) + */ +class InvenTreeBuildItem extends InvenTreeModel { + InvenTreeBuildItem() : super(); + + InvenTreeBuildItem.fromJson(Map json) : super.fromJson(json); + + @override + InvenTreeModel createFromJson(Map json) => + InvenTreeBuildItem.fromJson(json); + + // API endpoint URL + @override + String get URL => "build/item/"; + + @override + Map defaultFilters() { + return { + "part_detail": "true", + "stock_detail": "true", + "location_detail": "true", + }; + } + + @override + List get rolesRequired => ["build"]; + + // Build line reference + int get buildLineId => getInt("build_line"); + + // Stock item being allocated + int get stockItemId => getInt("stock_item"); + + // Quantity being allocated + double get quantity => getDouble("quantity"); + + // Stock item to install into + int get installIntoId => getInt("install_into"); + + // Stock item detail + InvenTreeStockItem? get stockItem { + dynamic stock_item = jsondata["stock_item_detail"]; + + if (stock_item == null) { + return null; + } else { + return InvenTreeStockItem.fromJson(stock_item as Map); + } + } + + // Part details + String get partName => getString("name", subKey: "part_detail"); + + String get partDescription => getString("description", subKey: "part_detail"); + + String get partThumbnail => getString("thumbnail", subKey: "part_detail"); + + // Allocation details + String get locationName => getString("name", subKey: "location_detail"); + + String get locationPath => getString("pathstring", subKey: "location_detail"); + + String get serialNumber => getString("serial", subKey: "stock_item_detail"); + + String get batchCode => getString("batch", subKey: "stock_item_detail"); + + @override + Map> formFields() { + return { + "build_line": {"required": true, "hidden": true}, + "stock_item": {"required": true}, + "quantity": {"required": true}, + "install_into": {"hidden": true}, + }; + } +} + +/* + * Build Order Status Codes + */ +class BuildOrderStatus { + // Status codes as defined in backend status_codes.py + static const int PENDING = 10; // Build is pending / inactive + static const int PRODUCTION = 20; // Build is active + static const int ON_HOLD = 25; // Build is on hold + static const int CANCELLED = 30; // Build was cancelled + static const int COMPLETE = 40; // Build is complete + + // Return a color based on the build status + static Color getStatusColor(int status) { + switch (status) { + case PENDING: + return Colors.blue; + case PRODUCTION: + return Colors.green; + case COMPLETE: + return Colors.purple; + case CANCELLED: + return Colors.red; + case ON_HOLD: + return Colors.orange; + default: + return Colors.grey; + } + } + + // Return a string based on the build status + static String getStatusText(int status) { + // TODO: This can be pulled from the API + + switch (status) { + case PENDING: + return "Pending"; + case PRODUCTION: + return "In Progress"; + case COMPLETE: + return "Complete"; + case CANCELLED: + return "Cancelled"; + case ON_HOLD: + return "On Hold"; + default: + return "Unknown"; + } + } +} diff --git a/lib/inventree/orders.dart b/lib/inventree/orders.dart index 124ded2a..678a453f 100644 --- a/lib/inventree/orders.dart +++ b/lib/inventree/orders.dart @@ -110,6 +110,8 @@ class InvenTreeOrderLine extends InvenTreeModel { String get partName => getString("name", subKey: "part_detail"); + String get partDescription => getString("description", subKey: "part_detail"); + String get partImage { String img = getString("thumbnail", subKey: "part_detail"); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f139071f..f3bc6152 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -47,9 +47,33 @@ "appDetails": "App Details", "@appDetails": {}, + "allocate": "Allocate", + "@allocate": {}, + + "allocateAuto": "Auto Allocate", + "@allocateAuto": {}, + + "allocateAutoDetail": "Automatically allocate required stock items to this order", + "@allocateAutoDetail": {}, + + "allocateStock": "Allocate Stock", + "@allocateStock": {}, + "allocated": "Allocated", "@allocated": {}, + "allocatedFilterDetail": "Show allocated items", + "@allocatedFilterDetail": {}, + + "allocatedItem": "Allocated Item", + "@allocatedItem": {}, + + "allocatedStock": "Allocated Stock", + "@allocatedStock": {}, + + "allocationEdit": "Edit Allocation", + "@allocationEdit": {}, + "aspectRatio16x9": "16:9", "@aspectRatio16x9": {}, @@ -109,8 +133,8 @@ "attention": "Attention", "@attention": {}, - "available": "Available", - "@available": {}, + "available": "Available", + "@available": {}, "availableStock": "Available Stock", "@availableStock": {}, @@ -220,6 +244,27 @@ "build": "Build", "@build": {}, + "buildOrder": "Build Order", + "@buildOrder": {}, + + "buildOrderCreate": "New Build Order", + "@buildOrderCreate": {}, + + "buildOrders": "Build Orders", + "@buildOrders": {}, + + "buildOrderEdit": "Edit Build Order", + "@buildOrderEdit": {}, + + "buildOrderUnallocateDetail": "Unallocate all stock from this build order", + "@buildOrderUnallocateDetail": {}, + + "buildOutput": "Build Output", + "@buildOutput": {}, + + "buildOutputs": "Build Outputs", + "@buildOutputs": {}, + "building": "Building", "@building": {}, @@ -246,6 +291,9 @@ "cancelOrder": "Cancel Order", "@cancelOrder": {}, + "cancelOrderConfirm": "Are you sure you want to cancel this order?", + "@cancelOrderConfirm": {}, + "category": "Category", "@category": {}, @@ -282,6 +330,12 @@ "completeOrder": "Complete Order", "@completeOrder": {}, + "completeOrderConfirm": "Are you sure you want to complete this order?", + "@completeOrderConfirm": {}, + + "completedFilterDetail": "Show completed items", + "@completedFilterDetail": {}, + "completionDate": "Completion Date", "@completionDate": {}, @@ -310,6 +364,9 @@ "description": "Count Stock" }, + "creationDate": "Creation Date", + "@creationDate": {}, + "credits": "Credits", "@credits": {}, @@ -522,6 +579,12 @@ "filterInStockDetail": "Show parts which have stock", "@filterInStockDetail": {}, + "filterInProduction": "In Production", + "@filterInProduction": {}, + + "filterInProductionDetail": "Show parts in production", + "@filterInProductionDetail": {}, + "filterSerialized": "Serialized", "@filterSerialized": {}, @@ -563,6 +626,15 @@ "description": "history" }, + "hold": "Hold", + "@hold": {}, + + "holdOrder": "Hold Order", + "@holdOrder": {}, + + "holdOrderConfirm": "Are you sure you want to place this order on hold?", + "@holdOrderConfirm": {}, + "home": "Home", "@home": {}, @@ -698,6 +770,9 @@ "issueOrder": "Issue Order", "@issueOrder": {}, + "issueOrderConfirm": "Are you sure you want to issue this order?", + "@issueOrderConfirm": {}, + "itemInLocation": "Item already in location", "@itemInLocation": {}, @@ -761,6 +836,9 @@ "lineItems": "Line Items", "@lineItems": {}, + "lineItemEdit": "Edit Line Item", + "@lineItemEdit": {}, + "lineItemUpdated": "Line item updated", "@lineItemUpdated": {}, @@ -880,7 +958,7 @@ "orientationLandscape": "Landscape", "@orientationLandscape": {}, - + "orientationPortrait": "Portrait", "@orientationPortrait": {}, @@ -1150,6 +1228,9 @@ "reference": "Reference", "@reference": {}, + "referenceNone": "No reference", + "@referenceNone": {}, + "refresh": "Refresh", "@refresh": {}, @@ -1204,6 +1285,9 @@ "description": "This field is required" }, + "requiredParts": "Required Parts", + "@requiredParts": {}, + "response400": "Bad Request", "@response400": {}, @@ -1484,6 +1568,12 @@ "soundOnServerError": "Play audible tone on server error", "@soundOnServerError": {}, + "sourceLocation": "Source Location", + "@sourceLocation": {}, + + "sourceLocationDetail": "Source location for this item", + "@sourceLocationDetail": {}, + "startDate": "Start Date", "@startDate": {}, @@ -1714,6 +1804,15 @@ "translateHelp": "Help translate the InvenTree app", "@translateHelp": {}, + "unallocate": "Unallocate", + "@unallocate": {}, + + "unallocateStock": "Unallocate Stock", + "@unallocateStock": {}, + + "unallocateStockConfirm": "Are you sure you want to unallocate the selected items?", + "@unallocateStockConfirm": {}, + "unavailable": "Unavailable", "@unavailable": {}, @@ -1770,6 +1869,9 @@ "version": "Version", "@version": {}, + "viewDetails": "View Details", + "@viewDetails": {}, + "viewSupplierPart": "View Supplier Part", "@viewSupplierPart": {}, diff --git a/lib/preferences.dart b/lib/preferences.dart index a4bc05d0..c1392183 100644 --- a/lib/preferences.dart +++ b/lib/preferences.dart @@ -11,6 +11,7 @@ const String INV_HOME_SHOW_SUBSCRIBED = "homeShowSubscribed"; const String INV_HOME_SHOW_PO = "homeShowPo"; const String INV_HOME_SHOW_SO = "homeShowSo"; const String INV_HOME_SHOW_SHIPMENTS = "homeShowShipments"; +const String INV_HOME_SHOW_BUILD = "homeShowBuild"; const String INV_HOME_SHOW_MANUFACTURERS = "homeShowManufacturers"; const String INV_HOME_SHOW_CUSTOMERS = "homeShowCustomers"; const String INV_HOME_SHOW_SUPPLIERS = "homeShowSuppliers"; diff --git a/lib/widget/attachment_widget.dart b/lib/widget/attachment_widget.dart index 721909d9..f5899b54 100644 --- a/lib/widget/attachment_widget.dart +++ b/lib/widget/attachment_widget.dart @@ -227,7 +227,7 @@ class _AttachmentWidgetState extends RefreshableState { } } - if (tiles.isEmpty) { + if (tiles.isEmpty && !loading) { tiles.add( ListTile( leading: Icon(TablerIcons.file_x, color: COLOR_WARNING), diff --git a/lib/widget/build/build_detail.dart b/lib/widget/build/build_detail.dart new file mode 100644 index 00000000..5dd2f8cc --- /dev/null +++ b/lib/widget/build/build_detail.dart @@ -0,0 +1,610 @@ +import "package:flutter/material.dart"; +import "package:flutter_speed_dial/flutter_speed_dial.dart"; +import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; + +import "package:inventree/api.dart"; +import "package:inventree/app_colors.dart"; +import "package:inventree/inventree/stock.dart"; +import "package:inventree/l10.dart"; + +import "package:inventree/inventree/build.dart"; +import "package:inventree/inventree/part.dart"; +import "package:inventree/widget/attachment_widget.dart"; + +import "package:inventree/widget/dialogs.dart"; +import "package:inventree/widget/link_icon.dart"; +import "package:inventree/widget/notes_widget.dart"; +import "package:inventree/widget/refreshable_state.dart"; +import "package:inventree/widget/build/build_line_list.dart"; +import "package:inventree/widget/build/build_item_list.dart"; +import "package:inventree/widget/build/build_output_list.dart"; + +/* + * Widget for viewing a single BuildOrder instance + */ +class BuildOrderDetailWidget extends StatefulWidget { + const BuildOrderDetailWidget(this.order, {Key? key}) : super(key: key); + + final InvenTreeBuildOrder order; + + @override + _BuildOrderDetailState createState() => _BuildOrderDetailState(); +} + +class _BuildOrderDetailState extends RefreshableState { + _BuildOrderDetailState(); + + // Track state of the build order + int allocatedLineCount = 0; + int totalLineCount = 0; + int outputCount = 0; + int attachmentCount = 0; + + bool showCameraShortcut = true; + + InvenTreeStockLocation? sourceLocation; + InvenTreeStockLocation? destinationLocation; + + @override + String getAppBarTitle() { + String title = L10().buildOrder; + + if (widget.order.reference.isNotEmpty) { + title += " - ${widget.order.reference}"; + } + + return title; + } + + @override + List appBarActions(BuildContext context) { + List actions = []; + + if (widget.order.canEdit) { + actions.add( + IconButton( + icon: const Icon(TablerIcons.edit), + tooltip: L10().buildOrderEdit, + onPressed: () { + editOrder(context); + }, + ), + ); + } + + return actions; + } + + @override + List actionButtons(BuildContext context) { + List actions = []; + + // Image upload shortcut + if (showCameraShortcut && widget.order.canEdit) { + actions.add( + SpeedDialChild( + child: const Icon(TablerIcons.camera, color: Colors.blue), + label: L10().takePicture, + onTap: () async { + _uploadImage(context); + }, + ), + ); + } + + // Add actions based on current build order state + if (widget.order.canEdit) { + // Issue action (for pending build orders) + if (widget.order.canIssue) { + actions.add( + SpeedDialChild( + child: const Icon(TablerIcons.send, color: Colors.blue), + label: L10().issueOrder, + onTap: () async { + _issueOrder(context); + }, + ), + ); + } + + // Complete action (for in-progress build orders with outputs) + if (widget.order.canCompleteOrder) { + actions.add( + SpeedDialChild( + child: const Icon(TablerIcons.check, color: Colors.green), + label: L10().completeOrder, + onTap: () async { + _completeOrder(context); + }, + ), + ); + } + + // Hold action + if (widget.order.canHold) { + actions.add( + SpeedDialChild( + child: const Icon(TablerIcons.player_pause, color: Colors.orange), + label: L10().holdOrder, + onTap: () async { + _holdOrder(context); + }, + ), + ); + } + + // Auto-allocate action (for in-progress build orders) + if (widget.order.isInProgress) { + actions.add( + SpeedDialChild( + child: const Icon( + TablerIcons.arrow_autofit_down, + color: Colors.purple, + ), + label: L10().allocateAuto, + onTap: () async { + _autoAllocate(context); + }, + ), + ); + } + + // Unallocate action (if there are allocated items) + if (widget.order.isInProgress && + widget.order.allocatedLineItemCount > 0) { + actions.add( + SpeedDialChild( + child: const Icon(TablerIcons.arrow_autofit_up, color: Colors.red), + label: L10().unallocateStock, + onTap: () async { + _unallocateAll(context); + }, + ), + ); + } + + // Cancel action + if (widget.order.canCancel) { + actions.add( + SpeedDialChild( + child: const Icon(TablerIcons.circle_x, color: Colors.red), + label: L10().cancelOrder, + onTap: () async { + _cancelOrder(context); + }, + ), + ); + } + } + + return actions; + } + + /// Upload an image against the current BuildOrder + Future _uploadImage(BuildContext context) async { + // Implement image upload when attachment classes are created + // Placeholder for now + } + + /// Issue this build order + Future _issueOrder(BuildContext context) async { + confirmationDialog( + L10().issueOrder, + L10().issueOrderConfirm, + icon: TablerIcons.send, + color: Colors.blue, + acceptText: L10().issue, + onAccept: () async { + widget.order.issue().then((dynamic) { + refresh(context); + }); + }, + ); + } + + /// Complete this build order + Future _completeOrder(BuildContext context) async { + confirmationDialog( + L10().completeOrder, + L10().completeOrderConfirm, + icon: TablerIcons.check, + color: Colors.green, + acceptText: L10().complete, + onAccept: () async { + widget.order.completeOrder().then((dynamic) { + refresh(context); + }); + }, + ); + } + + /// Hold this build order + Future _holdOrder(BuildContext context) async { + confirmationDialog( + L10().holdOrder, + L10().holdOrderConfirm, + icon: TablerIcons.player_pause, + color: Colors.orange, + acceptText: L10().hold, + onAccept: () async { + widget.order.hold().then((dynamic) { + refresh(context); + }); + }, + ); + } + + /// Cancel this build order + Future _cancelOrder(BuildContext context) async { + confirmationDialog( + L10().cancelOrder, + L10().cancelOrderConfirm, + icon: TablerIcons.circle_x, + color: Colors.red, + acceptText: L10().cancel, + onAccept: () async { + widget.order.cancel().then((dynamic) { + refresh(context); + }); + }, + ); + } + + /// Auto allocate stock items for this build order + Future _autoAllocate(BuildContext context) async { + confirmationDialog( + L10().allocateAuto, + L10().allocateAutoDetail, + icon: TablerIcons.arrow_autofit_down, + color: Colors.purple, + acceptText: L10().allocate, + onAccept: () async { + widget.order.autoAllocate().then((dynamic) { + refresh(context); + }); + }, + ); + } + + /// Unallocate all stock from this build order + Future _unallocateAll(BuildContext context) async { + confirmationDialog( + L10().unallocateStock, + L10().buildOrderUnallocateDetail, + icon: TablerIcons.trash, + color: Colors.orange, + acceptText: L10().unallocate, + onAccept: () async { + widget.order.unallocateAll().then((dynamic) { + refresh(context); + }); + }, + ); + } + + @override + List barcodeButtons(BuildContext context) { + // Build orders don't have barcode functionality yet + return []; + } + + @override + Future request(BuildContext context) async { + super.request(context); + + // Refresh the BuildOrder instance + await widget.order.reload(); + + if (mounted) { + setState(() {}); + } else { + return; + } + + if (widget.order.sourceLocationId != null) { + InvenTreeStockLocation().get(widget.order.sourceLocationId!).then(( + value, + ) { + if (mounted) { + setState(() { + sourceLocation = value as InvenTreeStockLocation?; + }); + } + }); + } else if (mounted) { + setState(() { + sourceLocation = null; + }); + } + + if (widget.order.destinationId != null) { + InvenTreeStockLocation().get(widget.order.destinationId!).then((value) { + if (mounted) { + setState(() { + destinationLocation = value as InvenTreeStockLocation?; + }); + } + }); + } else if (mounted) { + setState(() { + destinationLocation = null; + }); + } + } + + /// Edit this build order + Future editOrder(BuildContext context) async { + if (!widget.order.canEdit) { + return; + } + + var fields = widget.order.formFields(); + + // Cannot edit part field from here + fields.remove("part"); + + widget.order.editForm( + context, + L10().buildOrderEdit, + fields: fields, + onSuccess: (data) async { + refresh(context); + }, + ); + } + + /// Header tile for the build order + Widget headerTile(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + widget.order.reference, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + Text( + BuildOrderStatus.getStatusText(widget.order.status), + style: TextStyle( + color: BuildOrderStatus.getStatusColor(widget.order.status), + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ); + } + + /// Build the list of order detail tiles + List orderTiles(BuildContext context) { + List tiles = []; + + // Header tile + tiles.add(headerTile(context)); + + // Part information + if (widget.order.partDetail != null) { + InvenTreePart part = widget.order.partDetail!; + + tiles.add( + ListTile( + title: Text(part.name), + subtitle: Text(part.description), + leading: part.thumbnail.isNotEmpty + ? SizedBox( + width: 32, + height: 32, + child: InvenTreeAPI().getThumbnail(part.thumbnail), + ) + : const Icon(TablerIcons.box, color: Colors.blue), + onTap: () { + part.goToDetailPage(context); + }, + ), + ); + } + + // Build quantities + tiles.add( + ListTile( + title: Text(L10().quantity), + leading: const Icon(TablerIcons.box), + trailing: Text( + "${widget.order.completed.toInt()} / ${widget.order.quantity.toInt()}", + ), + ), + ); + + // Progress bar + Color progressColor = Colors.blue; + + if (widget.order.isComplete) { + progressColor = Colors.green; + } else if (widget.order.targetDate.isNotEmpty && + DateTime.tryParse(widget.order.targetDate) != null && + DateTime.tryParse(widget.order.targetDate)!.isBefore(DateTime.now())) { + progressColor = Colors.red; + } + + tiles.add( + ListTile( + title: LinearProgressIndicator( + value: widget.order.progressPercent / 100.0, + color: progressColor, + backgroundColor: const Color(0xFFEEEEEE), + ), + leading: const Icon(TablerIcons.chart_bar), + trailing: Text( + "${widget.order.progressPercent.toStringAsFixed(1)}%", + style: TextStyle(color: progressColor, fontWeight: FontWeight.bold), + ), + ), + ); + + // Line items tile + /* + * TODO: Reimplement this item + Color lineColor = Colors.red; + if (widget.order.areAllLinesAllocated) { + lineColor = Colors.green; + } else if (widget.order.allocatedLineItemCount > 0) { + lineColor = Colors.orange; + } + + tiles.add( + ListTile( + title: Text(L10().requiredParts), + subtitle: LinearProgressIndicator( + value: widget.order.lineItemCount > 0 + ? widget.order.allocatedLineItemCount / widget.order.lineItemCount + : 0, + color: lineColor, + ), + leading: const Icon(TablerIcons.clipboard_check), + trailing: Text( + "${widget.order.allocatedLineItemCount} / ${widget.order.lineItemCount}", + style: TextStyle(color: lineColor), + ), + ), + ); + */ + + // Output items + tiles.add( + ListTile( + title: Text(L10().allocatedStock), + leading: Icon(TablerIcons.box_model_2, color: COLOR_ACTION), + trailing: LinkIcon(text: widget.order.outputCount.toString()), + onTap: () => { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PaginatedBuildItemWidget(widget.order), + ), + ), + }, + ), + ); + + // Source location + if (sourceLocation != null) { + tiles.add( + ListTile( + title: Text(L10().sourceLocation), + subtitle: Text(sourceLocation!.pathstring), + leading: Icon(TablerIcons.sitemap, color: COLOR_ACTION), + trailing: LinkIcon(), + onTap: () => sourceLocation!.goToDetailPage(context), + ), + ); + } + + // Destination location + if (destinationLocation != null) { + tiles.add( + ListTile( + title: Text(L10().destination), + subtitle: Text(destinationLocation!.pathstring), + leading: Icon(TablerIcons.sitemap, color: COLOR_ACTION), + trailing: LinkIcon(), + onTap: () => destinationLocation!.goToDetailPage(context), + ), + ); + } + + // Dates + if (widget.order.creationDate.isNotEmpty) { + tiles.add( + ListTile( + title: Text(L10().creationDate), + trailing: Text(widget.order.creationDate), + leading: const Icon(TablerIcons.calendar), + ), + ); + } + + if (widget.order.startDate.isNotEmpty) { + tiles.add( + ListTile( + title: Text(L10().startDate), + trailing: Text(widget.order.startDate), + leading: const Icon(TablerIcons.calendar), + ), + ); + } + + if (widget.order.targetDate.isNotEmpty) { + tiles.add( + ListTile( + title: Text(L10().targetDate), + trailing: Text(widget.order.targetDate), + leading: const Icon(TablerIcons.calendar), + ), + ); + } + + if (widget.order.completionDate.isNotEmpty) { + tiles.add( + ListTile( + title: Text(L10().completionDate), + trailing: Text(widget.order.completionDate), + leading: const Icon(TablerIcons.calendar), + ), + ); + } + + // Notes tile + tiles.add( + ListTile( + title: Text(L10().notes), + leading: Icon(TablerIcons.notes, color: COLOR_ACTION), + trailing: LinkIcon(), + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => NotesWidget(widget.order)), + ); + }, + ), + ); + + // Attachments tile + ListTile? attachmentTile = ShowAttachmentsItem( + context, + InvenTreeBuildOrder.MODEL_TYPE, + widget.order.pk, + widget.order.reference, + attachmentCount, + widget.order.canEdit, + ); + + if (attachmentTile != null) { + tiles.add(attachmentTile); + } + + return tiles; + } + + @override + List getTabIcons(BuildContext context) { + return [ + Tab(text: L10().details), + Tab(text: L10().requiredParts), + // Tab(text: L10().allocatedStock), + Tab(text: L10().buildOutputs), + ]; + } + + @override + List getTabs(BuildContext context) { + return [ + ListView(children: orderTiles(context)), + PaginatedBuildLineList({"build": widget.order.pk.toString()}), + // PaginatedBuildItemList({"build": widget.order.pk.toString()}), + PaginatedBuildOutputList({"build": widget.order.pk.toString()}), + ]; + } +} diff --git a/lib/widget/build/build_item_detail.dart b/lib/widget/build/build_item_detail.dart new file mode 100644 index 00000000..4e60c3c7 --- /dev/null +++ b/lib/widget/build/build_item_detail.dart @@ -0,0 +1,218 @@ +import "package:flutter/material.dart"; +import "package:flutter_speed_dial/flutter_speed_dial.dart"; +import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; + +import "package:inventree/api.dart"; +import "package:inventree/app_colors.dart"; +import "package:inventree/l10.dart"; + +import "package:inventree/inventree/build.dart"; +import "package:inventree/inventree/stock.dart"; + +import "package:inventree/widget/progress.dart"; +import "package:inventree/widget/refreshable_state.dart"; +import "package:inventree/widget/snacks.dart"; +import "package:inventree/widget/dialogs.dart"; + +/* + * Widget for displaying detail view of a single BuildItem (stock allocation) +*/ +class BuildItemDetailWidget extends StatefulWidget { + const BuildItemDetailWidget(this.item, {Key? key}) : super(key: key); + + final InvenTreeBuildItem item; + + @override + _BuildItemDetailWidgetState createState() => _BuildItemDetailWidgetState(); +} + +/* + * State for the BuildItemDetailWidget + */ +class _BuildItemDetailWidgetState + extends RefreshableState { + _BuildItemDetailWidgetState(); + + @override + String getAppBarTitle() => L10().allocatedItem; + + @override + List appBarActions(BuildContext context) { + List actions = []; + + actions.add( + IconButton( + icon: const Icon(TablerIcons.edit), + tooltip: L10().allocationEdit, + onPressed: () { + _editAllocation(context); + }, + ), + ); + + return actions; + } + + @override + List actionButtons(BuildContext context) { + List buttons = []; + + // Unallocate button + buttons.add( + SpeedDialChild( + child: const Icon(TablerIcons.minus, color: Colors.red), + label: L10().unallocate, + onTap: () async { + _unallocateStock(context); + }, + ), + ); + + return buttons; + } + + @override + Future request(BuildContext context) async { + await widget.item.reload(); + } + + // Edit this allocation + Future _editAllocation(BuildContext context) async { + var fields = widget.item.formFields(); + + fields["stock_item"]?["hidden"] = true; + + widget.item.editForm( + context, + L10().allocationEdit, + fields: fields, + onSuccess: (data) async { + refresh(context); + showSnackIcon(L10().itemUpdated, success: true); + }, + ); + } + + // Deallocate this stock item + Future _unallocateStock(BuildContext context) async { + confirmationDialog( + L10().unallocateStock, + L10().unallocateStockConfirm, + icon: TablerIcons.minus, + color: Colors.red, + acceptText: L10().unallocate, + onAccept: () async { + widget.item.delete().then((result) { + if (result) { + showSnackIcon(L10().stockItemUpdated, success: true); + Navigator.pop(context); + } else { + showSnackIcon(L10().error); + } + }); + }, + ); + } + + // Go to stock item detail page + Future _viewStockItem(BuildContext context) async { + if (widget.item.stockItem != null) { + widget.item.stockItem!.goToDetailPage(context); + } + } + + @override + List getTiles(BuildContext context) { + List tiles = []; + + // Stock item information + if (widget.item.stockItem != null) { + tiles.add( + ListTile( + title: Text(L10().stockItem), + subtitle: Text(widget.item.partName), + leading: widget.item.partThumbnail.isNotEmpty + ? SizedBox( + width: 32, + height: 32, + child: InvenTreeAPI().getThumbnail(widget.item.partThumbnail), + ) + : Icon(TablerIcons.box, color: COLOR_ACTION), + trailing: Icon(TablerIcons.chevron_right, color: COLOR_ACTION), + onTap: () { + _viewStockItem(context); + }, + ), + ); + + // Location information + tiles.add( + ListTile( + title: Text(L10().stockLocation), + subtitle: Text( + widget.item.locationName.isNotEmpty + ? widget.item.locationPath + : L10().locationNotSet, + ), + leading: const Icon(TablerIcons.map_pin), + ), + ); + + // Serial number if available + if (widget.item.serialNumber.isNotEmpty) { + tiles.add( + ListTile( + title: Text(L10().serialNumber), + subtitle: Text(widget.item.serialNumber), + leading: const Icon(TablerIcons.hash), + ), + ); + } + + // Batch code if available + if (widget.item.batchCode.isNotEmpty) { + tiles.add( + ListTile( + title: Text(L10().batchCode), + subtitle: Text(widget.item.batchCode), + leading: const Icon(TablerIcons.barcode), + ), + ); + } + } + + // Quantity allocated + tiles.add( + ListTile( + title: Text(L10().quantity), + subtitle: Text(widget.item.quantity.toString()), + leading: const Icon(TablerIcons.list), + ), + ); + + // Install into (if specified) + if (widget.item.installIntoId > 0) { + tiles.add( + ListTile( + title: Text(L10().buildOutput), + subtitle: Text(L10().viewDetails), + leading: const Icon(TablerIcons.arrow_right), + trailing: const Icon(TablerIcons.chevron_right), + onTap: () async { + showLoadingOverlay(); + var stockItem = await InvenTreeStockItem().get( + widget.item.installIntoId, + ); + hideLoadingOverlay(); + + if (stockItem is InvenTreeStockItem) { + stockItem.goToDetailPage(context); + } + }, + ), + ); + } + + return tiles; + } +} diff --git a/lib/widget/build/build_item_list.dart b/lib/widget/build/build_item_list.dart new file mode 100644 index 00000000..f299849e --- /dev/null +++ b/lib/widget/build/build_item_list.dart @@ -0,0 +1,136 @@ +import "package:flutter/material.dart"; + +import "package:inventree/api.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/inventree/build.dart"; +import "package:inventree/inventree/model.dart"; +import "package:inventree/widget/paginator.dart"; +import "package:inventree/widget/build/build_item_detail.dart"; +import "package:inventree/widget/progress.dart"; +import "package:inventree/widget/refreshable_state.dart"; + +class PaginatedBuildItemWidget extends StatefulWidget { + const PaginatedBuildItemWidget(this.build, {Key? key}) : super(key: key); + + final InvenTreeBuildOrder build; + + @override + _PaginatedBuildItemWidgetState createState() => + _PaginatedBuildItemWidgetState(); +} + +class _PaginatedBuildItemWidgetState + extends RefreshableState { + _PaginatedBuildItemWidgetState(); + + @override + String getAppBarTitle() { + return L10().allocatedStock; + } + + @override + Widget getBody(BuildContext context) { + Map filters = {"build": widget.build.pk.toString()}; + + return Column( + children: [ + ListTile( + leading: InvenTreeAPI().getThumbnail( + widget.build.partDetail!.thumbnail, + ), + title: Text(widget.build.reference), + subtitle: Text(L10().allocatedStock), + ), + Divider(thickness: 1.25), + Expanded(child: PaginatedBuildItemList(filters)), + ], + ); + } +} + +/* + * Paginated widget class for displaying a list of build order item allocations + */ +class PaginatedBuildItemList extends PaginatedSearchWidget { + const PaginatedBuildItemList(Map filters) + : super(filters: filters); + + @override + String get searchTitle => L10().allocatedStock; + + @override + _PaginatedBuildItemListState createState() => _PaginatedBuildItemListState(); +} + +/* + * State class for PaginatedBuildItemList +*/ +class _PaginatedBuildItemListState + extends PaginatedSearchState { + _PaginatedBuildItemListState() : super(); + + @override + String get prefix => "build_item_"; + + @override + Map get orderingOptions => { + "stock_item": L10().stockItem, + "quantity": L10().quantity, + }; + + @override + Future requestPage( + int limit, + int offset, + Map params, + ) async { + params["part_detail"] = "true"; + + final page = await InvenTreeBuildItem().listPaginated( + limit, + offset, + filters: params, + ); + return page; + } + + @override + Widget buildItem(BuildContext context, InvenTreeModel model) { + InvenTreeBuildItem item = model as InvenTreeBuildItem; + + // Format the serialized data + String info = ""; + + // Show serial number if available + if (item.serialNumber.isNotEmpty) { + info = "${L10().serialNumber}: ${item.serialNumber}"; + } + // Show batch code if available + else if (item.batchCode.isNotEmpty) { + info = "${L10().batchCode}: ${item.batchCode}"; + } + // Otherwise show location + else if (item.locationName.isNotEmpty) { + info = item.locationPath; + } + + return ListTile( + title: Text(item.partName), + subtitle: Text(info), + trailing: Text( + item.quantity.toString(), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + leading: InvenTreeAPI().getThumbnail(item.partThumbnail), + onTap: () async { + showLoadingOverlay(); + await item.reload(); + hideLoadingOverlay(); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => BuildItemDetailWidget(item)), + ); + }, + ); + } +} diff --git a/lib/widget/build/build_line_detail.dart b/lib/widget/build/build_line_detail.dart new file mode 100644 index 00000000..76054134 --- /dev/null +++ b/lib/widget/build/build_line_detail.dart @@ -0,0 +1,136 @@ +import "package:flutter/material.dart"; +import "package:flutter_speed_dial/flutter_speed_dial.dart"; +import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; + +import "package:inventree/api.dart"; +import "package:inventree/app_colors.dart"; +import "package:inventree/l10.dart"; + +import "package:inventree/inventree/part.dart"; +import "package:inventree/inventree/build.dart"; + +import "package:inventree/widget/progress.dart"; +import "package:inventree/widget/refreshable_state.dart"; + +/* + * Widget for displaying detail view of a single BuildOrderLineItem +*/ +class BuildLineDetailWidget extends StatefulWidget { + const BuildLineDetailWidget(this.item, {Key? key}) : super(key: key); + + final InvenTreeBuildLine item; + + @override + _BuildLineDetailWidgetState createState() => _BuildLineDetailWidgetState(); +} + +/* + * State for the BuildLineDetailWidget + */ +class _BuildLineDetailWidgetState + extends RefreshableState { + _BuildLineDetailWidgetState(); + + @override + String getAppBarTitle() => L10().lineItem; + + @override + List appBarActions(BuildContext context) { + List actions = []; + + return actions; + } + + @override + List actionButtons(BuildContext context) { + // Currently, no action buttons are needed as allocation/deallocation + // is done at the build order level instead of individual line level + return []; + } + + @override + Future request(BuildContext context) async { + await widget.item.reload(); + } + + @override + List getTiles(BuildContext context) { + List tiles = []; + + // Reference to the part + tiles.add( + ListTile( + title: Text(L10().part), + subtitle: Text(widget.item.partName), + leading: + widget.item.part != null && widget.item.part!.thumbnail.isNotEmpty + ? SizedBox( + width: 32, + height: 32, + child: InvenTreeAPI().getThumbnail(widget.item.part!.thumbnail), + ) + : Icon(TablerIcons.box, color: COLOR_ACTION), + trailing: const Icon(TablerIcons.chevron_right), + onTap: () async { + showLoadingOverlay(); + var part = await InvenTreePart().get(widget.item.partId); + hideLoadingOverlay(); + + if (part is InvenTreePart) { + part.goToDetailPage(context); + } + }, + ), + ); + + // Required quantity + tiles.add( + ListTile( + title: Text(L10().quantity), + subtitle: Text(widget.item.requiredQuantity.toString()), + leading: const Icon(TablerIcons.list), + ), + ); + + // Allocated quantity + tiles.add( + ListTile( + title: Text(L10().allocated), + subtitle: ProgressBar( + widget.item.allocatedQuantity / widget.item.requiredQuantity, + ), + trailing: Text( + "${widget.item.allocatedQuantity.toInt()} / ${widget.item.requiredQuantity.toInt()}", + style: TextStyle( + color: widget.item.isFullyAllocated ? COLOR_SUCCESS : COLOR_WARNING, + ), + ), + leading: const Icon(TablerIcons.progress), + ), + ); + + // Reference + if (widget.item.reference.isNotEmpty) { + tiles.add( + ListTile( + title: Text(L10().reference), + subtitle: Text(widget.item.reference), + leading: const Icon(TablerIcons.hash), + ), + ); + } + + // Notes + if (widget.item.notes.isNotEmpty) { + tiles.add( + ListTile( + title: Text(L10().notes), + subtitle: Text(widget.item.notes), + leading: const Icon(TablerIcons.note), + ), + ); + } + + return tiles; + } +} diff --git a/lib/widget/build/build_line_list.dart b/lib/widget/build/build_line_list.dart new file mode 100644 index 00000000..397b07b9 --- /dev/null +++ b/lib/widget/build/build_line_list.dart @@ -0,0 +1,115 @@ +import "package:flutter/material.dart"; +import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; +import "package:inventree/api.dart"; + +import "package:inventree/app_colors.dart"; +import "package:inventree/l10.dart"; + +import "package:inventree/inventree/model.dart"; +import "package:inventree/inventree/build.dart"; + +import "package:inventree/widget/paginator.dart"; +import "package:inventree/widget/build/build_line_detail.dart"; +import "package:inventree/widget/progress.dart"; + +/* + * Paginated widget class for displaying a list of build order line items + */ +class PaginatedBuildLineList extends PaginatedSearchWidget { + const PaginatedBuildLineList(Map filters) + : super(filters: filters); + + @override + String get searchTitle => L10().requiredParts; + + @override + _PaginatedBuildLineListState createState() => _PaginatedBuildLineListState(); +} + +/* + * State class for PaginatedBuildLineList +*/ +class _PaginatedBuildLineListState + extends PaginatedSearchState { + _PaginatedBuildLineListState() : super(); + + @override + String get prefix => "build_line_"; + + @override + Map get orderingOptions => { + "part": L10().part, + "reference": L10().reference, + "quantity": L10().quantity, + }; + + @override + Map> get filterOptions => { + "allocated": { + "label": L10().allocated, + "help_text": L10().allocatedFilterDetail, + "tristate": true, + }, + "completed": { + "label": L10().complete, + "help_text": L10().completedFilterDetail, + "tristate": true, + }, + }; + + @override + Future requestPage( + int limit, + int offset, + Map params, + ) async { + final page = await InvenTreeBuildLine().listPaginated( + limit, + offset, + filters: params, + ); + return page; + } + + @override + Widget buildItem(BuildContext context, InvenTreeModel model) { + InvenTreeBuildLine item = model as InvenTreeBuildLine; + + // Calculate allocation progress + double progress = 0; + if (item.requiredQuantity > 0) { + progress = item.allocatedQuantity / item.requiredQuantity; + } + + // Clamp to valid range + progress = progress.clamp(0, 1); + + return ListTile( + title: Text(item.partName), + subtitle: Text(item.partDescription), + trailing: Text( + "${item.allocatedQuantity.toInt()} / ${item.requiredQuantity.toInt()}", + style: TextStyle( + color: progress >= 1 ? COLOR_SUCCESS : COLOR_WARNING, + fontWeight: FontWeight.bold, + ), + ), + leading: item.part != null && item.part!.thumbnail.isNotEmpty + ? SizedBox( + width: 32, + height: 32, + child: InvenTreeAPI().getThumbnail(item.part!.thumbnail), + ) + : const Icon(TablerIcons.box), + onTap: () async { + showLoadingOverlay(); + await item.reload(); + hideLoadingOverlay(); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => BuildLineDetailWidget(item)), + ); + }, + ); + } +} diff --git a/lib/widget/build/build_list.dart b/lib/widget/build/build_list.dart new file mode 100644 index 00000000..7a53c0fa --- /dev/null +++ b/lib/widget/build/build_list.dart @@ -0,0 +1,190 @@ +import "package:flutter/material.dart"; +import "package:flutter_speed_dial/flutter_speed_dial.dart"; +import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; + +import "package:inventree/inventree/model.dart"; +import "package:inventree/widget/paginator.dart"; +import "package:inventree/widget/refreshable_state.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/api.dart"; +import "package:inventree/inventree/part.dart"; +import "package:inventree/inventree/build.dart"; + +/* + * Widget class for displaying a list of Build Orders + */ +class BuildOrderListWidget extends StatefulWidget { + const BuildOrderListWidget({this.filters = const {}, Key? key}) + : super(key: key); + + final Map filters; + + @override + _BuildOrderListWidgetState createState() => _BuildOrderListWidgetState(); +} + +class _BuildOrderListWidgetState + extends RefreshableState { + _BuildOrderListWidgetState(); + + @override + String getAppBarTitle() => L10().buildOrders; + + @override + List actionButtons(BuildContext context) { + List actions = []; + + // Add create button if user has permission + // Using canCreate instead of specific permission check + if (InvenTreeBuildOrder().canCreate) { + actions.add( + SpeedDialChild( + child: const Icon(TablerIcons.circle_plus), + label: L10().buildOrderCreate, + onTap: () { + _createBuildOrder(context); + }, + ), + ); + } + + return actions; + } + + // Launch form to create a new BuildOrder + Future _createBuildOrder(BuildContext context) async { + var fields = InvenTreeBuildOrder().formFields(); + + InvenTreeBuildOrder().createForm( + context, + L10().buildOrderCreate, + fields: fields, + onSuccess: (result) async { + Map data = result as Map; + + if (data.containsKey("pk")) { + var order = InvenTreeBuildOrder.fromJson(data); + order.goToDetailPage(context); + } + }, + ); + } + + @override + List barcodeButtons(BuildContext context) { + // Build orders don't have barcode functionality yet + return []; + } + + @override + Widget getBody(BuildContext context) { + return PaginatedBuildOrderList(widget.filters); + } +} + +class PaginatedBuildOrderList extends PaginatedSearchWidget { + const PaginatedBuildOrderList(Map filters) + : super(filters: filters); + + @override + String get searchTitle => L10().buildOrders; + + @override + _PaginatedBuildOrderListState createState() => + _PaginatedBuildOrderListState(); +} + +class _PaginatedBuildOrderListState + extends PaginatedSearchState { + _PaginatedBuildOrderListState() : super(); + + @override + String get prefix => "build_"; + + @override + Map get orderingOptions => { + "reference": L10().reference, + "part__name": L10().part, + "status": L10().status, + "creation_date": L10().creationDate, + "target_date": L10().targetDate, + "completion_date": L10().completionDate, + }; + + @override + Map> get filterOptions => { + "outstanding": { + "label": L10().outstanding, + "help_text": L10().outstandingOrderDetail, + "tristate": true, + "default": "true", + }, + "overdue": { + "label": L10().overdue, + "help_text": L10().overdueDetail, + "tristate": true, + }, + "assigned_to_me": { + "label": L10().assignedToMe, + "help_text": L10().assignedToMeDetail, + "tristate": true, + }, + }; + + @override + Future requestPage( + int limit, + int offset, + Map params, + ) async { + // Make sure the status codes are loaded + await InvenTreeAPI().fetchStatusCodeData(); + + final page = await InvenTreeBuildOrder().listPaginated( + limit, + offset, + filters: params, + ); + + return page; + } + + @override + Widget buildItem(BuildContext context, InvenTreeModel model) { + InvenTreeBuildOrder order = model as InvenTreeBuildOrder; + InvenTreePart? part = order.partDetail; + + return ListTile( + title: Text(order.reference), + subtitle: Text(order.description), + leading: part != null + ? InvenTreeAPI().getThumbnail(part.thumbnail) + : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + BuildOrderStatus.getStatusText(order.status), + style: TextStyle( + color: BuildOrderStatus.getStatusColor(order.status), + fontWeight: FontWeight.bold, + ), + ), + Text( + "${order.completed.toInt()} / ${order.quantity.toInt()}", + style: const TextStyle(fontSize: 12), + ), + ], + ), + ], + ), + onTap: () async { + order.goToDetailPage(context); + }, + ); + } +} diff --git a/lib/widget/build/build_list_item.dart b/lib/widget/build/build_list_item.dart new file mode 100644 index 00000000..3dd2f1be --- /dev/null +++ b/lib/widget/build/build_list_item.dart @@ -0,0 +1,166 @@ +import "package:flutter/material.dart"; +import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; +import "package:inventree/widget/progress.dart"; + +import "package:inventree/api.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/inventree/build.dart"; + +/* + * A widget for displaying a single build order in a list + */ +class BuildOrderListItem extends StatelessWidget { + const BuildOrderListItem(this.order, {Key? key}) : super(key: key); + + final InvenTreeBuildOrder order; + + @override + Widget build(BuildContext context) { + // Calculate completion percentage + double progress = 0; + if (order.quantity > 0) { + progress = order.completed / order.quantity; + } + + // Clamp to valid range + progress = progress.clamp(0, 1); + + // Part name may be empty + String partName = order.partDetail?.name ?? "-"; + + // Format dates + String creationDate = order.creationDate; + String targetDate = order.targetDate.isNotEmpty ? order.targetDate : "-"; + + return Card( + margin: const EdgeInsets.all(4.0), + child: InkWell( + onTap: () { + order.goToDetailPage(context); + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row with reference and status + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + order.reference, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 4.0, + ), + decoration: BoxDecoration( + color: BuildOrderStatus.getStatusColor( + order.status, + ).withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12.0), + ), + child: Text( + BuildOrderStatus.getStatusText(order.status), + style: TextStyle( + color: BuildOrderStatus.getStatusColor(order.status), + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ), + + const SizedBox(height: 8), + + // Part information + Row( + children: [ + SizedBox( + width: 32, + height: 32, + child: + order.partDetail != null && + order.partDetail!.thumbnail.isNotEmpty + ? InvenTreeAPI().getThumbnail( + order.partDetail!.thumbnail, + ) + : const Icon(TablerIcons.tool), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + partName, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + if (order.description.isNotEmpty) + Text( + order.description, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 8), + + // Progress indicator + ProgressBar(order.completed, maximum: order.quantity), + + const SizedBox(height: 8), + + // Date information + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(TablerIcons.calendar, size: 14), + const SizedBox(width: 4), + Text( + "${L10().creationDate}: ${creationDate}", + style: const TextStyle(fontSize: 12), + ), + ], + ), + Row( + children: [ + const Icon(TablerIcons.calendar_due, size: 14), + const SizedBox(width: 4), + Text( + "${L10().targetDate}: $targetDate", + style: TextStyle( + fontSize: 12, + color: + (order.targetDate.isNotEmpty && + DateTime.tryParse(order.targetDate) != null && + DateTime.tryParse( + order.targetDate, + )!.isBefore(DateTime.now())) + ? Colors.red + : null, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widget/build/build_output_list.dart b/lib/widget/build/build_output_list.dart new file mode 100644 index 00000000..c634cabf --- /dev/null +++ b/lib/widget/build/build_output_list.dart @@ -0,0 +1,129 @@ +import "package:flutter/material.dart"; +import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; + +import "package:inventree/api.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/inventree/model.dart"; +import "package:inventree/inventree/stock.dart"; + +import "package:inventree/widget/paginator.dart"; +import "package:inventree/widget/progress.dart"; +import "package:inventree/widget/stock/stock_detail.dart"; + +/* + * Paginated widget class for displaying a list of build order outputs (manufactured items) + */ +class PaginatedBuildOutputList extends PaginatedSearchWidget { + const PaginatedBuildOutputList(Map filters) + : super(filters: filters); + + @override + String get searchTitle => L10().buildOutputs; + + @override + _PaginatedBuildOutputListState createState() => + _PaginatedBuildOutputListState(); +} + +/* + * State class for PaginatedBuildOutputList +*/ +class _PaginatedBuildOutputListState + extends PaginatedSearchState { + _PaginatedBuildOutputListState() : super(); + + @override + String get prefix => "build_output_"; + + @override + Map get orderingOptions => { + "part": L10().part, + "serial": L10().serialNumber, + "quantity": L10().quantity, + }; + + @override + Map> get filterOptions => { + "is_building": { + "label": L10().filterInProduction, + "help_text": L10().filterInProductionDetail, + "default": null, + "tristate": true, + }, + "status": { + "label": L10().status, + "help_text": L10().statusCode, + "choices": InvenTreeAPI().StockStatus.choices, + }, + }; + + @override + Future requestPage( + int limit, + int offset, + Map params, + ) async { + final page = await InvenTreeStockItem().listPaginated( + limit, + offset, + filters: params, + ); + return page; + } + + @override + Widget buildItem(BuildContext context, InvenTreeModel model) { + InvenTreeStockItem stockItem = model as InvenTreeStockItem; + + // Format the information to display + String info = ""; + + // Show serial number if available + if (stockItem.serialNumber.isNotEmpty) { + info = "${L10().serialNumber}: ${stockItem.serialNumber}"; + } + // Show batch code if available + else if (stockItem.batch.isNotEmpty) { + info = "${L10().batchCode}: ${stockItem.batch}"; + } + // Otherwise show location + else if (stockItem.locationId > 0) { + // Use locationName if available + info = stockItem.getString("name", subKey: "location_detail"); + + // Try to get the path if available + String path = stockItem.getString( + "pathstring", + subKey: "location_detail", + ); + if (path.isNotEmpty) { + info = path; + } + } + + return ListTile( + title: Text(stockItem.partName), + subtitle: Text(info), + trailing: Text( + stockItem.quantity.toString(), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + leading: stockItem.partThumbnail.isNotEmpty + ? SizedBox( + width: 32, + height: 32, + child: InvenTreeAPI().getThumbnail(stockItem.partThumbnail), + ) + : const Icon(TablerIcons.box), + onTap: () async { + showLoadingOverlay(); + await stockItem.reload(); + hideLoadingOverlay(); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => StockDetailWidget(stockItem)), + ); + }, + ); + } +} diff --git a/lib/widget/dialogs.dart b/lib/widget/dialogs.dart index feb267ea..f8e5407d 100644 --- a/lib/widget/dialogs.dart +++ b/lib/widget/dialogs.dart @@ -83,7 +83,7 @@ Future confirmationDialog( title: Text(title, style: TextStyle(color: color)), leading: Icon(icon, color: color), ), - content: text.isEmpty ? Text(text) : null, + content: text.isNotEmpty ? Text(text) : null, actions: [ TextButton( child: Text(_reject), diff --git a/lib/widget/drawer.dart b/lib/widget/drawer.dart index 0557aec4..738d9bf4 100644 --- a/lib/widget/drawer.dart +++ b/lib/widget/drawer.dart @@ -4,12 +4,14 @@ import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; import "package:inventree/api.dart"; import "package:inventree/app_colors.dart"; +import "package:inventree/inventree/build.dart"; import "package:inventree/inventree/part.dart"; import "package:inventree/inventree/purchase_order.dart"; import "package:inventree/inventree/sales_order.dart"; import "package:inventree/inventree/stock.dart"; import "package:inventree/l10.dart"; import "package:inventree/settings/settings.dart"; +import "package:inventree/widget/build/build_list.dart"; import "package:inventree/widget/order/sales_order_list.dart"; import "package:inventree/widget/part/category_display.dart"; import "package:inventree/widget/notifications.dart"; @@ -70,7 +72,7 @@ class ThemeSelectionDialog extends StatelessWidget { onPressed: () { Navigator.of(context).pop(); }, - child: Text("Cancel"), + child: Text(L10().cancel), ), ], ); @@ -155,6 +157,20 @@ class InvenTreeDrawer extends StatelessWidget { } } + // Load "build orders" page + void _buildOrders() { + _closeDrawer(); + + if (_checkConnection()) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BuildOrderListWidget(filters: {}), + ), + ); + } + } + // Load notifications screen void _notifications() { _closeDrawer(); @@ -226,6 +242,16 @@ class InvenTreeDrawer extends StatelessWidget { ); } + if (InvenTreeBuildOrder().canView) { + tiles.add( + ListTile( + title: Text(L10().buildOrders), + leading: Icon(TablerIcons.building_factory, color: COLOR_ACTION), + onTap: _buildOrders, + ), + ); + } + if (InvenTreePurchaseOrder().canView) { tiles.add( ListTile( diff --git a/lib/widget/home.dart b/lib/widget/home.dart index 15c4d3f1..63222f49 100644 --- a/lib/widget/home.dart +++ b/lib/widget/home.dart @@ -22,6 +22,7 @@ import "package:inventree/widget/stock/location_display.dart"; import "package:inventree/widget/part/part_list.dart"; import "package:inventree/widget/order/purchase_order_list.dart"; import "package:inventree/widget/order/sales_order_list.dart"; +import "package:inventree/widget/build/build_list.dart"; import "package:inventree/widget/refreshable_state.dart"; import "package:inventree/widget/snacks.dart"; import "package:inventree/widget/spinner.dart"; @@ -57,6 +58,7 @@ class _InvenTreeHomePageState extends State bool homeShowPo = false; bool homeShowSo = false; bool homeShowShipments = false; + bool homeShowBuild = false; bool homeShowSubscribed = false; bool homeShowManufacturers = false; bool homeShowCustomers = false; @@ -128,6 +130,17 @@ class _InvenTreeHomePageState extends State ); } + void _showBuildOrders(BuildContext context) { + if (!InvenTreeAPI().checkConnection()) return; + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BuildOrderListWidget(filters: {}), + ), + ); + } + void _showSuppliers(BuildContext context) { if (!InvenTreeAPI().checkConnection()) return; @@ -188,6 +201,10 @@ class _InvenTreeHomePageState extends State await InvenTreeSettingsManager().getValue(INV_HOME_SHOW_SHIPMENTS, true) as bool; + homeShowBuild = + await InvenTreeSettingsManager().getValue(INV_HOME_SHOW_BUILD, true) + as bool; + homeShowManufacturers = await InvenTreeSettingsManager().getValue( INV_HOME_SHOW_MANUFACTURERS, @@ -319,6 +336,22 @@ class _InvenTreeHomePageState extends State ); } + // Build Orders + if (homeShowBuild && InvenTreeAPI().checkRole("build", "view")) { + tiles.add( + _listTile( + context, + L10().buildOrders, + TablerIcons.building_factory, + callback: () { + _showBuildOrders(context); + }, + role: "build", + permission: "view", + ), + ); + } + // Purchase orders if (homeShowPo && InvenTreePurchaseOrder().canView) { tiles.add( diff --git a/lib/widget/order/purchase_order_detail.dart b/lib/widget/order/purchase_order_detail.dart index d309a4b3..306ad6fe 100644 --- a/lib/widget/order/purchase_order_detail.dart +++ b/lib/widget/order/purchase_order_detail.dart @@ -191,7 +191,7 @@ class _PurchaseOrderDetailState Future _issueOrder(BuildContext context) async { confirmationDialog( L10().issueOrder, - "", + L10().issueOrderConfirm, icon: TablerIcons.send, color: Colors.blue, acceptText: L10().issue, @@ -225,7 +225,7 @@ class _PurchaseOrderDetailState Future _cancelOrder(BuildContext context) async { confirmationDialog( L10().cancelOrder, - "", + L10().cancelOrderConfirm, icon: TablerIcons.circle_x, color: Colors.red, acceptText: L10().cancel, diff --git a/lib/widget/order/sales_order_detail.dart b/lib/widget/order/sales_order_detail.dart index 79a3620b..7b3298b5 100644 --- a/lib/widget/order/sales_order_detail.dart +++ b/lib/widget/order/sales_order_detail.dart @@ -125,7 +125,7 @@ class _SalesOrderDetailState extends RefreshableState { Future _issueOrder(BuildContext context) async { confirmationDialog( L10().issueOrder, - "", + L10().issueOrderConfirm, icon: TablerIcons.send, color: Colors.blue, acceptText: L10().issue, @@ -141,7 +141,7 @@ class _SalesOrderDetailState extends RefreshableState { Future _cancelOrder(BuildContext context) async { confirmationDialog( L10().cancelOrder, - "", + L10().cancelOrderConfirm, icon: TablerIcons.circle_x, color: Colors.red, acceptText: L10().cancel, diff --git a/pubspec.lock b/pubspec.lock index 5f17bb7d..b386be13 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -853,6 +853,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + percent_indicator: + dependency: "direct main" + description: + name: percent_indicator + sha256: "157d29133bbc6ecb11f923d36e7960a96a3f28837549a20b65e5135729f0f9fd" + url: "https://pub.dev" + source: hosted + version: "4.2.5" petitparser: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fedd83a7..28bedbd7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: package_info_plus: ^8.1.1 # App information introspection path: ^1.9.0 path_provider: ^2.1.5 # Local file storage + percent_indicator: ^4.2.5 sembast: ^3.6.0 # NoSQL data storage sentry_flutter: ^9.14.0 # Error reporting url_launcher: ^6.3.1 # Open link in system browser