From d3a2eced975abb78f7a86cbcab135d4bf1eeb2de Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 16 Apr 2024 01:05:56 +1000 Subject: [PATCH] [PUI] pricing tab (#6985) * Add recharts package - Brings us in-line with mantine v7 * Add skeleton pricing page * Fetch pricing data * Rough implementation of variant pricing chart - Needs better labels for tooltip and axis * Cleanup * More cleanup * Improve rendering * Add pricing overview * Add pie chart for BOM pricing - Needs extra work! * Split components into separate files * Backend: allow ordering parts by pricing * Bump API version * Update VariantPricingPanel: - Table drives data selection now * Refactor BomPricingPanel - Table drives the data * Allow BomItemList to be sorted by pricing too * Sort bom table * Make record index available to render function * Refactor BomPricingPanel - Better rendering of pie chart * Update pricing overview panel * Further updates - Expose "pricing_updated" column to API endpoints - Allow ordering by "pricing_updated" column * Update API endpoint for PurchaseOrderLineItem * Implement PurchaseOrderHistory panel * Cleanup PurchaseHistoryPanel * Enhance API for SupplierPriceBreak * Implement SupplierPricingPanel * Fix for getDetailUrl - Take base URL into account also! * Further fixes for getDetailUrl * Fix number form field * Implement SupplierPriceBreakTable * Tweaks for StockItemTable * Ensure frontend is translated when compiling static files * Fixes for BomPricingPanel * Simplify price rendering for bom table * Update BomItem serializer - Add pricing_min_total - Add pricing_max_total - Fix existing 1+N query issue * Use values provided by API * Fix BomItem serializer lookup * Refactor pricing charts * Fix for VariantPricingPanel * Remove unused imports * Implement SalePriceBreak table - Refactor the InternalPriceBreak table to be generic * Allow price breaks to be ordered by 'price' * Display alert for no available data * Update backend API filters * Allow ordering by customer * Implement SaleHistoryPanel * Allow user to select pie or bar chart for BOM pricing detail * Remove extra padding --- .../InvenTree/InvenTree/api_version.py | 8 +- src/backend/InvenTree/company/api.py | 8 +- src/backend/InvenTree/order/api.py | 104 +++++--- src/backend/InvenTree/part/api.py | 32 ++- src/backend/InvenTree/part/serializers.py | 40 +++- src/frontend/package.json | 1 + src/frontend/src/components/charts/colors.tsx | 12 + .../components/forms/fields/ApiFormField.tsx | 11 +- .../src/components/images/Thumbnail.tsx | 16 +- src/frontend/src/defaults/formatters.tsx | 34 ++- src/frontend/src/enums/ApiEndpoints.tsx | 6 + src/frontend/src/functions/urls.tsx | 16 +- .../src/pages/company/SupplierPartDetail.tsx | 22 +- src/frontend/src/pages/part/PartDetail.tsx | 6 +- .../src/pages/part/PartPricingPanel.tsx | 115 +++++++++ .../pages/part/pricing/BomPricingPanel.tsx | 203 ++++++++++++++++ .../pages/part/pricing/PriceBreakPanel.tsx | 185 +++++++++++++++ .../part/pricing/PricingOverviewPanel.tsx | 179 ++++++++++++++ .../src/pages/part/pricing/PricingPanel.tsx | 40 ++++ .../part/pricing/PurchaseHistoryPanel.tsx | 149 ++++++++++++ .../pages/part/pricing/SaleHistoryPanel.tsx | 108 +++++++++ .../part/pricing/SupplierPricingPanel.tsx | 80 +++++++ .../part/pricing/VariantPricingPanel.tsx | 121 ++++++++++ src/frontend/src/tables/Column.tsx | 2 +- src/frontend/src/tables/ColumnRenderers.tsx | 21 +- src/frontend/src/tables/InvenTreeTable.tsx | 5 +- .../purchasing/SupplierPriceBreakTable.tsx | 222 ++++++++++++++++++ .../src/tables/stock/StockItemTable.tsx | 14 +- src/frontend/yarn.lock | 201 +++++++++++++++- tasks.py | 2 + 30 files changed, 1894 insertions(+), 69 deletions(-) create mode 100644 src/frontend/src/components/charts/colors.tsx create mode 100644 src/frontend/src/pages/part/PartPricingPanel.tsx create mode 100644 src/frontend/src/pages/part/pricing/BomPricingPanel.tsx create mode 100644 src/frontend/src/pages/part/pricing/PriceBreakPanel.tsx create mode 100644 src/frontend/src/pages/part/pricing/PricingOverviewPanel.tsx create mode 100644 src/frontend/src/pages/part/pricing/PricingPanel.tsx create mode 100644 src/frontend/src/pages/part/pricing/PurchaseHistoryPanel.tsx create mode 100644 src/frontend/src/pages/part/pricing/SaleHistoryPanel.tsx create mode 100644 src/frontend/src/pages/part/pricing/SupplierPricingPanel.tsx create mode 100644 src/frontend/src/pages/part/pricing/VariantPricingPanel.tsx create mode 100644 src/frontend/src/tables/purchasing/SupplierPriceBreakTable.tsx diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index a2f40c6ec7..c6edfc2d96 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,17 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 186 +INVENTREE_API_VERSION = 187 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v187 - 2024-03-10 : https://github.com/inventree/InvenTree/pull/6985 + - Allow Part list endpoint to be sorted by pricing_min and pricing_max values + - Allow BomItem list endpoint to be sorted by pricing_min and pricing_max values + - Allow InternalPrice and SalePrice endpoints to be sorted by quantity + - Adds total pricing values to BomItem serializer + v186 - 2024-03-26 : https://github.com/inventree/InvenTree/pull/6855 - Adds license information to the API diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py index 8a0b839064..88b2c640bd 100644 --- a/src/backend/InvenTree/company/api.py +++ b/src/backend/InvenTree/company/api.py @@ -468,9 +468,13 @@ class SupplierPriceBreakList(ListCreateAPI): return self.serializer_class(*args, **kwargs) - filter_backends = ORDER_FILTER + filter_backends = SEARCH_ORDER_FILTER_ALIAS - ordering_fields = ['quantity'] + ordering_fields = ['quantity', 'supplier', 'SKU', 'price'] + + search_fields = ['part__SKU', 'part__supplier__name'] + + ordering_field_aliases = {'supplier': 'part__supplier__name', 'SKU': 'part__SKU'} ordering = 'quantity' diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index 8d9735fa4f..1d6692793e 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -154,11 +154,11 @@ class LineItemFilter(rest_filters.FilterSet): # Filter by order status order_status = rest_filters.NumberFilter( - label='order_status', field_name='order__status' + label=_('Order Status'), field_name='order__status' ) has_pricing = rest_filters.BooleanFilter( - label='Has Pricing', method='filter_has_pricing' + label=_('Has Pricing'), method='filter_has_pricing' ) def filter_has_pricing(self, queryset, name, value): @@ -425,9 +425,38 @@ class PurchaseOrderLineItemFilter(LineItemFilter): price_field = 'purchase_price' model = models.PurchaseOrderLineItem - fields = ['order', 'part'] + fields = [] - pending = rest_filters.BooleanFilter(label='pending', method='filter_pending') + order = rest_filters.ModelChoiceFilter( + queryset=models.PurchaseOrder.objects.all(), + field_name='order', + label=_('Order'), + ) + + order_complete = rest_filters.BooleanFilter( + label=_('Order Complete'), method='filter_order_complete' + ) + + def filter_order_complete(self, queryset, name, value): + """Filter by whether the order is 'complete' or not.""" + if str2bool(value): + return queryset.filter(order__status=PurchaseOrderStatus.COMPLETE.value) + + return queryset.exclude(order__status=PurchaseOrderStatus.COMPLETE.value) + + part = rest_filters.ModelChoiceFilter( + queryset=SupplierPart.objects.all(), field_name='part', label=_('Supplier Part') + ) + + base_part = rest_filters.ModelChoiceFilter( + queryset=Part.objects.filter(purchaseable=True), + field_name='part__part', + label=_('Internal Part'), + ) + + pending = rest_filters.BooleanFilter( + method='filter_pending', label=_('Order Pending') + ) def filter_pending(self, queryset, name, value): """Filter by "pending" status (order status = pending).""" @@ -435,7 +464,9 @@ class PurchaseOrderLineItemFilter(LineItemFilter): return queryset.filter(order__status__in=PurchaseOrderStatusGroups.OPEN) return queryset.exclude(order__status__in=PurchaseOrderStatusGroups.OPEN) - received = rest_filters.BooleanFilter(label='received', method='filter_received') + received = rest_filters.BooleanFilter( + label=_('Items Received'), method='filter_received' + ) def filter_received(self, queryset, name, value): """Filter by lines which are "received" (or "not" received). @@ -542,25 +573,6 @@ class PurchaseOrderLineItemList( serializer.data, status=status.HTTP_201_CREATED, headers=headers ) - def filter_queryset(self, queryset): - """Additional filtering options.""" - params = self.request.query_params - - queryset = super().filter_queryset(queryset) - - base_part = params.get('base_part', None) - - if base_part: - try: - base_part = Part.objects.get(pk=base_part) - - queryset = queryset.filter(part__part=base_part) - - except (ValueError, Part.DoesNotExist): - pass - - return queryset - def download_queryset(self, queryset, export_format): """Download the requested queryset as a file.""" dataset = PurchaseOrderLineItemResource().export(queryset=queryset) @@ -577,6 +589,8 @@ class PurchaseOrderLineItemList( 'MPN': 'part__manufacturer_part__MPN', 'SKU': 'part__SKU', 'part_name': 'part__part__name', + 'order': 'order__reference', + 'complete_date': 'order__complete_date', } ordering_fields = [ @@ -589,6 +603,8 @@ class PurchaseOrderLineItemList( 'SKU', 'total_price', 'target_date', + 'order', + 'complete_date', ] search_fields = [ @@ -791,7 +807,15 @@ class SalesOrderLineItemFilter(LineItemFilter): price_field = 'sale_price' model = models.SalesOrderLineItem - fields = ['order', 'part'] + fields = [] + + order = rest_filters.ModelChoiceFilter( + queryset=models.SalesOrder.objects.all(), field_name='order', label=_('Order') + ) + + part = rest_filters.ModelChoiceFilter( + queryset=Part.objects.all(), field_name='part', label=_('Part') + ) completed = rest_filters.BooleanFilter(label='completed', method='filter_completed') @@ -806,6 +830,17 @@ class SalesOrderLineItemFilter(LineItemFilter): return queryset.filter(q) return queryset.exclude(q) + order_complete = rest_filters.BooleanFilter( + label=_('Order Complete'), method='filter_order_complete' + ) + + def filter_order_complete(self, queryset, name, value): + """Filter by whether the order is 'complete' or not.""" + if str2bool(value): + return queryset.filter(order__status__in=SalesOrderStatusGroups.COMPLETE) + + return queryset.exclude(order__status__in=SalesOrderStatusGroups.COMPLETE) + class SalesOrderLineItemMixin: """Mixin class for SalesOrderLineItem endpoints.""" @@ -862,9 +897,24 @@ class SalesOrderLineItemList(SalesOrderLineItemMixin, APIDownloadMixin, ListCrea return DownloadFile(filedata, filename) - filter_backends = SEARCH_ORDER_FILTER + filter_backends = SEARCH_ORDER_FILTER_ALIAS - ordering_fields = ['part__name', 'quantity', 'reference', 'target_date'] + ordering_fields = [ + 'customer', + 'order', + 'part', + 'part__name', + 'quantity', + 'reference', + 'sale_price', + 'target_date', + ] + + ordering_field_aliases = { + 'customer': 'order__customer__name', + 'part': 'part__name', + 'order': 'order__reference', + } search_fields = ['part__name', 'quantity', 'reference'] diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index bf1b8848f7..df0a3451f4 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -38,7 +38,6 @@ from InvenTree.helpers import ( is_ajax, isNull, str2bool, - str2int, ) from InvenTree.mixins import ( CreateAPI, @@ -386,9 +385,10 @@ class PartSalePriceList(ListCreateAPI): queryset = PartSellPriceBreak.objects.all() serializer_class = part_serializers.PartSalePriceSerializer - filter_backends = [DjangoFilterBackend] - + filter_backends = SEARCH_ORDER_FILTER filterset_fields = ['part'] + ordering_fields = ['quantity', 'price'] + ordering = 'quantity' class PartInternalPriceDetail(RetrieveUpdateDestroyAPI): @@ -405,9 +405,10 @@ class PartInternalPriceList(ListCreateAPI): serializer_class = part_serializers.PartInternalPriceSerializer permission_required = 'roles.sales_order.show' - filter_backends = [DjangoFilterBackend] - + filter_backends = SEARCH_ORDER_FILTER filterset_fields = ['part'] + ordering_fields = ['quantity', 'price'] + ordering = 'quantity' class PartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView): @@ -1407,8 +1408,17 @@ class PartList(PartMixin, APIDownloadMixin, ListCreateAPI): 'category', 'last_stocktake', 'units', + 'pricing_min', + 'pricing_max', + 'pricing_updated', ] + ordering_field_aliases = { + 'pricing_min': 'pricing_data__overall_min', + 'pricing_max': 'pricing_data__overall_max', + 'pricing_updated': 'pricing_data__updated', + } + # Default ordering ordering = 'name' @@ -1939,9 +1949,19 @@ class BomList(BomMixin, ListCreateDestroyAPIView): 'inherited', 'optional', 'consumable', + 'pricing_min', + 'pricing_max', + 'pricing_min_total', + 'pricing_max_total', + 'pricing_updated', ] - ordering_field_aliases = {'sub_part': 'sub_part__name'} + ordering_field_aliases = { + 'sub_part': 'sub_part__name', + 'pricing_min': 'sub_part__pricing_data__overall_min', + 'pricing_max': 'sub_part__pricing_data__overall_max', + 'pricing_updated': 'sub_part__pricing_data__updated', + } class BomDetail(BomMixin, RetrieveUpdateDestroyAPI): diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index f02ade9ed9..7bed55d416 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -616,6 +616,7 @@ class PartSerializer( 'virtual', 'pricing_min', 'pricing_max', + 'pricing_updated', 'responsible', # Annotated fields 'allocated_to_build_orders', @@ -678,6 +679,7 @@ class PartSerializer( if not pricing: self.fields.pop('pricing_min') self.fields.pop('pricing_max') + self.fields.pop('pricing_updated') def get_api_url(self): """Return the API url associated with this serializer.""" @@ -843,6 +845,9 @@ class PartSerializer( pricing_max = InvenTree.serializers.InvenTreeMoneySerializer( source='pricing_data.overall_max', allow_null=True, read_only=True ) + pricing_updated = serializers.DateTimeField( + source='pricing_data.updated', allow_null=True, read_only=True + ) parameters = PartParameterSerializer(many=True, read_only=True) @@ -1413,6 +1418,9 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): 'part_detail', 'pricing_min', 'pricing_max', + 'pricing_min_total', + 'pricing_max_total', + 'pricing_updated', 'quantity', 'reference', 'sub_part', @@ -1451,6 +1459,9 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): if not pricing: self.fields.pop('pricing_min') self.fields.pop('pricing_max') + self.fields.pop('pricing_min_total') + self.fields.pop('pricing_max_total') + self.fields.pop('pricing_updated') quantity = InvenTree.serializers.InvenTreeDecimalField(required=True) @@ -1481,10 +1492,22 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): # Cached pricing fields pricing_min = InvenTree.serializers.InvenTreeMoneySerializer( - source='sub_part.pricing.overall_min', allow_null=True, read_only=True + source='sub_part.pricing_data.overall_min', allow_null=True, read_only=True ) + pricing_max = InvenTree.serializers.InvenTreeMoneySerializer( - source='sub_part.pricing.overall_max', allow_null=True, read_only=True + source='sub_part.pricing_data.overall_max', allow_null=True, read_only=True + ) + + pricing_min_total = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) + pricing_max_total = InvenTree.serializers.InvenTreeMoneySerializer( + allow_null=True, read_only=True + ) + + pricing_updated = serializers.DateTimeField( + source='sub_part.pricing_data.updated', allow_null=True, read_only=True ) # Annotated fields for available stock @@ -1504,6 +1527,7 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): queryset = queryset.prefetch_related('sub_part') queryset = queryset.prefetch_related('sub_part__category') + queryset = queryset.prefetch_related('sub_part__pricing_data') queryset = queryset.prefetch_related( 'sub_part__stock_items', @@ -1531,6 +1555,18 @@ class BomItemSerializer(InvenTree.serializers.InvenTreeModelSerializer): available_stock = total_stock - build_order_allocations - sales_order_allocations """ + # Annotate with the 'total pricing' information based on unit pricing and quantity + queryset = queryset.annotate( + pricing_min_total=ExpressionWrapper( + F('quantity') * F('sub_part__pricing_data__overall_min'), + output_field=models.DecimalField(), + ), + pricing_max_total=ExpressionWrapper( + F('quantity') * F('sub_part__pricing_data__overall_max'), + output_field=models.DecimalField(), + ), + ) + ref = 'sub_part__' # Annotate with the total "on order" amount for the sub-part diff --git a/src/frontend/package.json b/src/frontend/package.json index fc25530c64..702b3553f1 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -49,6 +49,7 @@ "react-router-dom": "^6.22.1", "react-select": "^5.8.0", "react-simplemde-editor": "^5.2.0", + "recharts": "^2.12.4", "styled-components": "^5.3.6", "zustand": "^4.5.1" }, diff --git a/src/frontend/src/components/charts/colors.tsx b/src/frontend/src/components/charts/colors.tsx new file mode 100644 index 0000000000..6be93493bd --- /dev/null +++ b/src/frontend/src/components/charts/colors.tsx @@ -0,0 +1,12 @@ +export const CHART_COLORS: string[] = [ + '#ffa8a8', + '#8ce99a', + '#74c0fc', + '#ffe066', + '#63e6be', + '#ffc078', + '#d8f5a2', + '#66d9e8', + '#e599f7', + '#dee2e6' +]; diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index 237ca74fdf..70e73ca5d6 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -230,17 +230,10 @@ export function ApiFormField({ id={fieldId} value={numericalValue} error={error?.message} - formatter={(value) => { - let v: any = parseFloat(value); - - if (Number.isNaN(v) || !Number.isFinite(v)) { - return value; - } - - return `${1 * v.toFixed()}`; - }} precision={definition.field_type == 'integer' ? 0 : 10} onChange={(value: number) => onChange(value)} + removeTrailingZeros + step={1} /> ); case 'choice': diff --git a/src/frontend/src/components/images/Thumbnail.tsx b/src/frontend/src/components/images/Thumbnail.tsx index 0a7d926af8..a7a8f0a984 100644 --- a/src/frontend/src/components/images/Thumbnail.tsx +++ b/src/frontend/src/components/images/Thumbnail.tsx @@ -13,6 +13,7 @@ export function Thumbnail({ src, alt = t`Thumbnail`, size = 20, + link, text, align }: { @@ -21,9 +22,22 @@ export function Thumbnail({ size?: number; text?: ReactNode; align?: string; + link?: string; }) { const backup_image = '/static/img/blank_image.png'; + const inner = useMemo(() => { + if (link) { + return ( + + {text} + + ); + } else { + return text; + } + }, [link, text]); + return ( - {text} + {inner} ); } diff --git a/src/frontend/src/defaults/formatters.tsx b/src/frontend/src/defaults/formatters.tsx index 7218586f05..563a67fdaf 100644 --- a/src/frontend/src/defaults/formatters.tsx +++ b/src/frontend/src/defaults/formatters.tsx @@ -5,11 +5,33 @@ import { useUserSettingsState } from '../states/SettingsState'; +interface formatDecmimalOptionsType { + digits?: number; + minDigits?: number; + locale?: string; +} + interface formatCurrencyOptionsType { digits?: number; minDigits?: number; currency?: string; locale?: string; + multiplier?: number; +} + +export function formatDecimal( + value: number | null | undefined, + options: formatDecmimalOptionsType = {} +) { + let locale = options.locale || navigator.language || 'en-US'; + + if (value === null || value === undefined) { + return value; + } + + let formatter = new Intl.NumberFormat(locale); + + return formatter.format(value); } /* @@ -21,13 +43,21 @@ interface formatCurrencyOptionsType { * - digits: Maximum number of significant digits (default = 10) */ export function formatCurrency( - value: number | null, + value: number | string | null | undefined, options: formatCurrencyOptionsType = {} ) { - if (value == null) { + if (value == null || value == undefined) { return null; } + value = parseFloat(value.toString()); + + if (isNaN(value) || !isFinite(value)) { + return null; + } + + value *= options.multiplier ?? 1; + const global_settings = useGlobalSettingsState.getState().lookup; let maxDigits = options.digits || global_settings.PRICING_DECIMAL_PLACES || 6; diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 7911a0d9c7..821bbd973e 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -61,6 +61,8 @@ export enum ApiEndpoints { part_parameter_template_list = 'part/parameter/template/', part_thumbs_list = 'part/thumbs/', part_pricing_get = 'part/:id/pricing/', + part_pricing_internal = 'part/internal-price/', + part_pricing_sale = 'part/sale-price/', part_stocktake_list = 'part/stocktake/', category_list = 'part/category/', category_tree = 'part/category/tree/', @@ -75,6 +77,7 @@ export enum ApiEndpoints { address_list = 'company/address/', company_attachment_list = 'company/attachment/', supplier_part_list = 'company/part/', + supplier_part_pricing_list = 'company/price-break/', manufacturer_part_list = 'company/part/manufacturer/', manufacturer_part_attachment_list = 'company/part/manufacturer/attachment/', manufacturer_part_parameter_list = 'company/part/manufacturer/parameter/', @@ -101,9 +104,12 @@ export enum ApiEndpoints { purchase_order_line_list = 'order/po-line/', purchase_order_attachment_list = 'order/po/attachment/', purchase_order_receive = 'order/po/:id/receive/', + sales_order_list = 'order/so/', + sales_order_line_list = 'order/so-line/', sales_order_attachment_list = 'order/so/attachment/', sales_order_shipment_list = 'order/so/shipment/', + return_order_list = 'order/ro/', return_order_attachment_list = 'order/ro/attachment/', diff --git a/src/frontend/src/functions/urls.tsx b/src/frontend/src/functions/urls.tsx index 55a3ae687c..ce058edd45 100644 --- a/src/frontend/src/functions/urls.tsx +++ b/src/frontend/src/functions/urls.tsx @@ -1,10 +1,15 @@ import { ModelInformationDict } from '../components/render/ModelType'; import { ModelType } from '../enums/ModelType'; +import { base_url } from '../main'; /** * Returns the detail view URL for a given model type */ -export function getDetailUrl(model: ModelType, pk: number | string): string { +export function getDetailUrl( + model: ModelType, + pk: number | string, + absolute?: boolean +): string { const modelInfo = ModelInformationDict[model]; if (pk === undefined || pk === null) { @@ -12,7 +17,14 @@ export function getDetailUrl(model: ModelType, pk: number | string): string { } if (!!pk && modelInfo && modelInfo.url_detail) { - return modelInfo.url_detail.replace(':pk', pk.toString()); + let url = modelInfo.url_detail.replace(':pk', pk.toString()); + let base = base_url; + + if (absolute && base) { + return `/${base}${url}`; + } else { + return url; + } } console.error(`No detail URL found for model ${model} <${pk}>`); diff --git a/src/frontend/src/pages/company/SupplierPartDetail.tsx b/src/frontend/src/pages/company/SupplierPartDetail.tsx index 9c98f2c02d..64731aefc1 100644 --- a/src/frontend/src/pages/company/SupplierPartDetail.tsx +++ b/src/frontend/src/pages/company/SupplierPartDetail.tsx @@ -30,6 +30,8 @@ import { useInstance } from '../../hooks/UseInstance'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable'; +import SupplierPriceBreakTable from '../../tables/purchasing/SupplierPriceBreakTable'; +import { StockItemTable } from '../../tables/stock/StockItemTable'; export default function SupplierPartDetail() { const { id } = useParams(); @@ -201,7 +203,16 @@ export default function SupplierPartDetail() { { name: 'stock', label: t`Received Stock`, - icon: + icon: , + content: supplierPart?.pk ? ( + + ) : ( + + ) }, { name: 'purchaseorders', @@ -215,8 +226,13 @@ export default function SupplierPartDetail() { }, { name: 'pricing', - label: t`Pricing`, - icon: + label: t`Supplier Pricing`, + icon: , + content: supplierPart?.pk ? ( + + ) : ( + + ) } ]; }, [supplierPart]); diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 0834dd549b..1e76fb46e0 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -84,6 +84,7 @@ import { ManufacturerPartTable } from '../../tables/purchasing/ManufacturerPartT import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable'; import { SalesOrderTable } from '../../tables/sales/SalesOrderTable'; import { StockItemTable } from '../../tables/stock/StockItemTable'; +import PartPricingPanel from './PartPricingPanel'; /** * Detail view for a single Part instance @@ -530,8 +531,9 @@ export default function PartDetail() { }, { name: 'pricing', - label: t`Pricing`, - icon: + label: t`Part Pricing`, + icon: , + content: part ? : }, { name: 'manufacturers', diff --git a/src/frontend/src/pages/part/PartPricingPanel.tsx b/src/frontend/src/pages/part/PartPricingPanel.tsx new file mode 100644 index 0000000000..0f570e681e --- /dev/null +++ b/src/frontend/src/pages/part/PartPricingPanel.tsx @@ -0,0 +1,115 @@ +import { t } from '@lingui/macro'; +import { Accordion, Alert, LoadingOverlay, Stack, Text } from '@mantine/core'; +import { ReactNode, useMemo } from 'react'; + +import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { UserRoles } from '../../enums/Roles'; +import { useInstance } from '../../hooks/UseInstance'; +import { useUserState } from '../../states/UserState'; +import BomPricingPanel from './pricing/BomPricingPanel'; +import PriceBreakPanel from './pricing/PriceBreakPanel'; +import PricingOverviewPanel from './pricing/PricingOverviewPanel'; +import PricingPanel from './pricing/PricingPanel'; +import PurchaseHistoryPanel from './pricing/PurchaseHistoryPanel'; +import SaleHistoryPanel from './pricing/SaleHistoryPanel'; +import SupplierPricingPanel from './pricing/SupplierPricingPanel'; +import VariantPricingPanel from './pricing/VariantPricingPanel'; + +export default function PartPricingPanel({ part }: { part: any }) { + const user = useUserState(); + + const { + instance: pricing, + refreshInstance, + instanceQuery + } = useInstance({ + pk: part?.pk, + hasPrimaryKey: true, + endpoint: ApiEndpoints.part_pricing_get, + defaultValue: {} + }); + + // TODO: Do we display internal price? This is a global setting + const internalPricing = true; + + const purchaseOrderPricing = useMemo(() => { + return user.hasViewRole(UserRoles.purchase_order) && part?.purchaseable; + }, [user, part]); + + const salesOrderPricing = useMemo(() => { + return user.hasViewRole(UserRoles.sales_order) && part?.salable; + }, [user, part]); + + return ( + + + {!pricing && !instanceQuery.isLoading && ( + + {t`No pricing data found for this part.`} + + )} + {pricing && ( + + } + label="overview" + title={t`Pricing Overview`} + visible={true} + /> + } + label="purchase" + title={t`Purchase History`} + visible={purchaseOrderPricing} + /> + + } + label="internal" + title={t`Internal Pricing`} + visible={internalPricing} + /> + } + label="supplier" + title={t`Supplier Pricing`} + visible={purchaseOrderPricing} + /> + } + label="bom" + title={t`BOM Pricing`} + visible={part?.assembly} + /> + } + label="variant" + title={t`Variant Pricing`} + visible={part?.is_template} + /> + + } + label="sale-pricing" + title={t`Sale Pricing`} + visible={salesOrderPricing} + /> + } + label="sale-history" + title={t`Sale History`} + visible={salesOrderPricing} + /> + + )} + + ); +} diff --git a/src/frontend/src/pages/part/pricing/BomPricingPanel.tsx b/src/frontend/src/pages/part/pricing/BomPricingPanel.tsx new file mode 100644 index 0000000000..4284e954df --- /dev/null +++ b/src/frontend/src/pages/part/pricing/BomPricingPanel.tsx @@ -0,0 +1,203 @@ +import { t } from '@lingui/macro'; +import { SegmentedControl, SimpleGrid, Stack } from '@mantine/core'; +import { ReactNode, useMemo, useState } from 'react'; +import { + Bar, + BarChart, + Cell, + Legend, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis +} from 'recharts'; + +import { CHART_COLORS } from '../../../components/charts/colors'; +import { formatDecimal, formatPriceRange } from '../../../defaults/formatters'; +import { ApiEndpoints } from '../../../enums/ApiEndpoints'; +import { useTable } from '../../../hooks/UseTable'; +import { apiUrl } from '../../../states/ApiState'; +import { TableColumn } from '../../../tables/Column'; +import { DateColumn, PartColumn } from '../../../tables/ColumnRenderers'; +import { InvenTreeTable } from '../../../tables/InvenTreeTable'; +import { NoPricingData } from './PricingPanel'; + +// Display BOM data as a pie chart +function BomPieChart({ data }: { data: any[] }) { + return ( + + + + {data.map((_entry, index) => ( + + ))} + + + {data.map((_entry, index) => ( + + ))} + + + + + ); +} + +// Display BOM data as a bar chart +function BomBarChart({ data }: { data: any[] }) { + return ( + + + + + + + + + + + ); +} + +export default function BomPricingPanel({ + part, + pricing +}: { + part: any; + pricing: any; +}): ReactNode { + const table = useTable('pricing-bom'); + + const columns: TableColumn[] = useMemo(() => { + return [ + { + accessor: 'name', + title: t`Component`, + sortable: true, + switchable: false, + render: (record: any) => PartColumn(record.sub_part_detail) + }, + { + accessor: 'quantity', + title: t`Quantity`, + sortable: true, + switchable: false, + render: (record: any) => formatDecimal(record.quantity) + }, + { + accessor: 'unit_price', + ordering: 'pricing_max', + sortable: true, + switchable: true, + title: t`Unit Price`, + render: (record: any) => { + return formatPriceRange(record.pricing_min, record.pricing_max, { + currency: pricing?.currency + }); + } + }, + { + accessor: 'total_price', + title: t`Total Price`, + ordering: 'pricing_max_total', + sortable: true, + switchable: false, + render: (record: any) => { + return formatPriceRange( + record.pricing_min_total, + record.pricing_max_total, + { + currency: pricing?.currency + } + ); + } + }, + DateColumn({ + accessor: 'pricing_updated', + title: t`Updated`, + sortable: true, + switchable: true + }) + ]; + }, [part, pricing]); + + const bomPricingData: any[] = useMemo(() => { + const pricing = table.records.map((entry: any) => { + return { + name: entry.sub_part_detail?.name, + unit_price_min: parseFloat(entry.pricing_min ?? 0), + unit_price_max: parseFloat(entry.pricing_max ?? 0), + total_price_min: parseFloat(entry.pricing_min_total ?? 0), + total_price_max: parseFloat(entry.pricing_max_total ?? 0) + }; + }); + + return pricing; + }, [table.records]); + + const [chartType, setChartType] = useState('pie'); + + return ( + + + + {bomPricingData.length > 0 ? ( + + {chartType == 'bar' && } + {chartType == 'pie' && } + + + ) : ( + + )} + + + ); +} diff --git a/src/frontend/src/pages/part/pricing/PriceBreakPanel.tsx b/src/frontend/src/pages/part/pricing/PriceBreakPanel.tsx new file mode 100644 index 0000000000..b340202b4e --- /dev/null +++ b/src/frontend/src/pages/part/pricing/PriceBreakPanel.tsx @@ -0,0 +1,185 @@ +import { t } from '@lingui/macro'; +import { Alert, SimpleGrid } from '@mantine/core'; +import { useCallback, useMemo, useState } from 'react'; +import { + Bar, + BarChart, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis +} from 'recharts'; + +import { AddItemButton } from '../../../components/buttons/AddItemButton'; +import { CHART_COLORS } from '../../../components/charts/colors'; +import { ApiFormFieldSet } from '../../../components/forms/fields/ApiFormField'; +import { formatCurrency } from '../../../defaults/formatters'; +import { ApiEndpoints } from '../../../enums/ApiEndpoints'; +import { UserRoles } from '../../../enums/Roles'; +import { + useCreateApiFormModal, + useDeleteApiFormModal, + useEditApiFormModal +} from '../../../hooks/UseForm'; +import { useTable } from '../../../hooks/UseTable'; +import { apiUrl } from '../../../states/ApiState'; +import { useUserState } from '../../../states/UserState'; +import { TableColumn } from '../../../tables/Column'; +import { InvenTreeTable } from '../../../tables/InvenTreeTable'; +import { RowDeleteAction, RowEditAction } from '../../../tables/RowActions'; +import { NoPricingData } from './PricingPanel'; + +export default function PriceBreakPanel({ + part, + endpoint +}: { + part: any; + endpoint: ApiEndpoints; +}) { + const user = useUserState(); + const table = useTable('pricing-internal'); + + const priceBreakFields: ApiFormFieldSet = useMemo(() => { + return { + part: { + disabled: true + }, + quantity: {}, + price: {}, + price_currency: {} + }; + }, []); + + const tableUrl = useMemo(() => { + return apiUrl(endpoint); + }, [endpoint]); + + const [selectedPriceBreak, setSelectedPriceBreak] = useState(0); + + const newPriceBreak = useCreateApiFormModal({ + url: tableUrl, + title: t`Add Price Break`, + fields: priceBreakFields, + initialData: { + part: part.pk + }, + onFormSuccess: (data: any) => { + table.updateRecord(data); + } + }); + + const editPriceBreak = useEditApiFormModal({ + url: tableUrl, + pk: selectedPriceBreak, + title: t`Edit Price Break`, + fields: priceBreakFields, + onFormSuccess: (data: any) => { + table.updateRecord(data); + } + }); + + const deletePriceBreak = useDeleteApiFormModal({ + url: tableUrl, + pk: selectedPriceBreak, + title: t`Delete Price Break`, + onFormSuccess: () => { + table.refreshTable(); + } + }); + + const columns: TableColumn[] = useMemo(() => { + return [ + { + accessor: 'quantity', + title: t`Quantity`, + sortable: true, + switchable: false + }, + { + accessor: 'price', + title: t`Price Break`, + sortable: true, + switchable: false, + render: (record: any) => { + return formatCurrency(record.price, { + currency: record.price_currency + }); + } + } + ]; + }, []); + + const tableActions = useMemo(() => { + return [ + { + newPriceBreak.open(); + }} + hidden={!user.hasAddRole(UserRoles.part)} + /> + ]; + }, [user]); + + const rowActions = useCallback( + (record: any) => { + return [ + RowEditAction({ + hidden: !user.hasChangeRole(UserRoles.part), + onClick: () => { + setSelectedPriceBreak(record.pk); + editPriceBreak.open(); + } + }), + RowDeleteAction({ + hidden: !user.hasDeleteRole(UserRoles.part), + onClick: () => { + setSelectedPriceBreak(record.pk); + deletePriceBreak.open(); + } + }) + ]; + }, + [user] + ); + + return ( + <> + {newPriceBreak.modal} + {editPriceBreak.modal} + {deletePriceBreak.modal} + + + {table.records.length > 0 ? ( + + + + + + + + + + ) : ( + + )} + + + ); +} diff --git a/src/frontend/src/pages/part/pricing/PricingOverviewPanel.tsx b/src/frontend/src/pages/part/pricing/PricingOverviewPanel.tsx new file mode 100644 index 0000000000..a91a4c4db5 --- /dev/null +++ b/src/frontend/src/pages/part/pricing/PricingOverviewPanel.tsx @@ -0,0 +1,179 @@ +import { t } from '@lingui/macro'; +import { Alert, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core'; +import { + IconBuildingWarehouse, + IconChartDonut, + IconExclamationCircle, + IconList, + IconReportAnalytics, + IconShoppingCart, + IconTriangleSquareCircle +} from '@tabler/icons-react'; +import { DataTable, DataTableColumn } from 'mantine-datatable'; +import { ReactNode, useMemo } from 'react'; +import { + Bar, + BarChart, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis +} from 'recharts'; + +import { CHART_COLORS } from '../../../components/charts/colors'; +import { formatCurrency, renderDate } from '../../../defaults/formatters'; + +interface PricingOverviewEntry { + icon: ReactNode; + name: string; + title: string; + min_value: number | null | undefined; + max_value: number | null | undefined; + visible?: boolean; + currency?: string | null | undefined; +} + +export default function PricingOverviewPanel({ + part, + pricing +}: { + part: any; + pricing: any; +}): ReactNode { + const columns: DataTableColumn[] = useMemo(() => { + return [ + { + accessor: 'title', + title: t`Pricing Category`, + render: (record: PricingOverviewEntry) => { + return ( + + {record.icon} + {record.title} + + ); + } + }, + { + accessor: 'min_value', + title: t`Minimum`, + render: (record: PricingOverviewEntry) => { + if (record?.min_value === null || record?.min_value === undefined) { + return '-'; + } + return formatCurrency(record?.min_value, { + currency: record.currency ?? pricing?.currency + }); + } + }, + { + accessor: 'max_value', + title: t`Maximum`, + render: (record: PricingOverviewEntry) => { + if (record?.max_value === null || record?.max_value === undefined) { + return '-'; + } + + return formatCurrency(record?.max_value, { + currency: record.currency ?? pricing?.currency + }); + } + } + ]; + }, [part, pricing]); + + const overviewData: PricingOverviewEntry[] = useMemo(() => { + return [ + { + name: 'internal', + title: t`Internal Pricing`, + icon: , + min_value: pricing?.internal_cost_min, + max_value: pricing?.internal_cost_max + }, + { + name: 'bom', + title: t`BOM Pricing`, + icon: , + min_value: pricing?.bom_cost_min, + max_value: pricing?.bom_cost_max + }, + { + name: 'purchase', + title: t`Purchase Pricing`, + icon: , + min_value: pricing?.purchase_cost_min, + max_value: pricing?.purchase_cost_max + }, + { + name: 'supplier', + title: t`Supplier Pricing`, + icon: , + min_value: pricing?.supplier_price_min, + max_value: pricing?.supplier_price_max + }, + { + name: 'variants', + title: t`Variant Pricing`, + icon: , + min_value: pricing?.variant_cost_min, + max_value: pricing?.variant_cost_max + }, + { + name: 'override', + title: t`Override Pricing`, + icon: , + min_value: pricing?.override_min, + max_value: pricing?.override_max + }, + { + name: 'overall', + title: t`Overall Pricing`, + icon: , + min_value: pricing?.overall_min, + max_value: pricing?.overall_max + } + ].filter((entry) => { + return entry.min_value !== null || entry.max_value !== null; + }); + }, [part, pricing]); + + // TODO: Add display of "last updated" + // TODO: Add "update now" button + + return ( + + + + {pricing?.updated && ( + + + {renderDate(pricing.updated)} + + + )} + + + + + + + + + + + + + + + ); +} diff --git a/src/frontend/src/pages/part/pricing/PricingPanel.tsx b/src/frontend/src/pages/part/pricing/PricingPanel.tsx new file mode 100644 index 0000000000..6199393f8d --- /dev/null +++ b/src/frontend/src/pages/part/pricing/PricingPanel.tsx @@ -0,0 +1,40 @@ +import { t } from '@lingui/macro'; +import { Accordion, Alert, Space, Stack, Text } from '@mantine/core'; +import { IconExclamationCircle } from '@tabler/icons-react'; +import { ReactNode } from 'react'; + +import { StylishText } from '../../../components/items/StylishText'; + +export default function PricingPanel({ + content, + label, + title, + visible +}: { + content: ReactNode; + label: string; + title: string; + visible: boolean; +}): ReactNode { + return ( + visible && ( + + + {title} + + {content} + + ) + ); +} + +export function NoPricingData() { + return ( + + } color="blue" title={t`No Data`}> + {t`No pricing data available`} + + + + ); +} diff --git a/src/frontend/src/pages/part/pricing/PurchaseHistoryPanel.tsx b/src/frontend/src/pages/part/pricing/PurchaseHistoryPanel.tsx new file mode 100644 index 0000000000..e71854efa9 --- /dev/null +++ b/src/frontend/src/pages/part/pricing/PurchaseHistoryPanel.tsx @@ -0,0 +1,149 @@ +import { t } from '@lingui/macro'; +import { Group, SimpleGrid, Text } from '@mantine/core'; +import { ReactNode, useCallback, useMemo } from 'react'; +import { + Bar, + BarChart, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis +} from 'recharts'; + +import { CHART_COLORS } from '../../../components/charts/colors'; +import { formatCurrency, renderDate } from '../../../defaults/formatters'; +import { ApiEndpoints } from '../../../enums/ApiEndpoints'; +import { useTable } from '../../../hooks/UseTable'; +import { apiUrl } from '../../../states/ApiState'; +import { TableColumn } from '../../../tables/Column'; +import { InvenTreeTable } from '../../../tables/InvenTreeTable'; +import { NoPricingData } from './PricingPanel'; + +export default function PurchaseHistoryPanel({ + part +}: { + part: any; +}): ReactNode { + const table = useTable('pricing-purchase-history'); + + const calculateUnitPrice = useCallback((record: any) => { + let pack_quantity = record?.supplier_part_detail?.pack_quantity_native ?? 1; + let unit_price = record.purchase_price / pack_quantity; + + return unit_price; + }, []); + + const columns: TableColumn[] = useMemo(() => { + return [ + { + accessor: 'order', + title: t`Purchase Order`, + render: (record: any) => record?.order_detail?.reference, + sortable: true, + switchable: false + }, + { + accessor: 'order_detail.complete_date', + ordering: 'complete_date', + title: t`Date`, + sortable: true, + switchable: true, + render: (record: any) => renderDate(record.order_detail.complete_date) + }, + { + accessor: 'purchase_price', + title: t`Purchase Price`, + sortable: true, + switchable: false, + render: (record: any) => { + let price = formatCurrency(record.purchase_price, { + currency: record.purchase_price_currency + }); + + let units = record.supplier_part_detail?.pack_quantity; + + return ( + + {price} + {units && [{units}]} + + ); + } + }, + { + accessor: 'unit_price', + title: t`Unit Price`, + ordering: 'purchase_price', + sortable: true, + switchable: false, + render: (record: any) => { + let price = formatCurrency(calculateUnitPrice(record), { + currency: record.purchase_price_currency + }); + + let units = record.part_detail?.units; + + return ( + + {price} + {units && [{units}]} + + ); + } + } + ]; + }, []); + + const purchaseHistoryData = useMemo(() => { + return table.records.map((record: any) => { + return { + quantity: record.quantity, + purchase_price: record.purchase_price, + unit_price: calculateUnitPrice(record), + name: record.order_detail.reference + }; + }); + }, [table.records]); + + return ( + + + {purchaseHistoryData.length > 0 ? ( + + + + + + + + + + + ) : ( + + )} + + ); +} diff --git a/src/frontend/src/pages/part/pricing/SaleHistoryPanel.tsx b/src/frontend/src/pages/part/pricing/SaleHistoryPanel.tsx new file mode 100644 index 0000000000..643cab3f11 --- /dev/null +++ b/src/frontend/src/pages/part/pricing/SaleHistoryPanel.tsx @@ -0,0 +1,108 @@ +import { t } from '@lingui/macro'; +import { SimpleGrid } from '@mantine/core'; +import { ReactNode, useMemo } from 'react'; +import { + Bar, + BarChart, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis +} from 'recharts'; + +import { CHART_COLORS } from '../../../components/charts/colors'; +import { formatCurrency, renderDate } from '../../../defaults/formatters'; +import { ApiEndpoints } from '../../../enums/ApiEndpoints'; +import { useTable } from '../../../hooks/UseTable'; +import { apiUrl } from '../../../states/ApiState'; +import { TableColumn } from '../../../tables/Column'; +import { InvenTreeTable } from '../../../tables/InvenTreeTable'; +import { NoPricingData } from './PricingPanel'; + +export default function SaleHistoryPanel({ part }: { part: any }): ReactNode { + const table = useTable('pricing-sale-history'); + + const columns: TableColumn[] = useMemo(() => { + return [ + { + accessor: 'order', + title: t`Sale Order`, + render: (record: any) => record?.order_detail?.reference, + sortable: true, + switchable: false + }, + { + accessor: 'customer', + title: t`Customer`, + sortable: true, + switchable: true, + render: (record: any) => record?.customer_detail?.name + }, + { + accessor: 'shipment_date', + title: t`Date`, + sortable: false, + switchable: true, + render: (record: any) => renderDate(record.order_detail.shipment_date) + }, + { + accessor: 'sale_price', + title: t`Sale Price`, + sortable: true, + switchable: false, + render: (record: any) => { + return formatCurrency(record.sale_price, { + currency: record.sale_price_currency + }); + } + } + ]; + }, []); + + const saleHistoryData = useMemo(() => { + return table.records.map((record: any) => { + return { + name: record.order_detail.reference, + sale_price: record.sale_price + }; + }); + }, [table.records]); + + return ( + + + {saleHistoryData.length > 0 ? ( + + + + + + + + + + ) : ( + + )} + + ); +} diff --git a/src/frontend/src/pages/part/pricing/SupplierPricingPanel.tsx b/src/frontend/src/pages/part/pricing/SupplierPricingPanel.tsx new file mode 100644 index 0000000000..23d476fe7a --- /dev/null +++ b/src/frontend/src/pages/part/pricing/SupplierPricingPanel.tsx @@ -0,0 +1,80 @@ +import { t } from '@lingui/macro'; +import { SimpleGrid } from '@mantine/core'; +import { useMemo } from 'react'; +import { + Bar, + BarChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis +} from 'recharts'; + +import { CHART_COLORS } from '../../../components/charts/colors'; +import { ApiEndpoints } from '../../../enums/ApiEndpoints'; +import { useTable } from '../../../hooks/UseTable'; +import { apiUrl } from '../../../states/ApiState'; +import { TableColumn } from '../../../tables/Column'; +import { InvenTreeTable } from '../../../tables/InvenTreeTable'; +import { + SupplierPriceBreakColumns, + calculateSupplierPartUnitPrice +} from '../../../tables/purchasing/SupplierPriceBreakTable'; +import { NoPricingData } from './PricingPanel'; + +export default function SupplierPricingPanel({ part }: { part: any }) { + const table = useTable('pricing-supplier'); + + const columns: TableColumn[] = useMemo(() => { + return SupplierPriceBreakColumns(); + }, []); + + const supplierPricingData = useMemo(() => { + return table.records.map((record: any) => { + return { + quantity: record.quantity, + supplier_price: record.price, + unit_price: calculateSupplierPartUnitPrice(record), + name: record.part_detail?.SKU + }; + }); + }, [table.records]); + + return ( + + + {supplierPricingData.length > 0 ? ( + + + + + + + + + + ) : ( + + )} + + ); +} diff --git a/src/frontend/src/pages/part/pricing/VariantPricingPanel.tsx b/src/frontend/src/pages/part/pricing/VariantPricingPanel.tsx new file mode 100644 index 0000000000..2c5c21a3c3 --- /dev/null +++ b/src/frontend/src/pages/part/pricing/VariantPricingPanel.tsx @@ -0,0 +1,121 @@ +import { t } from '@lingui/macro'; +import { SimpleGrid, Stack } from '@mantine/core'; +import { ReactNode, useMemo } from 'react'; +import { + Bar, + BarChart, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis +} from 'recharts'; + +import { CHART_COLORS } from '../../../components/charts/colors'; +import { formatCurrency } from '../../../defaults/formatters'; +import { ApiEndpoints } from '../../../enums/ApiEndpoints'; +import { useTable } from '../../../hooks/UseTable'; +import { apiUrl } from '../../../states/ApiState'; +import { TableColumn } from '../../../tables/Column'; +import { DateColumn, PartColumn } from '../../../tables/ColumnRenderers'; +import { InvenTreeTable } from '../../../tables/InvenTreeTable'; +import { NoPricingData } from './PricingPanel'; + +export default function VariantPricingPanel({ + part, + pricing +}: { + part: any; + pricing: any; +}): ReactNode { + const table = useTable('pricing-variants'); + + const columns: TableColumn[] = useMemo(() => { + return [ + { + accessor: 'name', + title: t`Variant Part`, + sortable: true, + switchable: false, + render: (record: any) => PartColumn(record) + }, + { + accessor: 'pricing_min', + title: t`Minimum Price`, + sortable: true, + switchable: false, + render: (record: any) => + formatCurrency(record.pricing_min, { currency: pricing?.currency }) + }, + { + accessor: 'pricing_max', + title: t`Maximum Price`, + sortable: true, + switchable: false, + render: (record: any) => + formatCurrency(record.pricing_max, { currency: pricing?.currency }) + }, + DateColumn({ + accessor: 'pricing_updated', + title: t`Updated`, + sortable: true, + switchable: true + }) + ]; + }, []); + + // Calculate pricing data for the part variants + const variantPricingData: any[] = useMemo(() => { + const pricing = table.records.map((variant: any) => { + return { + part: variant, + name: variant.full_name, + pmin: variant.pricing_min ?? variant.pricing_max ?? 0, + pmax: variant.pricing_max ?? variant.pricing_min ?? 0 + }; + }); + + return pricing; + }, [table.records]); + + return ( + + + + {variantPricingData.length > 0 ? ( + + + + + + + + + + + ) : ( + + )} + + + ); +} diff --git a/src/frontend/src/tables/Column.tsx b/src/frontend/src/tables/Column.tsx index de4d9ac5e7..dbd87a0e7d 100644 --- a/src/frontend/src/tables/Column.tsx +++ b/src/frontend/src/tables/Column.tsx @@ -8,7 +8,7 @@ export type TableColumn = { sortable?: boolean; // Whether the column is sortable switchable?: boolean; // Whether the column is switchable hidden?: boolean; // Whether the column is hidden - render?: (record: T) => any; // A custom render function + render?: (record: T, index?: number) => any; // A custom render function filter?: any; // A custom filter function filtering?: boolean; // Whether the column is filterable width?: number; // The width of the column diff --git a/src/frontend/src/tables/ColumnRenderers.tsx b/src/frontend/src/tables/ColumnRenderers.tsx index a7f266e8da..5353a467b8 100644 --- a/src/frontend/src/tables/ColumnRenderers.tsx +++ b/src/frontend/src/tables/ColumnRenderers.tsx @@ -148,12 +148,23 @@ export function ResponsibleColumn(): TableColumn { }; } -export function DateColumn(): TableColumn { +export function DateColumn({ + accessor, + sortable, + switchable, + title +}: { + accessor?: string; + sortable?: boolean; + switchable?: boolean; + title?: string; +}): TableColumn { return { - accessor: 'date', - sortable: true, - title: t`Date`, - render: (record: any) => renderDate(record.date) + accessor: accessor ?? 'date', + sortable: sortable ?? true, + title: title ?? t`Date`, + switchable: switchable, + render: (record: any) => renderDate(record[accessor ?? 'date']) }; } diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx index 6de23f03bc..93c17f67a8 100644 --- a/src/frontend/src/tables/InvenTreeTable.tsx +++ b/src/frontend/src/tables/InvenTreeTable.tsx @@ -14,7 +14,7 @@ import { modals } from '@mantine/modals'; import { showNotification } from '@mantine/notifications'; import { IconFilter, IconRefresh, IconTrash } from '@tabler/icons-react'; import { IconBarcode, IconPrinter } from '@tabler/icons-react'; -import { dataTagSymbol, useQuery } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { DataTable, DataTableCellClickHandler, @@ -91,6 +91,7 @@ export type InvenTreeTableProps = { onRowClick?: (record: T, index: number, event: any) => void; onCellClick?: DataTableCellClickHandler; modelType?: ModelType; + rowStyle?: (record: T, index: number) => any; modelField?: string; }; @@ -152,6 +153,7 @@ export function InvenTreeTable({ queryKey: ['options', url, tableState.tableKey], retry: 3, refetchOnMount: true, + refetchOnWindowFocus: false, queryFn: async () => { return api .options(url, { @@ -655,6 +657,7 @@ export function InvenTreeTable({ tableProps.enableSelection ? onSelectedRecordsChange : undefined } rowExpansion={tableProps.rowExpansion} + rowStyle={tableProps.rowStyle} fetching={isFetching} noRecordsText={missingRecordsText} records={tableState.records} diff --git a/src/frontend/src/tables/purchasing/SupplierPriceBreakTable.tsx b/src/frontend/src/tables/purchasing/SupplierPriceBreakTable.tsx new file mode 100644 index 0000000000..b5dcd14cbd --- /dev/null +++ b/src/frontend/src/tables/purchasing/SupplierPriceBreakTable.tsx @@ -0,0 +1,222 @@ +import { t } from '@lingui/macro'; +import { Anchor, Group, Text } from '@mantine/core'; +import { useCallback, useMemo, useState } from 'react'; + +import { AddItemButton } from '../../components/buttons/AddItemButton'; +import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField'; +import { Thumbnail } from '../../components/images/Thumbnail'; +import { formatCurrency } from '../../defaults/formatters'; +import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { ModelType } from '../../enums/ModelType'; +import { UserRoles } from '../../enums/Roles'; +import { getDetailUrl } from '../../functions/urls'; +import { + useCreateApiFormModal, + useDeleteApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; +import { useTable } from '../../hooks/UseTable'; +import { apiUrl } from '../../states/ApiState'; +import { useUserState } from '../../states/UserState'; +import { TableColumn } from '../Column'; +import { InvenTreeTable } from '../InvenTreeTable'; +import { RowDeleteAction, RowEditAction } from '../RowActions'; + +export function calculateSupplierPartUnitPrice(record: any) { + let pack_quantity = record?.part_detail?.pack_quantity_native ?? 1; + let unit_price = record.price / pack_quantity; + + return unit_price; +} + +export function SupplierPriceBreakColumns(): TableColumn[] { + return [ + { + accessor: 'supplier', + title: t`Supplier`, + sortable: true, + switchable: true, + render: (record: any) => { + return ( + + + {record.supplier_detail?.name} + + ); + } + }, + { + accessor: 'part_detail.SKU', + title: t`SKU`, + ordering: 'SKU', + sortable: true, + switchable: false, + render: (record: any) => { + return ( + + {record.part_detail.SKU} + + ); + } + }, + { + accessor: 'quantity', + title: t`Quantity`, + sortable: true, + switchable: false + }, + { + accessor: 'price', + title: t`Supplier Price`, + render: (record: any) => + formatCurrency(record.price, { currency: record.price_currency }), + sortable: true, + switchable: false + }, + { + accessor: 'unit_price', + ordering: 'price', + title: t`Unit Price`, + sortable: true, + switchable: true, + render: (record: any) => { + let units = record.part_detail?.pack_quantity; + + let price = formatCurrency(calculateSupplierPartUnitPrice(record), { + currency: record.price_currency + }); + + return ( + + {price} + {units && [{units}]} + + ); + } + } + ]; +} + +export default function SupplierPriceBreakTable({ + supplierPartId +}: { + supplierPartId: number; +}) { + const table = useTable('supplierpricebreaks'); + + const user = useUserState(); + + const columns: TableColumn[] = useMemo(() => { + return SupplierPriceBreakColumns(); + }, []); + + const supplierPriceBreakFields: ApiFormFieldSet = useMemo(() => { + return { + part: { + hidden: false, + disabled: true + }, + quantity: {}, + price: {}, + price_currency: {} + }; + }, []); + + const [selectedPriceBreak, setSelectedPriceBreak] = useState(0); + + const newPriceBreak = useCreateApiFormModal({ + url: apiUrl(ApiEndpoints.supplier_part_pricing_list), + title: t`Add Price Break`, + fields: supplierPriceBreakFields, + initialData: { + part: supplierPartId + }, + onFormSuccess: (data: any) => { + table.refreshTable(); + } + }); + + const editPriceBreak = useEditApiFormModal({ + url: apiUrl(ApiEndpoints.supplier_part_pricing_list), + pk: selectedPriceBreak, + title: t`Edit Price Break`, + fields: supplierPriceBreakFields, + onFormSuccess: (data: any) => { + table.refreshTable(); + } + }); + + const deletePriceBreak = useDeleteApiFormModal({ + url: apiUrl(ApiEndpoints.supplier_part_pricing_list), + pk: selectedPriceBreak, + title: t`Delete Price Break`, + onFormSuccess: () => { + table.refreshTable(); + } + }); + + const tableActions = useMemo(() => { + return [ + { + newPriceBreak.open(); + }} + hidden={!user.hasAddRole(UserRoles.part)} + /> + ]; + }, [user]); + + const rowActions = useCallback( + (record: any) => { + return [ + RowEditAction({ + hidden: !user.hasChangeRole(UserRoles.purchase_order), + onClick: () => { + setSelectedPriceBreak(record.pk); + editPriceBreak.open(); + } + }), + RowDeleteAction({ + hidden: !user.hasDeleteRole(UserRoles.purchase_order), + onClick: () => { + setSelectedPriceBreak(record.pk); + deletePriceBreak.open(); + } + }) + ]; + }, + [user] + ); + + return ( + <> + {newPriceBreak.modal} + {editPriceBreak.modal} + {deletePriceBreak.modal} + + + ); +} diff --git a/src/frontend/src/tables/stock/StockItemTable.tsx b/src/frontend/src/tables/stock/StockItemTable.tsx index 36546c21bb..3ac78560a2 100644 --- a/src/frontend/src/tables/stock/StockItemTable.tsx +++ b/src/frontend/src/tables/stock/StockItemTable.tsx @@ -342,11 +342,19 @@ function stockItemTableFilters(): TableFilter[] { /* * Load a table of stock items */ -export function StockItemTable({ params = {} }: { params?: any }) { +export function StockItemTable({ + params = {}, + allowAdd = true, + tableName = 'stockitems' +}: { + params?: any; + allowAdd?: boolean; + tableName?: string; +}) { let tableColumns = useMemo(() => stockItemTableColumns(), []); let tableFilters = useMemo(() => stockItemTableFilters(), []); - const table = useTable('stockitems'); + const table = useTable(tableName); const user = useUserState(); const navigate = useNavigate(); @@ -482,7 +490,7 @@ export function StockItemTable({ params = {} }: { params?: any }) { ]} />,