diff --git a/assets/release_notes.md b/assets/release_notes.md index 78696039..28977610 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -1,3 +1,11 @@ +### 0.20.0 - October 2025 +--- + +- View pending shipments from the home screen +- Display detail view for shipments +- Adds ability to ship pending outgoing shipments +- Adds ability to mark outgoing shipments as "checked" or "unchecked" + ### 0.19.3 - September 2025 --- diff --git a/lib/api.dart b/lib/api.dart index a63f1ccf..5d79a22a 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -279,6 +279,8 @@ class InvenTreeAPI { String get username => (userInfo["username"] ?? "") as String; + int get userId => (userInfo["pk"] ?? -1) as int; + // Map of server information Map serverInfo = {}; diff --git a/lib/api_form.dart b/lib/api_form.dart index e227289a..7852c452 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -586,6 +586,8 @@ class APIFormField { return InvenTreeSupplierPart().defaultListFilters(); case InvenTreeStockItem.MODEL_TYPE: return InvenTreeStockItem().defaultListFilters(); + case InvenTreeSalesOrder.MODEL_TYPE: + return InvenTreeSalesOrder().defaultListFilters(); default: break; } @@ -727,7 +729,7 @@ class APIFormField { return ListTile( title: Text(shipment.reference), subtitle: Text(shipment.tracking_number), - trailing: shipment.shipped ? Text(shipment.shipment_date!) : null, + trailing: shipment.isShipped ? Text(shipment.shipment_date!) : null, ); case "owner": String name = (data["name"] ?? "") as String; @@ -754,6 +756,15 @@ 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 ?? "", + ), + ); default: return ListTile( title: Text( diff --git a/lib/inventree/model.dart b/lib/inventree/model.dart index 532d1c2e..e08a053e 100644 --- a/lib/inventree/model.dart +++ b/lib/inventree/model.dart @@ -601,7 +601,7 @@ class InvenTreeModel { // POST data to update the model Future update({ - Map values = const {}, + Map values = const {}, int? expectedStatusCode = 200, }) async { var url = path.join(URL, pk.toString()); diff --git a/lib/inventree/sales_order.dart b/lib/inventree/sales_order.dart index 51412dbd..8b661f4a 100644 --- a/lib/inventree/sales_order.dart +++ b/lib/inventree/sales_order.dart @@ -5,6 +5,9 @@ import "package:inventree/helpers.dart"; import "package:inventree/inventree/company.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/order/so_shipment_detail.dart"; import "package:inventree/widget/progress.dart"; import "package:inventree/widget/order/extra_line_detail.dart"; import "package:inventree/widget/order/sales_order_detail.dart"; @@ -269,6 +272,19 @@ class InvenTreeSalesOrderShipment extends InvenTreeModel { @override String get URL => "/order/so/shipment/"; + String get SHIP_SHIPMENT_URL => "/order/so/shipment/${pk}/ship/"; + + @override + Future goToDetailPage(BuildContext context) async { + return Navigator.push( + context, + MaterialPageRoute(builder: (context) => SOShipmentDetailWidget(this)), + ); + } + + @override + List get rolesRequired => ["sales_order"]; + static const String MODEL_TYPE = "salesordershipment"; @override @@ -284,6 +300,18 @@ class InvenTreeSalesOrderShipment extends InvenTreeModel { return fields; } + int get orderId => getInt("order"); + + InvenTreeSalesOrder? get order { + dynamic order_detail = jsondata["order_detail"]; + + if (order_detail == null) { + return null; + } else { + return InvenTreeSalesOrder.fromJson(order_detail as Map); + } + } + String get reference => getString("reference"); String get tracking_number => getString("tracking_number"); @@ -292,7 +320,113 @@ class InvenTreeSalesOrderShipment extends InvenTreeModel { String? get shipment_date => getString("shipment_date"); - bool get shipped => shipment_date != null && shipment_date!.isNotEmpty; + String? get delivery_date => getString("delivery_date"); + + int? get checked_by_id => getInt("checked_by"); + + bool get isChecked => checked_by_id != null && checked_by_id! > 0; + + bool get isShipped => shipment_date != null && shipment_date!.isNotEmpty; + + bool get isDelivered => delivery_date != null && delivery_date!.isNotEmpty; +} + +/* + * Class representing an allocation of stock against a SalesOrderShipment + */ +class InvenTreeSalesOrderAllocation extends InvenTreeAttachment { + InvenTreeSalesOrderAllocation() : super(); + + InvenTreeSalesOrderAllocation.fromJson(Map json) + : super.fromJson(json); + + @override + InvenTreeModel createFromJson(Map json) => + InvenTreeSalesOrderAllocation.fromJson(json); + + @override + String get URL => "/order/so-allocation/"; + + @override + List get rolesRequired => ["sales_order"]; + + @override + Map defaultFilters() { + return { + "part_detail": "true", + "order_detail": "true", + "item_detail": "true", + "location_detail": "true", + }; + } + + static const String MODEL_TYPE = "salesorderallocation"; + + int get orderId => getInt("order"); + + InvenTreeSalesOrder? get order { + dynamic order_detail = jsondata["order_detail"]; + + if (order_detail == null) { + return null; + } else { + return InvenTreeSalesOrder.fromJson(order_detail as Map); + } + } + + int get stockItemId => getInt("item"); + + InvenTreeStockItem? get stockItem { + dynamic item_detail = jsondata["item_detail"]; + + if (item_detail == null) { + return null; + } else { + return InvenTreeStockItem.fromJson(item_detail as Map); + } + } + + int get partId => getInt("part"); + + InvenTreePart? get part { + dynamic part_detail = jsondata["part_detail"]; + + if (part_detail == null) { + return null; + } else { + return InvenTreePart.fromJson(part_detail as Map); + } + } + + int get shipmentId => getInt("shipment"); + + bool get hasShipment => shipmentId > 0; + + InvenTreeSalesOrderShipment? get shipment { + dynamic shipment_detail = jsondata["shipment_detail"]; + + if (shipment_detail == null) { + return null; + } else { + return InvenTreeSalesOrderShipment.fromJson( + shipment_detail as Map, + ); + } + } + + int get locationId => getInt("location"); + + InvenTreeStockLocation? get location { + dynamic location_detail = jsondata["location_detail"]; + + if (location_detail == null) { + return null; + } else { + return InvenTreeStockLocation.fromJson( + location_detail as Map, + ); + } + } } /* @@ -319,3 +453,23 @@ class InvenTreeSalesOrderAttachment extends InvenTreeAttachment { ? "attachment/" : "order/so/attachment/"; } + +class InvenTreeSalesOrderShipmentAttachment extends InvenTreeAttachment { + InvenTreeSalesOrderShipmentAttachment() : super(); + + InvenTreeSalesOrderShipmentAttachment.fromJson(Map json) + : super.fromJson(json); + + @override + InvenTreeModel createFromJson(Map json) => + InvenTreeSalesOrderShipmentAttachment.fromJson(json); + + @override + String get REFERENCE_FIELD => "shipment"; + + @override + String get REF_MODEL_TYPE => "salesordershipment"; + + @override + String get URL => "attachment/"; +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fbac9091..83fef3af 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -65,6 +65,9 @@ "allocateStock": "Allocate Stock", "@allocateStock": {}, + "allocatedStock": "Allocated Stock", + "@allocatedStock": {}, + "appReleaseNotes": "Display app release notes", "@appReleaseNotes": {}, @@ -331,6 +334,15 @@ "deleteFailed": "Delete operation failed", "@deleteFailed": {}, + "deleteImageConfirmation": "Are you sure you want to delete this image?", + "@deleteImageConfirmation": {}, + + "deleteImageTooltip": "Delete Image", + "@deleteImageTooltip": {}, + + "deleteImage": "Delete Image", + "@deleteImage": {}, + "deletePart": "Delete Part", "@deletePart": {}, @@ -340,6 +352,9 @@ "deleteSuccess": "Delete operation successful", "@deleteSuccess": {}, + "deliveryDate": "Delivery Date", + "@deliveryDate": {}, + "description": "Description", "@description": {}, @@ -548,6 +563,12 @@ "homeShowPoDescription": "Show purchase order button on home screen", "@homeShowPoDescription": {}, + "homeShowShipments": "Show Shipments", + "@homeShowShipments": {}, + + "homeShowShipmentsDescription": "Show pending shipments on the home screen", + "@homeShowShipmentsDescription": {}, + "homeShowSo": "Show Sales Orders", "@homeShowSo": {}, @@ -647,6 +668,12 @@ "invalidUsernamePassword": "Invalid username / password combination", "@invalidUsernamePassword": {}, + "invoice": "Invoice", + "@invoice": {}, + + "invoiceNumber": "Invoice Number", + "@invoiceNumber": {}, + "issue": "Issue", "@issue": {}, @@ -769,6 +796,12 @@ "@name": { }, + "no": "No", + "@no": {}, + + "notApplicable": "N/A", + "@notApplicable": {}, + "notConnected": "Not Connected", "@notConnected": {}, @@ -780,8 +813,8 @@ "notifications": "Notifications", "@notifications": {}, - "notificationsEmpty": "No unread notifications", - "@notificationsEmpty": {}, + "notificationsEmpty": "No unread notifications", + "@notificationsEmpty": {}, "noResponse": "No Response from Server", "@noResponse": {}, @@ -792,6 +825,12 @@ "noImageAvailable": "No image available", "@noImageAvailable": {}, + "noPricingAvailable": "No pricing available", + "@noPricingAvailable": {}, + + "noPricingDataFound": "No pricing data found for this part", + "@noPricingDataFound": {}, + "noSubcategories": "No Subcategories", "@noSubcategories": {}, @@ -927,6 +966,9 @@ "passwordEmpty": "Password cannot be empty", "@passwordEmpty": {}, + "pending": "Pending", + "@pending": {}, + "permissionAccountDenied": "Your account does not have the required permissions to perform this action", "@permissionAccountDenied": {}, @@ -1186,20 +1228,20 @@ "salesOrders": "Sales Orders", "@salesOrders": {}, - "salesOrderEnable": "Enable Sales Orders", - "@salesOrderEnable": {}, + "salesOrderEnable": "Enable Sales Orders", + "@salesOrderEnable": {}, - "salesOrderEnableDetail": "Enable sales order functionality", - "@salesOrderEnableDetail": {}, + "salesOrderEnableDetail": "Enable sales order functionality", + "@salesOrderEnableDetail": {}, - "salesOrderShowCamera": "Camera Shortcut", - "@salesOrderShowCamera": {}, + "salesOrderShowCamera": "Camera Shortcut", + "@salesOrderShowCamera": {}, - "salesOrderShowCameraDetail": "Enable image upload shortcut on sales order screen", - "@salesOrderShowCameraDetail": {}, + "salesOrderShowCameraDetail": "Enable image upload shortcut on sales order screen", + "@salesOrderShowCameraDetail": {}, "salesOrderSettings": "Sales order settings", - "@salesOrderSettings": {}, + "@salesOrderSettings": {}, "salesOrderCreate": "New Sales Order", "@saleOrderCreate": {}, @@ -1338,12 +1380,48 @@ "serverNotSelected": "Server not selected", "@serverNotSelected": {}, + "shipment": "Shipment", + "@shipment": {}, + "shipments": "Shipments", "@shipments": {}, + "shipmentsPending": "Pending Shipments", + "@shipmentsPending": {}, + "shipmentAdd": "Add Shipment", "@shipmentAdd": {}, + "shipmentCheck": "Check Shipment", + "@shipmentCheck": {}, + + "shipmentCheckDetail": "Mark this shipment as checked", + "@shipmentCheckDetail": {}, + + "shipmentChecked": "Shipment Checked", + "@shipmentChecked": {}, + + "shipmentDate": "Shipment Date", + "@shipmentDate": {}, + + "shipmentEdit": "Edit Shipment", + "@shipmentEdit": {}, + + "shipmentReference": "Shipment Reference", + "@shipmentReference": {}, + + "shipmentSend": "Send Shipment", + "@shipmentSend": {}, + + "shipmentUncheck": "Uncheck Shipment", + "@shipmentUncheck": {}, + + "shipmentUncheckDetail": "Mark this shipment as unchecked", + "@shipmentUncheckDetail": {}, + + "shipmentUpdated": "Shipment Updated", + "@shipmentUpdated": {}, + "shipped": "Shipped", "@shipped": {}, @@ -1555,6 +1633,9 @@ "totalPrice": "Total Price", "@totalPrice": {}, + "trackingNumber": "Tracking Number", + "@trackingNumber": {}, + "transfer": "Transfer", "@transfer": { "description": "transfer" @@ -1642,6 +1723,9 @@ "website": "Website", "@website": {}, + "yes": "Yes", + "@yes": {}, + "price": "Price", "@price": {}, @@ -1682,20 +1766,5 @@ "@currency": {}, "priceBreaks": "Price Breaks", - "@priceBreaks": {}, - - "noPricingAvailable": "No pricing available", - "@noPricingAvailable": {}, - - "noPricingDataFound": "No pricing data found for this part", - "@noPricingDataFound": {}, - - "deleteImageConfirmation": "Are you sure you want to delete this image?", - "@deleteImageConfirmation": {}, - - "deleteImageTooltip": "Delete Image", - "@deleteImageTooltip": {}, - - "deleteImage": "Delete Image", - "@deleteImage": {} + "@priceBreaks": {} } diff --git a/lib/preferences.dart b/lib/preferences.dart index e10c3d93..bf0d8ae4 100644 --- a/lib/preferences.dart +++ b/lib/preferences.dart @@ -10,6 +10,7 @@ import "package:path/path.dart"; 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_MANUFACTURERS = "homeShowManufacturers"; const String INV_HOME_SHOW_CUSTOMERS = "homeShowCustomers"; const String INV_HOME_SHOW_SUPPLIERS = "homeShowSuppliers"; diff --git a/lib/settings/home_settings.dart b/lib/settings/home_settings.dart index 58740d4f..a4da9d20 100644 --- a/lib/settings/home_settings.dart +++ b/lib/settings/home_settings.dart @@ -20,6 +20,7 @@ class _HomeScreenSettingsState extends State { bool homeShowSubscribed = true; bool homeShowPo = true; bool homeShowSo = true; + bool homeShowShipments = true; bool homeShowSuppliers = true; bool homeShowManufacturers = true; bool homeShowCustomers = true; @@ -46,6 +47,11 @@ class _HomeScreenSettingsState extends State { homeShowSo = await InvenTreeSettingsManager().getValue(INV_HOME_SHOW_SO, true) as bool; + + homeShowShipments = + await InvenTreeSettingsManager().getValue(INV_HOME_SHOW_SHIPMENTS, true) + as bool; + homeShowManufacturers = await InvenTreeSettingsManager().getValue( INV_HOME_SHOW_MANUFACTURERS, @@ -118,6 +124,23 @@ class _HomeScreenSettingsState extends State { }, ), ), + ListTile( + title: Text(L10().homeShowShipments), + subtitle: Text(L10().homeShowShipmentsDescription), + leading: Icon(TablerIcons.cube_send), + trailing: Switch( + value: homeShowShipments, + onChanged: (bool value) { + InvenTreeSettingsManager().setValue( + INV_HOME_SHOW_SHIPMENTS, + value, + ); + setState(() { + homeShowShipments = value; + }); + }, + ), + ), ListTile( title: Text(L10().homeShowSuppliers), subtitle: Text(L10().homeShowSuppliersDescription), diff --git a/lib/widget/attachment_widget.dart b/lib/widget/attachment_widget.dart index b4c9746a..b66462f3 100644 --- a/lib/widget/attachment_widget.dart +++ b/lib/widget/attachment_widget.dart @@ -3,7 +3,6 @@ import "dart:io"; import "package:flutter/material.dart"; import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; import "package:one_context/one_context.dart"; -import "package:url_launcher/url_launcher.dart"; import "package:inventree/api.dart"; import "package:inventree/l10.dart"; @@ -212,17 +211,14 @@ class _AttachmentWidgetState extends RefreshableState { }, ), ); - } else if (attachment.link.isNotEmpty) { + } else if (attachment.hasLink) { tiles.add( ListTile( title: Text(attachment.link), subtitle: Text(attachment.comment), leading: Icon(TablerIcons.link, color: COLOR_ACTION), onTap: () async { - var uri = Uri.tryParse(attachment.link.trimLeft()); - if (uri != null && await canLaunchUrl(uri)) { - await launchUrl(uri); - } + attachment.openLink(); }, onLongPress: () { showOptionsMenu(context, attachment); diff --git a/lib/widget/company/company_detail.dart b/lib/widget/company/company_detail.dart index 64356d71..5d06e581 100644 --- a/lib/widget/company/company_detail.dart +++ b/lib/widget/company/company_detail.dart @@ -287,7 +287,7 @@ class _CompanyDetailState extends RefreshableState { } // External link - if (widget.company.link.isNotEmpty) { + if (widget.company.hasLink) { tiles.add( ListTile( title: Text(L10().link), diff --git a/lib/widget/company/manufacturer_part_detail.dart b/lib/widget/company/manufacturer_part_detail.dart index 5267a31b..9cc6bc23 100644 --- a/lib/widget/company/manufacturer_part_detail.dart +++ b/lib/widget/company/manufacturer_part_detail.dart @@ -12,7 +12,6 @@ import "package:inventree/inventree/part.dart"; import "package:inventree/widget/refreshable_state.dart"; import "package:inventree/widget/snacks.dart"; import "package:inventree/widget/progress.dart"; -import "package:url_launcher/url_launcher.dart"; /* * Detail widget for viewing a single ManufacturerPart instance @@ -163,16 +162,13 @@ class _ManufacturerPartDisplayState ); } - if (widget.manufacturerPart.link.isNotEmpty) { + if (widget.manufacturerPart.hasLink) { tiles.add( ListTile( title: Text(widget.manufacturerPart.link), leading: Icon(TablerIcons.link, color: COLOR_ACTION), onTap: () async { - var uri = Uri.tryParse(widget.manufacturerPart.link); - if (uri != null && await canLaunchUrl(uri)) { - await launchUrl(uri); - } + widget.manufacturerPart.openLink(); }, ), ); diff --git a/lib/widget/company/supplier_part_detail.dart b/lib/widget/company/supplier_part_detail.dart index 92cf2de6..b2a965fc 100644 --- a/lib/widget/company/supplier_part_detail.dart +++ b/lib/widget/company/supplier_part_detail.dart @@ -2,7 +2,6 @@ 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/widget/link_icon.dart"; -import "package:url_launcher/url_launcher.dart"; import "package:inventree/app_colors.dart"; import "package:inventree/l10.dart"; @@ -239,7 +238,7 @@ class _SupplierPartDisplayState ); } - if (widget.supplierPart.link.isNotEmpty) { + if (widget.supplierPart.hasLink) { tiles.add( ListTile( title: Text(L10().link), @@ -247,10 +246,7 @@ class _SupplierPartDisplayState leading: Icon(TablerIcons.link, color: COLOR_ACTION), trailing: LinkIcon(external: true), onTap: () async { - var uri = Uri.tryParse(widget.supplierPart.link); - if (uri != null && await canLaunchUrl(uri)) { - await launchUrl(uri); - } + widget.supplierPart.openLink(); }, ), ); diff --git a/lib/widget/home.dart b/lib/widget/home.dart index 3b29d868..15c4d3f1 100644 --- a/lib/widget/home.dart +++ b/lib/widget/home.dart @@ -14,6 +14,7 @@ import "package:inventree/preferences.dart"; import "package:inventree/l10.dart"; import "package:inventree/settings/select_server.dart"; import "package:inventree/user_profile.dart"; +import "package:inventree/widget/order/so_shipment_list.dart"; import "package:inventree/widget/part/category_display.dart"; import "package:inventree/widget/drawer.dart"; @@ -55,6 +56,7 @@ class _InvenTreeHomePageState extends State bool homeShowPo = false; bool homeShowSo = false; + bool homeShowShipments = false; bool homeShowSubscribed = false; bool homeShowManufacturers = false; bool homeShowCustomers = false; @@ -112,6 +114,20 @@ class _InvenTreeHomePageState extends State ); } + void _showPendingShipments(BuildContext context) { + if (!InvenTreeAPI().checkConnection()) return; + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SOShipmentListWidget( + title: L10().shipmentsPending, + filters: {"order_outstanding": "true", "shipped": "false"}, + ), + ), + ); + } + void _showSuppliers(BuildContext context) { if (!InvenTreeAPI().checkConnection()) return; @@ -167,6 +183,11 @@ class _InvenTreeHomePageState extends State homeShowSo = await InvenTreeSettingsManager().getValue(INV_HOME_SHOW_SO, true) as bool; + + homeShowShipments = + await InvenTreeSettingsManager().getValue(INV_HOME_SHOW_SHIPMENTS, true) + as bool; + homeShowManufacturers = await InvenTreeSettingsManager().getValue( INV_HOME_SHOW_MANUFACTURERS, @@ -325,6 +346,19 @@ class _InvenTreeHomePageState extends State ); } + if (homeShowShipments && InvenTreeSalesOrderShipment().canView) { + tiles.add( + _listTile( + context, + L10().shipmentsPending, + TablerIcons.cube_send, + callback: () { + _showPendingShipments(context); + }, + ), + ); + } + // Suppliers if (homeShowSuppliers && InvenTreePurchaseOrder().canView) { tiles.add( diff --git a/lib/widget/order/po_line_detail.dart b/lib/widget/order/po_line_detail.dart index 7faf4975..16bac283 100644 --- a/lib/widget/order/po_line_detail.dart +++ b/lib/widget/order/po_line_detail.dart @@ -241,7 +241,7 @@ class _POLineDetailWidgetState extends RefreshableState { } // External link - if (widget.item.link.isNotEmpty) { + if (widget.item.hasLink) { tiles.add( ListTile( title: Text(L10().link), diff --git a/lib/widget/order/sales_order_detail.dart b/lib/widget/order/sales_order_detail.dart index bc23961e..ec18561c 100644 --- a/lib/widget/order/sales_order_detail.dart +++ b/lib/widget/order/sales_order_detail.dart @@ -375,24 +375,7 @@ class _SalesOrderDetailState extends RefreshableState { Color lineColor = widget.order.complete ? COLOR_SUCCESS : COLOR_WARNING; - // Shipment progress - if (widget.order.shipmentCount > 0) { - tiles.add( - ListTile( - title: Text(L10().shipments), - subtitle: ProgressBar( - widget.order.completedShipmentCount.toDouble(), - maximum: widget.order.shipmentCount.toDouble(), - ), - leading: Icon(TablerIcons.truck_delivery), - trailing: LargeText( - "${widget.order.completedShipmentCount} / ${widget.order.shipmentCount}", - color: lineColor, - ), - ), - ); - } - + // Line items progress tiles.add( ListTile( title: Text(L10().lineItems), @@ -408,6 +391,24 @@ class _SalesOrderDetailState extends RefreshableState { ), ); + // Shipment progress + if (widget.order.shipmentCount > 0) { + tiles.add( + ListTile( + title: Text(L10().shipments), + subtitle: ProgressBar( + widget.order.completedShipmentCount.toDouble(), + maximum: widget.order.shipmentCount.toDouble(), + ), + leading: Icon(TablerIcons.cube_send), + trailing: LargeText( + "${widget.order.completedShipmentCount} / ${widget.order.shipmentCount}", + color: lineColor, + ), + ), + ); + } + // Extra line items tiles.add( ListTile( @@ -522,8 +523,8 @@ class _SalesOrderDetailState extends RefreshableState { List getTabIcons(BuildContext context) { return [ Tab(text: L10().details), - Tab(text: L10().shipments), Tab(text: L10().lineItems), + Tab(text: L10().shipments), ]; } @@ -531,8 +532,8 @@ class _SalesOrderDetailState extends RefreshableState { List getTabs(BuildContext context) { return [ ListView(children: orderTiles(context)), - PaginatedSOShipmentList({"order": widget.order.pk.toString()}), PaginatedSOLineList({"order": widget.order.pk.toString()}), + PaginatedSOShipmentList({"order": widget.order.pk.toString()}), ]; } } diff --git a/lib/widget/order/so_allocation_list.dart b/lib/widget/order/so_allocation_list.dart new file mode 100644 index 00000000..5076f071 --- /dev/null +++ b/lib/widget/order/so_allocation_list.dart @@ -0,0 +1,69 @@ +import "package:flutter/material.dart"; +import "package:inventree/api.dart"; +import "package:inventree/inventree/model.dart"; +import "package:inventree/inventree/part.dart"; +import "package:inventree/inventree/sales_order.dart"; +import "package:inventree/inventree/stock.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/widget/link_icon.dart"; +import "package:inventree/widget/paginator.dart"; + +class PaginatedSOAllocationList extends PaginatedSearchWidget { + const PaginatedSOAllocationList(Map filters) + : super(filters: filters); + + @override + String get searchTitle => L10().allocatedStock; + + @override + _PaginatedSOAllocationListState createState() => + _PaginatedSOAllocationListState(); +} + +class _PaginatedSOAllocationListState + extends PaginatedSearchState { + _PaginatedSOAllocationListState() : super(); + + @override + String get prefix => "so_allocation_"; + + @override + Map get orderingOptions => {}; + + @override + Map> get filterOptions => {}; + + @override + Future requestPage( + int limit, + int offset, + Map params, + ) async { + final page = await InvenTreeSalesOrderAllocation().listPaginated( + limit, + offset, + filters: params, + ); + + return page; + } + + @override + Widget buildItem(BuildContext context, InvenTreeModel model) { + InvenTreeSalesOrderAllocation allocation = + model as InvenTreeSalesOrderAllocation; + + InvenTreePart? part = allocation.part; + InvenTreeStockItem? stockItem = allocation.stockItem; + + return ListTile( + title: Text(part?.fullname ?? ""), + subtitle: Text(part?.description ?? ""), + onTap: () async { + stockItem?.goToDetailPage(context); + }, + leading: InvenTreeAPI().getThumbnail(allocation.part?.thumbnail ?? ""), + trailing: LargeText(stockItem?.serialOrQuantityDisplay() ?? ""), + ); + } +} diff --git a/lib/widget/order/so_line_detail.dart b/lib/widget/order/so_line_detail.dart index ae0d3025..bc6fd6fe 100644 --- a/lib/widget/order/so_line_detail.dart +++ b/lib/widget/order/so_line_detail.dart @@ -244,7 +244,7 @@ class _SOLineDetailWidgetState extends RefreshableState { } // External link - if (widget.item.link.isNotEmpty) { + if (widget.item.hasLink) { tiles.add( ListTile( title: Text(L10().link), diff --git a/lib/widget/order/so_shipment_detail.dart b/lib/widget/order/so_shipment_detail.dart new file mode 100644 index 00000000..9116fb7f --- /dev/null +++ b/lib/widget/order/so_shipment_detail.dart @@ -0,0 +1,384 @@ +/* + * Widget for displaying detail view of a single SalesOrderShipment + */ + +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/api_form.dart"; +import "package:inventree/app_colors.dart"; +import "package:inventree/inventree/sales_order.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/preferences.dart"; +import "package:inventree/widget/attachment_widget.dart"; +import "package:inventree/widget/link_icon.dart"; +import "package:inventree/widget/notes_widget.dart"; +import "package:inventree/widget/order/so_allocation_list.dart"; +import "package:inventree/widget/refreshable_state.dart"; +import "package:inventree/widget/snacks.dart"; + +class SOShipmentDetailWidget extends StatefulWidget { + const SOShipmentDetailWidget(this.shipment, {Key? key}) : super(key: key); + + final InvenTreeSalesOrderShipment shipment; + + @override + _SOShipmentDetailWidgetState createState() => _SOShipmentDetailWidgetState(); +} + +class _SOShipmentDetailWidgetState + extends RefreshableState { + _SOShipmentDetailWidgetState(); + + // The SalesOrder associated with this shipment + InvenTreeSalesOrder? order; + + int attachmentCount = 0; + bool showCameraShortcut = true; + + @override + String getAppBarTitle() => L10().shipment; + + @override + List appBarActions(BuildContext context) { + List actions = []; + + if (widget.shipment.canEdit) { + actions.add( + IconButton( + icon: Icon(TablerIcons.edit), + onPressed: () { + _editShipment(context); + }, + ), + ); + } + + return actions; + } + + Future _editShipment(BuildContext context) async { + var fields = widget.shipment.formFields(); + + fields["order"]?["hidden"] = true; + + widget.shipment.editForm( + context, + L10().shipmentEdit, + fields: fields, + onSuccess: (data) async { + refresh(context); + showSnackIcon(L10().shipmentUpdated, success: true); + }, + ); + } + + @override + Future request(BuildContext context) async { + await widget.shipment.reload(); + + showCameraShortcut = await InvenTreeSettingsManager().getBool( + INV_SO_SHOW_CAMERA, + true, + ); + + final so = await InvenTreeSalesOrder().get(widget.shipment.orderId); + + if (mounted) { + setState(() { + order = (so is InvenTreeSalesOrder ? so : null); + }); + } + + InvenTreeSalesOrderShipmentAttachment() + .countAttachments(widget.shipment.pk) + .then((int value) { + if (mounted) { + setState(() { + attachmentCount = value; + }); + } + }); + } + + /// Upload an image for this shipment + Future _uploadImage(BuildContext context) async { + InvenTreeSalesOrderShipmentAttachment() + .uploadImage(widget.shipment.pk, prefix: widget.shipment.reference) + .then((result) => refresh(context)); + } + + /// Mark this shipment as shipped + Future _sendShipment(BuildContext context) async { + Map fields = { + "shipment_date": { + "value": widget.shipment.isShipped + ? widget.shipment.shipment_date! + : DateTime.now().toIso8601String().split("T").first, + }, + "tracking_number": {"value": widget.shipment.tracking_number}, + "invoice_number": {"value": widget.shipment.invoice_number}, + }; + + launchApiForm( + context, + L10().shipmentSend, + widget.shipment.SHIP_SHIPMENT_URL, + fields, + method: "POST", + onSuccess: (data) { + refresh(context); + showSnackIcon(L10().shipmentUpdated, success: true); + }, + ); + } + + @override + List actionButtons(BuildContext context) { + List actions = []; + + if (!widget.shipment.canEdit) { + // Exit early if we do not have edit permissions + return actions; + } + + if (showCameraShortcut) { + actions.add( + SpeedDialChild( + child: Icon(TablerIcons.camera, color: Colors.blue), + label: L10().takePicture, + onTap: () async { + _uploadImage(context); + }, + ), + ); + } + + // Check shipment + if (!widget.shipment.isChecked && !widget.shipment.isShipped) { + actions.add( + SpeedDialChild( + child: Icon(TablerIcons.check, color: Colors.green), + label: L10().shipmentCheck, + onTap: () async { + widget.shipment + .update(values: {"checked_by": InvenTreeAPI().userId}) + .then((_) { + showSnackIcon(L10().shipmentUpdated, success: true); + refresh(context); + }); + }, + ), + ); + } + + // Uncheck shipment + if (widget.shipment.isChecked && !widget.shipment.isShipped) { + actions.add( + SpeedDialChild( + child: Icon(TablerIcons.x, color: Colors.red), + label: L10().shipmentUncheck, + onTap: () async { + widget.shipment.update(values: {"checked_by": null}).then((_) { + showSnackIcon(L10().shipmentUpdated, success: true); + refresh(context); + }); + }, + ), + ); + } + + // Send shipment + if (!widget.shipment.isShipped) { + actions.add( + SpeedDialChild( + child: Icon(TablerIcons.truck_delivery, color: Colors.green), + label: L10().shipmentSend, + onTap: () async { + _sendShipment(context); + }, + ), + ); + } + + // TODO: Cancel shipment + + return actions; + } + + List shipmentTiles(BuildContext context) { + List tiles = []; + + final bool checked = widget.shipment.isChecked; + final bool shipped = widget.shipment.isShipped; + final bool delivered = widget.shipment.isDelivered; + + // Order information + if (order != null) { + // Add SalesOrder information + + tiles.add( + Card( + child: ListTile( + title: Text(order!.reference), + subtitle: Text(order!.description), + leading: api.getThumbnail(order!.customer?.thumbnail ?? ""), + trailing: LargeText( + api.SalesOrderStatus.label(order!.status), + color: api.SalesOrderStatus.color(order!.status), + ), + onTap: () { + order!.goToDetailPage(context); + }, + ), + ), + ); + } + + // Shipment reference number + tiles.add( + ListTile( + title: Text(L10().shipmentReference), + trailing: LargeText(widget.shipment.reference), + leading: Icon(TablerIcons.hash), + ), + ); + + if (widget.shipment.invoice_number.isNotEmpty) { + tiles.add( + ListTile( + title: Text(L10().invoiceNumber), + trailing: LargeText(widget.shipment.invoice_number), + leading: Icon(TablerIcons.invoice), + ), + ); + } + + // Tracking Number + if (widget.shipment.tracking_number.isNotEmpty) { + tiles.add( + ListTile( + title: Text(L10().trackingNumber), + trailing: LargeText(widget.shipment.tracking_number), + leading: Icon(TablerIcons.truck_delivery), + ), + ); + } + + if (checked || !shipped) { + tiles.add( + ListTile( + title: Text(L10().shipmentChecked), + trailing: LargeText( + checked ? L10().yes : L10().no, + color: checked ? COLOR_SUCCESS : COLOR_WARNING, + ), + leading: Icon( + checked ? TablerIcons.circle_check : TablerIcons.circle_x, + color: checked ? COLOR_SUCCESS : COLOR_WARNING, + ), + ), + ); + } + + tiles.add( + ListTile( + title: Text(L10().shipmentDate), + trailing: LargeText( + shipped ? widget.shipment.shipment_date! : L10().notApplicable, + ), + leading: Icon( + shipped ? TablerIcons.calendar_check : TablerIcons.calendar_cancel, + color: shipped ? COLOR_SUCCESS : COLOR_WARNING, + ), + ), + ); + + tiles.add( + ListTile( + title: Text(L10().deliveryDate), + trailing: LargeText( + delivered ? widget.shipment.delivery_date! : L10().notApplicable, + ), + leading: Icon( + delivered ? TablerIcons.calendar_check : TablerIcons.calendar_cancel, + color: delivered ? COLOR_SUCCESS : COLOR_WARNING, + ), + ), + ); + + // External link + if (widget.shipment.hasLink) { + tiles.add( + ListTile( + title: Text(L10().link), + leading: Icon(TablerIcons.link, color: COLOR_ACTION), + trailing: LinkIcon(), + onTap: () async { + widget.shipment.openLink(); + }, + ), + ); + } + + // Notes tile + tiles.add( + ListTile( + title: Text(L10().notes), + leading: Icon(TablerIcons.note, color: COLOR_ACTION), + trailing: LinkIcon(), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => NotesWidget(widget.shipment), + ), + ); + }, + ), + ); + + // Attachments + tiles.add( + ListTile( + title: Text(L10().attachments), + leading: Icon(TablerIcons.file, color: COLOR_ACTION), + trailing: LinkIcon( + text: attachmentCount > 0 ? attachmentCount.toString() : null, + ), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AttachmentWidget( + InvenTreeSalesOrderShipmentAttachment(), + widget.shipment.pk, + widget.shipment.reference, + widget.shipment.canEdit, + ), + ), + ); + }, + ), + ); + + return tiles; + } + + @override + List getTabIcons(BuildContext context) { + return [Tab(text: L10().details), Tab(text: L10().allocatedStock)]; + } + + @override + List getTabs(BuildContext context) { + return [ + ListView(children: shipmentTiles(context)), + PaginatedSOAllocationList({ + "order": widget.shipment.orderId.toString(), + "shipment": widget.shipment.pk.toString(), + }), + ]; + } +} diff --git a/lib/widget/order/so_shipment_list.dart b/lib/widget/order/so_shipment_list.dart index 126e247e..221d7485 100644 --- a/lib/widget/order/so_shipment_list.dart +++ b/lib/widget/order/so_shipment_list.dart @@ -7,6 +7,35 @@ import "package:inventree/widget/paginator.dart"; import "package:inventree/inventree/model.dart"; import "package:inventree/l10.dart"; +import "package:inventree/widget/refreshable_state.dart"; + +class SOShipmentListWidget extends StatefulWidget { + const SOShipmentListWidget({ + this.title = "", + this.filters = const {}, + Key? key, + }) : super(key: key); + + final Map filters; + + final String title; + + @override + _SOShipmentListWidgetState createState() => _SOShipmentListWidgetState(); +} + +class _SOShipmentListWidgetState + extends RefreshableState { + _SOShipmentListWidgetState(); + + @override + String getAppBarTitle() => widget.title; + + @override + Widget getBody(BuildContext context) { + return PaginatedSOShipmentList(widget.filters); + } +} class PaginatedSOShipmentList extends PaginatedSearchWidget { const PaginatedSOShipmentList(Map filters) @@ -51,15 +80,21 @@ class _PaginatedSOShipmentListState Widget buildItem(BuildContext context, InvenTreeModel model) { InvenTreeSalesOrderShipment shipment = model as InvenTreeSalesOrderShipment; + InvenTreeSalesOrder? order = shipment.order; return ListTile( - title: Text(shipment.reference), - subtitle: Text(shipment.tracking_number), - leading: shipment.shipped + title: Text( + "${order?.reference ?? L10().salesOrder} - ${shipment.reference}", + ), + subtitle: Text(order?.description ?? L10().description), + onTap: () async { + shipment.goToDetailPage(context); + }, + leading: shipment.isShipped ? Icon(TablerIcons.calendar_check, color: COLOR_SUCCESS) : Icon(TablerIcons.calendar_cancel, color: COLOR_WARNING), - trailing: shipment.shipped + trailing: shipment.isShipped ? LargeText(shipment.shipment_date ?? "") - : null, + : LargeText(L10().pending), ); } } diff --git a/lib/widget/part/part_detail.dart b/lib/widget/part/part_detail.dart index 5d59aeda..e6c7d6e6 100644 --- a/lib/widget/part/part_detail.dart +++ b/lib/widget/part/part_detail.dart @@ -524,7 +524,7 @@ class _PartDisplayState extends RefreshableState { } // External link? - if (part.link.isNotEmpty) { + if (part.hasLink) { tiles.add( ListTile( title: Text("${part.link}"), diff --git a/lib/widget/stock/stock_detail.dart b/lib/widget/stock/stock_detail.dart index e91bd989..19f1592e 100644 --- a/lib/widget/stock/stock_detail.dart +++ b/lib/widget/stock/stock_detail.dart @@ -750,7 +750,7 @@ class _StockItemDisplayState extends RefreshableState { ); } - if (widget.item.link.isNotEmpty) { + if (widget.item.hasLink) { tiles.add( ListTile( title: Text("${widget.item.link}"), diff --git a/pubspec.yaml b/pubspec.yaml index 0b9b3d55..92de2677 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: inventree description: InvenTree stock management -version: 0.19.3+102 +version: 0.20.0+103 environment: sdk: ^3.8.1