mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
[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
This commit is contained in:
parent
cfa06cd01e
commit
d3a2eced97
@ -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
|
||||
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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']
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
},
|
||||
|
12
src/frontend/src/components/charts/colors.tsx
Normal file
12
src/frontend/src/components/charts/colors.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
export const CHART_COLORS: string[] = [
|
||||
'#ffa8a8',
|
||||
'#8ce99a',
|
||||
'#74c0fc',
|
||||
'#ffe066',
|
||||
'#63e6be',
|
||||
'#ffc078',
|
||||
'#d8f5a2',
|
||||
'#66d9e8',
|
||||
'#e599f7',
|
||||
'#dee2e6'
|
||||
];
|
@ -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':
|
||||
|
@ -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 (
|
||||
<Anchor href={link} target="_blank">
|
||||
{text}
|
||||
</Anchor>
|
||||
);
|
||||
} else {
|
||||
return text;
|
||||
}
|
||||
}, [link, text]);
|
||||
|
||||
return (
|
||||
<Group align={align ?? 'left'} spacing="xs" noWrap={true}>
|
||||
<ApiImage
|
||||
@ -39,7 +53,7 @@ export function Thumbnail({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{text}
|
||||
{inner}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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/',
|
||||
|
||||
|
@ -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}>`);
|
||||
|
@ -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: <IconPackages />
|
||||
icon: <IconPackages />,
|
||||
content: supplierPart?.pk ? (
|
||||
<StockItemTable
|
||||
tableName="supplier-stock"
|
||||
allowAdd={false}
|
||||
params={{ supplier_part: supplierPart.pk }}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton />
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'purchaseorders',
|
||||
@ -215,8 +226,13 @@ export default function SupplierPartDetail() {
|
||||
},
|
||||
{
|
||||
name: 'pricing',
|
||||
label: t`Pricing`,
|
||||
icon: <IconCurrencyDollar />
|
||||
label: t`Supplier Pricing`,
|
||||
icon: <IconCurrencyDollar />,
|
||||
content: supplierPart?.pk ? (
|
||||
<SupplierPriceBreakTable supplierPartId={supplierPart.pk} />
|
||||
) : (
|
||||
<Skeleton />
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [supplierPart]);
|
||||
|
@ -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: <IconCurrencyDollar />
|
||||
label: t`Part Pricing`,
|
||||
icon: <IconCurrencyDollar />,
|
||||
content: part ? <PartPricingPanel part={part} /> : <Skeleton />
|
||||
},
|
||||
{
|
||||
name: 'manufacturers',
|
||||
|
115
src/frontend/src/pages/part/PartPricingPanel.tsx
Normal file
115
src/frontend/src/pages/part/PartPricingPanel.tsx
Normal file
@ -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 (
|
||||
<Stack spacing="xs">
|
||||
<LoadingOverlay visible={instanceQuery.isLoading} />
|
||||
{!pricing && !instanceQuery.isLoading && (
|
||||
<Alert color="ref" title={t`Error`}>
|
||||
<Text>{t`No pricing data found for this part.`}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{pricing && (
|
||||
<Accordion multiple defaultValue={['overview']}>
|
||||
<PricingPanel
|
||||
content={<PricingOverviewPanel part={part} pricing={pricing} />}
|
||||
label="overview"
|
||||
title={t`Pricing Overview`}
|
||||
visible={true}
|
||||
/>
|
||||
<PricingPanel
|
||||
content={<PurchaseHistoryPanel part={part} />}
|
||||
label="purchase"
|
||||
title={t`Purchase History`}
|
||||
visible={purchaseOrderPricing}
|
||||
/>
|
||||
<PricingPanel
|
||||
content={
|
||||
<PriceBreakPanel
|
||||
part={part}
|
||||
endpoint={ApiEndpoints.part_pricing_internal}
|
||||
/>
|
||||
}
|
||||
label="internal"
|
||||
title={t`Internal Pricing`}
|
||||
visible={internalPricing}
|
||||
/>
|
||||
<PricingPanel
|
||||
content={<SupplierPricingPanel part={part} />}
|
||||
label="supplier"
|
||||
title={t`Supplier Pricing`}
|
||||
visible={purchaseOrderPricing}
|
||||
/>
|
||||
<PricingPanel
|
||||
content={<BomPricingPanel part={part} pricing={pricing} />}
|
||||
label="bom"
|
||||
title={t`BOM Pricing`}
|
||||
visible={part?.assembly}
|
||||
/>
|
||||
<PricingPanel
|
||||
content={<VariantPricingPanel part={part} pricing={pricing} />}
|
||||
label="variant"
|
||||
title={t`Variant Pricing`}
|
||||
visible={part?.is_template}
|
||||
/>
|
||||
<PricingPanel
|
||||
content={
|
||||
<PriceBreakPanel
|
||||
part={part}
|
||||
endpoint={ApiEndpoints.part_pricing_sale}
|
||||
/>
|
||||
}
|
||||
label="sale-pricing"
|
||||
title={t`Sale Pricing`}
|
||||
visible={salesOrderPricing}
|
||||
/>
|
||||
<PricingPanel
|
||||
content={<SaleHistoryPanel part={part} />}
|
||||
label="sale-history"
|
||||
title={t`Sale History`}
|
||||
visible={salesOrderPricing}
|
||||
/>
|
||||
</Accordion>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
203
src/frontend/src/pages/part/pricing/BomPricingPanel.tsx
Normal file
203
src/frontend/src/pages/part/pricing/BomPricingPanel.tsx
Normal file
@ -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 (
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="total_price_min"
|
||||
nameKey="name"
|
||||
innerRadius={20}
|
||||
outerRadius={100}
|
||||
>
|
||||
{data.map((_entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={CHART_COLORS[index % CHART_COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="total_price_max"
|
||||
nameKey="name"
|
||||
innerRadius={120}
|
||||
outerRadius={240}
|
||||
>
|
||||
{data.map((_entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={CHART_COLORS[index % CHART_COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Display BOM data as a bar chart
|
||||
function BomBarChart({ data }: { data: any[] }) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<BarChart data={data}>
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="total_price_min"
|
||||
fill={CHART_COLORS[0]}
|
||||
label={t`Minimum Total Price`}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="total_price_max"
|
||||
fill={CHART_COLORS[1]}
|
||||
label={t`Maximum Total Price`}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
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<string>('pie');
|
||||
|
||||
return (
|
||||
<Stack spacing="xs">
|
||||
<SimpleGrid cols={2}>
|
||||
<InvenTreeTable
|
||||
tableState={table}
|
||||
url={apiUrl(ApiEndpoints.bom_list)}
|
||||
columns={columns}
|
||||
props={{
|
||||
params: {
|
||||
part: part?.pk,
|
||||
sub_part_detail: true,
|
||||
has_pricing: true
|
||||
},
|
||||
enableSelection: false
|
||||
}}
|
||||
/>
|
||||
{bomPricingData.length > 0 ? (
|
||||
<Stack spacing="xs">
|
||||
{chartType == 'bar' && <BomBarChart data={bomPricingData} />}
|
||||
{chartType == 'pie' && <BomPieChart data={bomPricingData} />}
|
||||
<SegmentedControl
|
||||
value={chartType}
|
||||
onChange={setChartType}
|
||||
data={[
|
||||
{ value: 'pie', label: t`Pie Chart` },
|
||||
{ value: 'bar', label: t`Bar Chart` }
|
||||
]}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<NoPricingData />
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
185
src/frontend/src/pages/part/pricing/PriceBreakPanel.tsx
Normal file
185
src/frontend/src/pages/part/pricing/PriceBreakPanel.tsx
Normal file
@ -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<number>(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 [
|
||||
<AddItemButton
|
||||
tooltip={t`Add Price Break`}
|
||||
onClick={() => {
|
||||
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}
|
||||
<SimpleGrid cols={2}>
|
||||
<InvenTreeTable
|
||||
tableState={table}
|
||||
url={tableUrl}
|
||||
columns={columns}
|
||||
props={{
|
||||
params: {
|
||||
part: part.pk
|
||||
},
|
||||
tableActions: tableActions,
|
||||
rowActions: rowActions
|
||||
}}
|
||||
/>
|
||||
{table.records.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<BarChart data={table.records}>
|
||||
<XAxis dataKey="quantity" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="price"
|
||||
fill={CHART_COLORS[0]}
|
||||
label={t`Price Break`}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<NoPricingData />
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
179
src/frontend/src/pages/part/pricing/PricingOverviewPanel.tsx
Normal file
179
src/frontend/src/pages/part/pricing/PricingOverviewPanel.tsx
Normal file
@ -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<any>[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'title',
|
||||
title: t`Pricing Category`,
|
||||
render: (record: PricingOverviewEntry) => {
|
||||
return (
|
||||
<Group position="left" spacing="xs">
|
||||
{record.icon}
|
||||
<Text weight={700}>{record.title}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
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: <IconList />,
|
||||
min_value: pricing?.internal_cost_min,
|
||||
max_value: pricing?.internal_cost_max
|
||||
},
|
||||
{
|
||||
name: 'bom',
|
||||
title: t`BOM Pricing`,
|
||||
icon: <IconChartDonut />,
|
||||
min_value: pricing?.bom_cost_min,
|
||||
max_value: pricing?.bom_cost_max
|
||||
},
|
||||
{
|
||||
name: 'purchase',
|
||||
title: t`Purchase Pricing`,
|
||||
icon: <IconShoppingCart />,
|
||||
min_value: pricing?.purchase_cost_min,
|
||||
max_value: pricing?.purchase_cost_max
|
||||
},
|
||||
{
|
||||
name: 'supplier',
|
||||
title: t`Supplier Pricing`,
|
||||
icon: <IconBuildingWarehouse />,
|
||||
min_value: pricing?.supplier_price_min,
|
||||
max_value: pricing?.supplier_price_max
|
||||
},
|
||||
{
|
||||
name: 'variants',
|
||||
title: t`Variant Pricing`,
|
||||
icon: <IconTriangleSquareCircle />,
|
||||
min_value: pricing?.variant_cost_min,
|
||||
max_value: pricing?.variant_cost_max
|
||||
},
|
||||
{
|
||||
name: 'override',
|
||||
title: t`Override Pricing`,
|
||||
icon: <IconExclamationCircle />,
|
||||
min_value: pricing?.override_min,
|
||||
max_value: pricing?.override_max
|
||||
},
|
||||
{
|
||||
name: 'overall',
|
||||
title: t`Overall Pricing`,
|
||||
icon: <IconReportAnalytics />,
|
||||
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 (
|
||||
<Stack spacing="xs">
|
||||
<SimpleGrid cols={2}>
|
||||
<Stack spacing="xs">
|
||||
{pricing?.updated && (
|
||||
<Paper p="xs">
|
||||
<Alert color="blue" title={t`Last Updated`}>
|
||||
<Text>{renderDate(pricing.updated)}</Text>
|
||||
</Alert>
|
||||
</Paper>
|
||||
)}
|
||||
<DataTable records={overviewData} columns={columns} />
|
||||
</Stack>
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<BarChart data={overviewData}>
|
||||
<XAxis dataKey="title" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="min_value"
|
||||
fill={CHART_COLORS[0]}
|
||||
label={t`Minimum Price`}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="max_value"
|
||||
fill={CHART_COLORS[1]}
|
||||
label={t`Maximum Price`}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
40
src/frontend/src/pages/part/pricing/PricingPanel.tsx
Normal file
40
src/frontend/src/pages/part/pricing/PricingPanel.tsx
Normal file
@ -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 && (
|
||||
<Accordion.Item value={label}>
|
||||
<Accordion.Control>
|
||||
<StylishText size="lg">{title}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>{content}</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function NoPricingData() {
|
||||
return (
|
||||
<Stack spacing="xs">
|
||||
<Alert icon={<IconExclamationCircle />} color="blue" title={t`No Data`}>
|
||||
<Text>{t`No pricing data available`}</Text>
|
||||
</Alert>
|
||||
<Space />
|
||||
</Stack>
|
||||
);
|
||||
}
|
149
src/frontend/src/pages/part/pricing/PurchaseHistoryPanel.tsx
Normal file
149
src/frontend/src/pages/part/pricing/PurchaseHistoryPanel.tsx
Normal file
@ -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 (
|
||||
<Group position="apart" spacing="xs">
|
||||
<Text>{price}</Text>
|
||||
{units && <Text size="xs">[{units}]</Text>}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<Group position="apart" spacing="xs">
|
||||
<Text>{price}</Text>
|
||||
{units && <Text size="xs">[{units}]</Text>}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
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 (
|
||||
<SimpleGrid cols={2}>
|
||||
<InvenTreeTable
|
||||
tableState={table}
|
||||
url={apiUrl(ApiEndpoints.purchase_order_line_list)}
|
||||
columns={columns}
|
||||
props={{
|
||||
params: {
|
||||
base_part: part.pk,
|
||||
part_detail: true,
|
||||
order_detail: true,
|
||||
has_pricing: true,
|
||||
order_complete: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{purchaseHistoryData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<BarChart data={purchaseHistoryData}>
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="unit_price"
|
||||
fill={CHART_COLORS[0]}
|
||||
label={t`Unit Price`}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="purchase_price"
|
||||
fill={CHART_COLORS[1]}
|
||||
label={t`Purchase Price`}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<NoPricingData />
|
||||
)}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
108
src/frontend/src/pages/part/pricing/SaleHistoryPanel.tsx
Normal file
108
src/frontend/src/pages/part/pricing/SaleHistoryPanel.tsx
Normal file
@ -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 (
|
||||
<SimpleGrid cols={2}>
|
||||
<InvenTreeTable
|
||||
tableState={table}
|
||||
url={apiUrl(ApiEndpoints.sales_order_line_list)}
|
||||
columns={columns}
|
||||
props={{
|
||||
params: {
|
||||
part: part.pk,
|
||||
part_detail: true,
|
||||
order_detail: true,
|
||||
customer_detail: true,
|
||||
has_pricing: true,
|
||||
order_complete: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{saleHistoryData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<BarChart data={saleHistoryData}>
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="sale_price"
|
||||
fill={CHART_COLORS[0]}
|
||||
label={t`Sale Price`}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<NoPricingData />
|
||||
)}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
80
src/frontend/src/pages/part/pricing/SupplierPricingPanel.tsx
Normal file
80
src/frontend/src/pages/part/pricing/SupplierPricingPanel.tsx
Normal file
@ -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 (
|
||||
<SimpleGrid cols={2}>
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.supplier_part_pricing_list)}
|
||||
columns={columns}
|
||||
tableState={table}
|
||||
props={{
|
||||
params: {
|
||||
base_part: part.pk,
|
||||
supplier_detail: true,
|
||||
part_detail: true
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{supplierPricingData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<BarChart data={supplierPricingData}>
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar
|
||||
dataKey="unit_price"
|
||||
fill={CHART_COLORS[0]}
|
||||
label={t`Unit Price`}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="supplier_price"
|
||||
fill="#82ca9d"
|
||||
label={t`Supplier Price`}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<NoPricingData />
|
||||
)}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
121
src/frontend/src/pages/part/pricing/VariantPricingPanel.tsx
Normal file
121
src/frontend/src/pages/part/pricing/VariantPricingPanel.tsx
Normal file
@ -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 (
|
||||
<Stack spacing="xs">
|
||||
<SimpleGrid cols={2}>
|
||||
<InvenTreeTable
|
||||
tableState={table}
|
||||
url={apiUrl(ApiEndpoints.part_list)}
|
||||
columns={columns}
|
||||
props={{
|
||||
params: {
|
||||
ancestor: part?.pk,
|
||||
has_pricing: true
|
||||
},
|
||||
enablePagination: false
|
||||
}}
|
||||
/>
|
||||
{variantPricingData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<BarChart data={variantPricingData}>
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="pmin"
|
||||
fill={CHART_COLORS[0]}
|
||||
label={t`Minimum Price`}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="pmax"
|
||||
fill={CHART_COLORS[1]}
|
||||
label={t`Maximum Price`}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<NoPricingData />
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
@ -8,7 +8,7 @@ export type TableColumn<T = any> = {
|
||||
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
|
||||
|
@ -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'])
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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<T = any> = {
|
||||
onRowClick?: (record: T, index: number, event: any) => void;
|
||||
onCellClick?: DataTableCellClickHandler<T>;
|
||||
modelType?: ModelType;
|
||||
rowStyle?: (record: T, index: number) => any;
|
||||
modelField?: string;
|
||||
};
|
||||
|
||||
@ -152,6 +153,7 @@ export function InvenTreeTable<T = any>({
|
||||
queryKey: ['options', url, tableState.tableKey],
|
||||
retry: 3,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
queryFn: async () => {
|
||||
return api
|
||||
.options(url, {
|
||||
@ -655,6 +657,7 @@ export function InvenTreeTable<T = any>({
|
||||
tableProps.enableSelection ? onSelectedRecordsChange : undefined
|
||||
}
|
||||
rowExpansion={tableProps.rowExpansion}
|
||||
rowStyle={tableProps.rowStyle}
|
||||
fetching={isFetching}
|
||||
noRecordsText={missingRecordsText}
|
||||
records={tableState.records}
|
||||
|
222
src/frontend/src/tables/purchasing/SupplierPriceBreakTable.tsx
Normal file
222
src/frontend/src/tables/purchasing/SupplierPriceBreakTable.tsx
Normal file
@ -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 (
|
||||
<Group spacing="xs" noWrap>
|
||||
<Thumbnail
|
||||
src={
|
||||
record?.supplier_detail?.thumbnail ??
|
||||
record?.supplier_detail?.image
|
||||
}
|
||||
alt={record?.supplier_detail?.name}
|
||||
size={24}
|
||||
/>
|
||||
<Text>{record.supplier_detail?.name}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'part_detail.SKU',
|
||||
title: t`SKU`,
|
||||
ordering: 'SKU',
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
render: (record: any) => {
|
||||
return (
|
||||
<Anchor
|
||||
href={getDetailUrl(ModelType.supplierpart, record.part_detail.pk)}
|
||||
>
|
||||
{record.part_detail.SKU}
|
||||
</Anchor>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<Group position="apart" spacing="xs" grow>
|
||||
<Text>{price}</Text>
|
||||
{units && <Text size="xs">[{units}]</Text>}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
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<number>(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 [
|
||||
<AddItemButton
|
||||
tooltip={t`Add Price Break`}
|
||||
onClick={() => {
|
||||
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}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.supplier_part_pricing_list)}
|
||||
columns={columns}
|
||||
tableState={table}
|
||||
props={{
|
||||
params: {
|
||||
part: supplierPartId,
|
||||
part_detail: true,
|
||||
supplier_detail: true
|
||||
},
|
||||
tableActions: tableActions,
|
||||
rowActions: rowActions
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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 }) {
|
||||
]}
|
||||
/>,
|
||||
<AddItemButton
|
||||
hidden={!user.hasAddRole(UserRoles.stock)}
|
||||
hidden={!allowAdd || !user.hasAddRole(UserRoles.stock)}
|
||||
tooltip={t`Add Stock Item`}
|
||||
onClick={() => newStockItem.open()}
|
||||
/>
|
||||
|
@ -1520,6 +1520,57 @@
|
||||
dependencies:
|
||||
"@types/tern" "*"
|
||||
|
||||
"@types/d3-array@^3.0.3":
|
||||
version "3.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.1.tgz#1f6658e3d2006c4fceac53fde464166859f8b8c5"
|
||||
integrity sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==
|
||||
|
||||
"@types/d3-color@*":
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2"
|
||||
integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==
|
||||
|
||||
"@types/d3-ease@^3.0.0":
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b"
|
||||
integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==
|
||||
|
||||
"@types/d3-interpolate@^3.0.1":
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c"
|
||||
integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==
|
||||
dependencies:
|
||||
"@types/d3-color" "*"
|
||||
|
||||
"@types/d3-path@*":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.0.tgz#2b907adce762a78e98828f0b438eaca339ae410a"
|
||||
integrity sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==
|
||||
|
||||
"@types/d3-scale@^4.0.2":
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.8.tgz#d409b5f9dcf63074464bf8ddfb8ee5a1f95945bb"
|
||||
integrity sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==
|
||||
dependencies:
|
||||
"@types/d3-time" "*"
|
||||
|
||||
"@types/d3-shape@^3.1.0":
|
||||
version "3.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.6.tgz#65d40d5a548f0a023821773e39012805e6e31a72"
|
||||
integrity sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==
|
||||
dependencies:
|
||||
"@types/d3-path" "*"
|
||||
|
||||
"@types/d3-time@*", "@types/d3-time@^3.0.0":
|
||||
version "3.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.3.tgz#3c186bbd9d12b9d84253b6be6487ca56b54f88be"
|
||||
integrity sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==
|
||||
|
||||
"@types/d3-timer@^3.0.0":
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70"
|
||||
integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==
|
||||
|
||||
"@types/estree@*", "@types/estree@1.0.5":
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4"
|
||||
@ -2162,6 +2213,77 @@ csstype@^3.0.2:
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
|
||||
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
|
||||
|
||||
"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@^3.1.6:
|
||||
version "3.2.4"
|
||||
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5"
|
||||
integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==
|
||||
dependencies:
|
||||
internmap "1 - 2"
|
||||
|
||||
"d3-color@1 - 3":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2"
|
||||
integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==
|
||||
|
||||
d3-ease@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4"
|
||||
integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
|
||||
|
||||
"d3-format@1 - 3":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641"
|
||||
integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==
|
||||
|
||||
"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
|
||||
integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
|
||||
dependencies:
|
||||
d3-color "1 - 3"
|
||||
|
||||
d3-path@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526"
|
||||
integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==
|
||||
|
||||
d3-scale@^4.0.2:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396"
|
||||
integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==
|
||||
dependencies:
|
||||
d3-array "2.10.0 - 3"
|
||||
d3-format "1 - 3"
|
||||
d3-interpolate "1.2.0 - 3"
|
||||
d3-time "2.1.1 - 3"
|
||||
d3-time-format "2 - 4"
|
||||
|
||||
d3-shape@^3.1.0:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5"
|
||||
integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==
|
||||
dependencies:
|
||||
d3-path "^3.1.0"
|
||||
|
||||
"d3-time-format@2 - 4":
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a"
|
||||
integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==
|
||||
dependencies:
|
||||
d3-time "1 - 3"
|
||||
|
||||
"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@^3.0.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7"
|
||||
integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==
|
||||
dependencies:
|
||||
d3-array "2 - 3"
|
||||
|
||||
d3-timer@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
|
||||
integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
|
||||
|
||||
date-fns@^3.6.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.6.0.tgz#f20ca4fe94f8b754951b24240676e8618c0206bf"
|
||||
@ -2184,6 +2306,11 @@ decamelize@^1.2.0:
|
||||
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
|
||||
integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
|
||||
|
||||
decimal.js-light@^2.4.1:
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934"
|
||||
integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==
|
||||
|
||||
default-require-extensions@^3.0.0:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/default-require-extensions/-/default-require-extensions-3.0.1.tgz#bfae00feeaeada68c2ae256c62540f60b80625bd"
|
||||
@ -2358,6 +2485,11 @@ esprima@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
|
||||
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
|
||||
|
||||
eventemitter3@^4.0.1:
|
||||
version "4.0.7"
|
||||
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
|
||||
integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
|
||||
|
||||
external-editor@^3.0.3:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495"
|
||||
@ -2377,6 +2509,11 @@ fast-equals@^4.0.3:
|
||||
resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-4.0.3.tgz#72884cc805ec3c6679b99875f6b7654f39f0e8c7"
|
||||
integrity sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==
|
||||
|
||||
fast-equals@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-5.0.1.tgz#a4eefe3c5d1c0d021aeed0bc10ba5e0c12ee405d"
|
||||
integrity sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==
|
||||
|
||||
figures@^3.0.0:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af"
|
||||
@ -2627,6 +2764,11 @@ inquirer@^7.3.3:
|
||||
strip-ansi "^6.0.0"
|
||||
through "^2.3.6"
|
||||
|
||||
"internmap@1 - 2":
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009"
|
||||
integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==
|
||||
|
||||
invariant@^2.2.4:
|
||||
version "2.2.4"
|
||||
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
|
||||
@ -3355,7 +3497,7 @@ react-hook-form@^7.51.2:
|
||||
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.51.2.tgz#79f7f72ee217c5114ff831012d1a7ec344096e7f"
|
||||
integrity sha512-y++lwaWjtzDt/XNnyGDQy6goHskFualmDlf+jzEZvjvz6KWDf7EboL7pUvRCzPTJd0EOPpdekYaQLEvvG6m6HA==
|
||||
|
||||
react-is@^16.13.1, react-is@^16.7.0:
|
||||
react-is@^16.10.2, react-is@^16.13.1, react-is@^16.7.0:
|
||||
version "16.13.1"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||
@ -3434,6 +3576,15 @@ react-simplemde-editor@^5.2.0:
|
||||
dependencies:
|
||||
"@types/codemirror" "~5.60.5"
|
||||
|
||||
react-smooth@^4.0.0:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/react-smooth/-/react-smooth-4.0.1.tgz#6200d8699bfe051ae40ba187988323b1449eab1a"
|
||||
integrity sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==
|
||||
dependencies:
|
||||
fast-equals "^5.0.1"
|
||||
prop-types "^15.8.1"
|
||||
react-transition-group "^4.4.5"
|
||||
|
||||
react-style-singleton@^2.2.1:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4"
|
||||
@ -3462,7 +3613,7 @@ react-transition-group@4.4.2:
|
||||
loose-envify "^1.4.0"
|
||||
prop-types "^15.6.2"
|
||||
|
||||
react-transition-group@^4.3.0:
|
||||
react-transition-group@^4.3.0, react-transition-group@^4.4.5:
|
||||
version "4.4.5"
|
||||
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1"
|
||||
integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==
|
||||
@ -3495,6 +3646,27 @@ readdirp@~3.5.0:
|
||||
dependencies:
|
||||
picomatch "^2.2.1"
|
||||
|
||||
recharts-scale@^0.4.4:
|
||||
version "0.4.5"
|
||||
resolved "https://registry.yarnpkg.com/recharts-scale/-/recharts-scale-0.4.5.tgz#0969271f14e732e642fcc5bd4ab270d6e87dd1d9"
|
||||
integrity sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==
|
||||
dependencies:
|
||||
decimal.js-light "^2.4.1"
|
||||
|
||||
recharts@^2.12.4:
|
||||
version "2.12.4"
|
||||
resolved "https://registry.yarnpkg.com/recharts/-/recharts-2.12.4.tgz#e560a57cd44ab554c99a0d93bdd58d059b309a2e"
|
||||
integrity sha512-dM4skmk4fDKEDjL9MNunxv6zcTxePGVEzRnLDXALRpfJ85JoQ0P0APJ/CoJlmnQI0gPjBlOkjzrwrfQrRST3KA==
|
||||
dependencies:
|
||||
clsx "^2.0.0"
|
||||
eventemitter3 "^4.0.1"
|
||||
lodash "^4.17.21"
|
||||
react-is "^16.10.2"
|
||||
react-smooth "^4.0.0"
|
||||
recharts-scale "^0.4.4"
|
||||
tiny-invariant "^1.3.1"
|
||||
victory-vendor "^36.6.8"
|
||||
|
||||
regenerator-runtime@^0.14.0:
|
||||
version "0.14.1"
|
||||
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f"
|
||||
@ -3847,6 +4019,11 @@ through@^2.3.6:
|
||||
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
|
||||
integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
|
||||
|
||||
tiny-invariant@^1.3.1:
|
||||
version "1.3.3"
|
||||
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
|
||||
integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==
|
||||
|
||||
tmp@^0.0.33:
|
||||
version "0.0.33"
|
||||
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
|
||||
@ -3975,6 +4152,26 @@ uuid@^8.3.2:
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
|
||||
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
|
||||
|
||||
victory-vendor@^36.6.8:
|
||||
version "36.9.2"
|
||||
resolved "https://registry.yarnpkg.com/victory-vendor/-/victory-vendor-36.9.2.tgz#668b02a448fa4ea0f788dbf4228b7e64669ff801"
|
||||
integrity sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==
|
||||
dependencies:
|
||||
"@types/d3-array" "^3.0.3"
|
||||
"@types/d3-ease" "^3.0.0"
|
||||
"@types/d3-interpolate" "^3.0.1"
|
||||
"@types/d3-scale" "^4.0.2"
|
||||
"@types/d3-shape" "^3.1.0"
|
||||
"@types/d3-time" "^3.0.0"
|
||||
"@types/d3-timer" "^3.0.0"
|
||||
d3-array "^3.1.6"
|
||||
d3-ease "^3.0.1"
|
||||
d3-interpolate "^3.0.1"
|
||||
d3-scale "^4.0.2"
|
||||
d3-shape "^3.1.0"
|
||||
d3-time "^3.0.0"
|
||||
d3-timer "^3.0.1"
|
||||
|
||||
vite-plugin-babel-macros@^1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/vite-plugin-babel-macros/-/vite-plugin-babel-macros-1.0.6.tgz#d05cee3c38c620ccb534e38f412fdd899a3365b5"
|
||||
|
2
tasks.py
2
tasks.py
@ -313,7 +313,9 @@ def remove_mfa(c, mail=''):
|
||||
def static(c, frontend=False):
|
||||
"""Copies required static files to the STATIC_ROOT directory, as per Django requirements."""
|
||||
manage(c, 'prerender')
|
||||
|
||||
if frontend and node_available():
|
||||
frontend_trans(c)
|
||||
frontend_build(c)
|
||||
|
||||
print('Collecting static files...')
|
||||
|
Loading…
Reference in New Issue
Block a user