2
0
mirror of https://github.com/inventree/inventree-app.git synced 2025-10-28 20:17:36 +00:00

SalesOrderShipment (#697)

* Add detail widget for SalesOrderShipment

* Support editing of shipment details

* Rearrange SalesOrderDetail page

* Add support for attachments against shipment model

* refactoring

* Add shipment details page

* Add user actions for shipments:

- Check / uncheck
- Take photo

* Placeholder action to send shipment

* Send shipment from app

* Display pending shipments on the home screen

* Improve rending for shipments list

* Add class definition for SalesOrderAllocation

* Display list of items allocated against SalesOrderShipment

* Bump release notse

* Click through to stock item

* Bump version number

* dart format

* cleanup

* Remove unused imports
This commit is contained in:
Oliver
2025-10-24 13:36:10 +11:00
committed by GitHub
parent 6b67cc9e50
commit 624655ec6b
22 changed files with 858 additions and 79 deletions

View File

@@ -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
---

View File

@@ -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<String, dynamic> serverInfo = {};

View File

@@ -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(

View File

@@ -601,7 +601,7 @@ class InvenTreeModel {
// POST data to update the model
Future<APIResponse> update({
Map<String, String> values = const {},
Map<String, dynamic> values = const {},
int? expectedStatusCode = 200,
}) async {
var url = path.join(URL, pk.toString());

View File

@@ -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<Object?> goToDetailPage(BuildContext context) async {
return Navigator.push(
context,
MaterialPageRoute(builder: (context) => SOShipmentDetailWidget(this)),
);
}
@override
List<String> 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, dynamic>);
}
}
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<String, dynamic> json)
: super.fromJson(json);
@override
InvenTreeModel createFromJson(Map<String, dynamic> json) =>
InvenTreeSalesOrderAllocation.fromJson(json);
@override
String get URL => "/order/so-allocation/";
@override
List<String> get rolesRequired => ["sales_order"];
@override
Map<String, String> 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<String, dynamic>);
}
}
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<String, dynamic>);
}
}
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<String, dynamic>);
}
}
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<String, dynamic>,
);
}
}
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<String, dynamic>,
);
}
}
}
/*
@@ -319,3 +453,23 @@ class InvenTreeSalesOrderAttachment extends InvenTreeAttachment {
? "attachment/"
: "order/so/attachment/";
}
class InvenTreeSalesOrderShipmentAttachment extends InvenTreeAttachment {
InvenTreeSalesOrderShipmentAttachment() : super();
InvenTreeSalesOrderShipmentAttachment.fromJson(Map<String, dynamic> json)
: super.fromJson(json);
@override
InvenTreeModel createFromJson(Map<String, dynamic> json) =>
InvenTreeSalesOrderShipmentAttachment.fromJson(json);
@override
String get REFERENCE_FIELD => "shipment";
@override
String get REF_MODEL_TYPE => "salesordershipment";
@override
String get URL => "attachment/";
}

View File

@@ -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": {}
}

View File

@@ -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";

View File

@@ -20,6 +20,7 @@ class _HomeScreenSettingsState extends State<HomeScreenSettingsWidget> {
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<HomeScreenSettingsWidget> {
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<HomeScreenSettingsWidget> {
},
),
),
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),

View File

@@ -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<AttachmentWidget> {
},
),
);
} 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);

View File

@@ -287,7 +287,7 @@ class _CompanyDetailState extends RefreshableState<CompanyDetailWidget> {
}
// External link
if (widget.company.link.isNotEmpty) {
if (widget.company.hasLink) {
tiles.add(
ListTile(
title: Text(L10().link),

View File

@@ -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();
},
),
);

View File

@@ -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();
},
),
);

View File

@@ -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<InvenTreeHomePage>
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<InvenTreeHomePage>
);
}
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<InvenTreeHomePage>
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<InvenTreeHomePage>
);
}
if (homeShowShipments && InvenTreeSalesOrderShipment().canView) {
tiles.add(
_listTile(
context,
L10().shipmentsPending,
TablerIcons.cube_send,
callback: () {
_showPendingShipments(context);
},
),
);
}
// Suppliers
if (homeShowSuppliers && InvenTreePurchaseOrder().canView) {
tiles.add(

View File

@@ -241,7 +241,7 @@ class _POLineDetailWidgetState extends RefreshableState<POLineDetailWidget> {
}
// External link
if (widget.item.link.isNotEmpty) {
if (widget.item.hasLink) {
tiles.add(
ListTile(
title: Text(L10().link),

View File

@@ -375,24 +375,7 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> {
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<SalesOrderDetailWidget> {
),
);
// 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<SalesOrderDetailWidget> {
List<Widget> 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<SalesOrderDetailWidget> {
List<Widget> 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()}),
];
}
}

View File

@@ -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<String, String> filters)
: super(filters: filters);
@override
String get searchTitle => L10().allocatedStock;
@override
_PaginatedSOAllocationListState createState() =>
_PaginatedSOAllocationListState();
}
class _PaginatedSOAllocationListState
extends PaginatedSearchState<PaginatedSOAllocationList> {
_PaginatedSOAllocationListState() : super();
@override
String get prefix => "so_allocation_";
@override
Map<String, String> get orderingOptions => {};
@override
Map<String, Map<String, dynamic>> get filterOptions => {};
@override
Future<InvenTreePageResponse?> requestPage(
int limit,
int offset,
Map<String, String> 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() ?? ""),
);
}
}

View File

@@ -244,7 +244,7 @@ class _SOLineDetailWidgetState extends RefreshableState<SoLineDetailWidget> {
}
// External link
if (widget.item.link.isNotEmpty) {
if (widget.item.hasLink) {
tiles.add(
ListTile(
title: Text(L10().link),

View File

@@ -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<SOShipmentDetailWidget> {
_SOShipmentDetailWidgetState();
// The SalesOrder associated with this shipment
InvenTreeSalesOrder? order;
int attachmentCount = 0;
bool showCameraShortcut = true;
@override
String getAppBarTitle() => L10().shipment;
@override
List<Widget> appBarActions(BuildContext context) {
List<Widget> actions = [];
if (widget.shipment.canEdit) {
actions.add(
IconButton(
icon: Icon(TablerIcons.edit),
onPressed: () {
_editShipment(context);
},
),
);
}
return actions;
}
Future<void> _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<void> 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<void> _uploadImage(BuildContext context) async {
InvenTreeSalesOrderShipmentAttachment()
.uploadImage(widget.shipment.pk, prefix: widget.shipment.reference)
.then((result) => refresh(context));
}
/// Mark this shipment as shipped
Future<void> _sendShipment(BuildContext context) async {
Map<String, dynamic> 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<SpeedDialChild> actionButtons(BuildContext context) {
List<SpeedDialChild> 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<Widget> shipmentTiles(BuildContext context) {
List<Widget> 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<Widget> getTabIcons(BuildContext context) {
return [Tab(text: L10().details), Tab(text: L10().allocatedStock)];
}
@override
List<Widget> getTabs(BuildContext context) {
return [
ListView(children: shipmentTiles(context)),
PaginatedSOAllocationList({
"order": widget.shipment.orderId.toString(),
"shipment": widget.shipment.pk.toString(),
}),
];
}
}

View File

@@ -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<String, String> filters;
final String title;
@override
_SOShipmentListWidgetState createState() => _SOShipmentListWidgetState();
}
class _SOShipmentListWidgetState
extends RefreshableState<SOShipmentListWidget> {
_SOShipmentListWidgetState();
@override
String getAppBarTitle() => widget.title;
@override
Widget getBody(BuildContext context) {
return PaginatedSOShipmentList(widget.filters);
}
}
class PaginatedSOShipmentList extends PaginatedSearchWidget {
const PaginatedSOShipmentList(Map<String, String> 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),
);
}
}

View File

@@ -524,7 +524,7 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
}
// External link?
if (part.link.isNotEmpty) {
if (part.hasLink) {
tiles.add(
ListTile(
title: Text("${part.link}"),

View File

@@ -750,7 +750,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
);
}
if (widget.item.link.isNotEmpty) {
if (widget.item.hasLink) {
tiles.add(
ListTile(
title: Text("${widget.item.link}"),

View File

@@ -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