diff --git a/lib/inventree/bom.dart b/lib/inventree/bom.dart new file mode 100644 index 00000000..52ad5782 --- /dev/null +++ b/lib/inventree/bom.dart @@ -0,0 +1,69 @@ + + +import "package:inventree/inventree/model.dart"; +import "package:inventree/inventree/part.dart"; + +/* + * Class representing the BomItem database model + */ +class InvenTreeBomItem extends InvenTreeModel { + + InvenTreeBomItem() : super(); + + InvenTreeBomItem.fromJson(Map json) : super.fromJson(json); + + @override + InvenTreeModel createFromJson(Map json) { + return InvenTreeBomItem.fromJson(json); + } + + @override + String get URL => "bom/"; + + @override + Map defaultListFilters() { + return { + "sub_part_detail": "true", + }; + } + + @override + Map defaultGetFilters() { + return { + "sub_part_detail": "true", + }; + } + + // Extract the 'quantity' value associated with this BomItem + double get quantity => double.tryParse(jsondata["quantity"].toString()) ?? 0; + + // Extract the ID of the related part + int get partId => int.tryParse(jsondata["part"].toString()) ?? -1; + + // Return a Part instance for the referenced part + InvenTreePart? get part { + if (jsondata.containsKey("part_detail")) { + dynamic data = jsondata["part_detail"] ?? {}; + if (data is Map) { + return InvenTreePart.fromJson(data); + } + } + + return null; + } + + // Return a Part instance for the referenced sub-part + InvenTreePart? get subPart { + if (jsondata.containsKey("sub_part_detail")) { + dynamic data = jsondata["sub_part_detail"] ?? {}; + if (data is Map) { + return InvenTreePart.fromJson(data); + } + } + + return null; +} + + // Extract the ID of the related sub-part + int get subPartId => int.tryParse(jsondata["sub_part"].toString()) ?? -1; +} \ No newline at end of file diff --git a/lib/inventree/part.dart b/lib/inventree/part.dart index 85ee6d86..8e58eaf2 100644 --- a/lib/inventree/part.dart +++ b/lib/inventree/part.dart @@ -10,6 +10,9 @@ import "package:inventree/l10.dart"; import "package:inventree/inventree/model.dart"; +/* + * Class representing the PartCategory database model + */ class InvenTreePartCategory extends InvenTreeModel { InvenTreePartCategory() : super(); @@ -70,6 +73,9 @@ class InvenTreePartCategory extends InvenTreeModel { } +/* + * Class representing the PartTestTemplate database model + */ class InvenTreePartTestTemplate extends InvenTreeModel { InvenTreePartTestTemplate() : super(); @@ -122,6 +128,9 @@ class InvenTreePartTestTemplate extends InvenTreeModel { } +/* + * Class representing the Part database model + */ class InvenTreePart extends InvenTreeModel { InvenTreePart() : super(); @@ -219,7 +228,6 @@ class InvenTreePart extends InvenTreeModel { return _supplierParts; } - // Cached list of test templates List testingTemplates = []; @@ -303,9 +311,6 @@ class InvenTreePart extends InvenTreeModel { // Get the number of units being build for this Part double get building => double.tryParse(jsondata["building"].toString()) ?? 0; - // Get the number of BOM items in this Part (if it is an assembly) - int get bomItemCount => (jsondata["bom_items"] ?? 0) as int; - // Get the number of BOMs this Part is used in (if it is a component) int get usedInCount => (jsondata["used_in"] ?? 0) as int; diff --git a/lib/inventree/stock.dart b/lib/inventree/stock.dart index 46b0c75a..0bc826fb 100644 --- a/lib/inventree/stock.dart +++ b/lib/inventree/stock.dart @@ -451,7 +451,14 @@ class InvenTreeStockItem extends InvenTreeModel { String quantityString({bool includeUnits = false}){ - String q = simpleNumberString(quantity); + String q = ""; + + if (allocated > 0) { + q += simpleNumberString(available); + q += " / "; + } + + q += simpleNumberString(quantity); if (includeUnits && units.isNotEmpty) { q += " ${units}"; @@ -460,6 +467,10 @@ class InvenTreeStockItem extends InvenTreeModel { return q; } + double get allocated => double.tryParse(jsondata["allocated"].toString()) ?? 0; + + double get available => quantity - allocated; + int get locationId => (jsondata["location"] ?? -1) as int; bool isSerialized() => serialNumber.isNotEmpty && quantity.toInt() == 1; @@ -467,9 +478,11 @@ class InvenTreeStockItem extends InvenTreeModel { String serialOrQuantityDisplay() { if (isSerialized()) { return "SN ${serialNumber}"; + } else if (allocated > 0) { + return "${available} / ${quantity}"; + } else { + return simpleNumberString(quantity); } - - return simpleNumberString(quantity); } String get locationName { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 332692ea..8183af6b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -655,6 +655,9 @@ "description": "Quantity" }, + "quantityAvailable": "Quantity Available", + "@quantityAvailable": {}, + "quantityEmpty": "Quantity is empty", "@quantityEmpty": {}, @@ -1119,6 +1122,9 @@ "valueRequired": "Value is required", "@valueRequired": {}, + "variants": "Variants", + "@variants": {}, + "version": "Version", "@version": {}, diff --git a/lib/widget/bom_list.dart b/lib/widget/bom_list.dart new file mode 100644 index 00000000..5e51feeb --- /dev/null +++ b/lib/widget/bom_list.dart @@ -0,0 +1,113 @@ + + +import "package:flutter/material.dart"; + +import "package:inventree/api.dart"; +import "package:inventree/helpers.dart"; +import "package:inventree/inventree/bom.dart"; +import "package:inventree/l10.dart"; + +import "package:inventree/inventree/model.dart"; +import "package:inventree/inventree/part.dart"; + +import "package:inventree/widget/paginator.dart"; +import "package:inventree/widget/part_detail.dart"; +import "package:inventree/widget/refreshable_state.dart"; + + +/* + * Widget for displaying a list of BomItems for the specified 'parent' Part instance + */ +class BomList extends StatefulWidget { + + const BomList(this.parent); + + final InvenTreePart parent; + + @override + _BomListState createState() => _BomListState(parent); + +} + + +class _BomListState extends RefreshableState { + + _BomListState(this.parent); + + final InvenTreePart parent; + + @override + String getAppBarTitle(BuildContext context) => L10().billOfMaterials; + + @override + Widget getBody(BuildContext context) { + return PaginatedBomList({ + "part": parent.pk.toString(), + }); + } +} + + +/* + * Create a paginated widget displaying a list of BomItem objects + */ +class PaginatedBomList extends StatefulWidget { + + const PaginatedBomList(this.filters, {this.onTotalChanged}); + + final Map filters; + + final Function(int)? onTotalChanged; + + @override + _PaginatedBomListState createState() => _PaginatedBomListState(filters, onTotalChanged); + +} + + +class _PaginatedBomListState extends PaginatedSearchState { + + _PaginatedBomListState(Map filters, this.onTotalChanged) : super(filters); + + Function(int)? onTotalChanged; + + @override + Future requestPage(int limit, int offset, Map params) async { + + final page = await InvenTreeBomItem().listPaginated(limit, offset, filters: params); + + return page; + } + + @override + Widget buildItem(BuildContext context, InvenTreeModel model) { + + InvenTreeBomItem bomItem = model as InvenTreeBomItem; + + InvenTreePart? subPart = bomItem.subPart; + + String title = subPart?.fullname ?? "error - no name"; + String description = subPart?.description ?? "error - no description"; + + return ListTile( + title: Text(title), + subtitle: Text(description), + trailing: Text( + simpleNumberString(bomItem.quantity), + style: TextStyle(fontWeight: FontWeight.bold), + ), + leading: InvenTreeAPI().getImage( + subPart?.thumbnail ?? "", + width: 40, + height: 40, + ), + onTap: subPart == null ? null : () async { + InvenTreePart().get(bomItem.subPartId).then((var part) { + if (part is InvenTreePart) { + Navigator.push(context, MaterialPageRoute(builder: (context) => PartDetailWidget(part))); + } + }); + }, + ); + } +} \ No newline at end of file diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart index 376d5480..c7e36b85 100644 --- a/lib/widget/part_detail.dart +++ b/lib/widget/part_detail.dart @@ -2,16 +2,19 @@ import "package:flutter/material.dart"; import "package:font_awesome_flutter/font_awesome_flutter.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/helpers.dart"; +import "package:inventree/inventree/part.dart"; + import "package:inventree/widget/attachment_widget.dart"; +import "package:inventree/widget/bom_list.dart"; +import "package:inventree/widget/part_list.dart"; import "package:inventree/widget/part_notes.dart"; import "package:inventree/widget/progress.dart"; -import "package:inventree/inventree/part.dart"; import "package:inventree/widget/category_display.dart"; -import "package:inventree/api.dart"; import "package:inventree/widget/refreshable_state.dart"; import "package:inventree/widget/part_image_widget.dart"; import "package:inventree/widget/snacks.dart"; @@ -41,6 +44,10 @@ class _PartDisplayState extends RefreshableState { int attachmentCount = 0; + int bomCount = 0; + + int variantCount = 0; + @override String getAppBarTitle(BuildContext context) => L10().partDetails; @@ -118,6 +125,18 @@ class _PartDisplayState extends RefreshableState { "part": part.pk.toString() } ); + + bomCount = await InvenTreePart().count( + filters: { + "in_bom_for": part.pk.toString(), + } + ); + + variantCount = await InvenTreePart().count( + filters: { + "variant_of": part.pk.toString(), + } + ); } Future _toggleStar() async { @@ -261,12 +280,41 @@ class _PartDisplayState extends RefreshableState { ); } + // Display number of "variant" parts if any exist + if (variantCount > 0) { + tiles.add( + ListTile( + title: Text(L10().variants), + leading: FaIcon(FontAwesomeIcons.shapes, color: COLOR_CLICK), + trailing: Text(variantCount.toString()), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PartList( + { + "variant_of": part.pk.toString(), + }, + title: L10().variants + ) + ) + ); + }, + ) + ); + } + tiles.add( ListTile( title: Text(L10().availableStock), subtitle: Text(L10().stockDetails), leading: FaIcon(FontAwesomeIcons.boxes, color: COLOR_CLICK), - trailing: Text(part.stockString()), + trailing: Text( + part.stockString(), + style: TextStyle( + fontWeight: FontWeight.bold, + ), + ), onTap: () { setState(() { tabIndex = 1; @@ -296,14 +344,19 @@ class _PartDisplayState extends RefreshableState { // Tiles for an "assembly" part if (part.isAssembly) { - if (part.bomItemCount > 0) { + if (bomCount > 0) { tiles.add( ListTile( title: Text(L10().billOfMaterials), - leading: FaIcon(FontAwesomeIcons.thList), - trailing: Text("${part.bomItemCount}"), + leading: FaIcon(FontAwesomeIcons.thList, color: COLOR_CLICK), + trailing: Text(bomCount.toString()), onTap: () { - // TODO + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BomList(part) + ) + ); } ) ); @@ -583,7 +636,6 @@ class _PartDisplayState extends RefreshableState { icon: FaIcon(FontAwesomeIcons.boxes), label: L10().stock ), - // TODO - Add part actions BottomNavigationBarItem( icon: FaIcon(FontAwesomeIcons.wrench), label: L10().actions, diff --git a/lib/widget/part_list.dart b/lib/widget/part_list.dart index 528273f7..2be5e557 100644 --- a/lib/widget/part_list.dart +++ b/lib/widget/part_list.dart @@ -12,23 +12,27 @@ import "package:inventree/l10.dart"; class PartList extends StatefulWidget { - const PartList(this.filters); + const PartList(this.filters, {this.title = ""}); + + final String title; final Map filters; @override - _PartListState createState() => _PartListState(filters); + _PartListState createState() => _PartListState(filters, title); } class _PartListState extends RefreshableState { - _PartListState(this.filters); + _PartListState(this.filters, this.title); + + final String title; final Map filters; @override - String getAppBarTitle(BuildContext context) => L10().parts; + String getAppBarTitle(BuildContext context) => title.isNotEmpty ? title : L10().parts; @override Widget getBody(BuildContext context) { diff --git a/lib/widget/stock_detail.dart b/lib/widget/stock_detail.dart index d8c43adb..85121036 100644 --- a/lib/widget/stock_detail.dart +++ b/lib/widget/stock_detail.dart @@ -513,7 +513,7 @@ class _StockItemDisplayState extends RefreshableState { } else { tiles.add( ListTile( - title: Text(L10().quantity), + title: item.allocated > 0 ? Text(L10().quantityAvailable) : Text(L10().quantity), leading: FaIcon(FontAwesomeIcons.cubes), trailing: Text("${item.quantityString()}"), )