mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +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:
		| @@ -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" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user