mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-17 12:35:46 +00:00
[PUI] Part scheduling (#8163)
* Placeholder part scheduling panel * Add API endpoint definition * Add defined serializer to scheduling endpoint * Refactor add_schedule_entry * Fix field type * API tweak * Render scheduling data * Make links clickable * Correctly account for dates in the past * Cleanup table * Bump API version information * js linting
This commit is contained in:
@ -95,6 +95,7 @@ export enum ApiEndpoints {
|
||||
part_thumbs_list = 'part/thumbs/',
|
||||
part_pricing_get = 'part/:id/pricing/',
|
||||
part_serial_numbers = 'part/:id/serial-numbers/',
|
||||
part_scheduling = 'part/:id/scheduling/',
|
||||
part_pricing_internal = 'part/internal-price/',
|
||||
part_pricing_sale = 'part/sale-price/',
|
||||
part_stocktake_list = 'part/stocktake/',
|
||||
|
@ -104,6 +104,7 @@ import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
|
||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||
import { TestStatisticsTable } from '../../tables/stock/TestStatisticsTable';
|
||||
import PartPricingPanel from './PartPricingPanel';
|
||||
import PartSchedulingDetail from './PartSchedulingDetail';
|
||||
import PartStocktakeDetail from './PartStocktakeDetail';
|
||||
|
||||
/**
|
||||
@ -705,7 +706,7 @@ export default function PartDetail() {
|
||||
name: 'scheduling',
|
||||
label: t`Scheduling`,
|
||||
icon: <IconCalendarStats />,
|
||||
content: <PlaceholderPanel />,
|
||||
content: part ? <PartSchedulingDetail part={part} /> : <Skeleton />,
|
||||
hidden: !userSettings.isSet('DISPLAY_SCHEDULE_TAB')
|
||||
},
|
||||
{
|
||||
|
312
src/frontend/src/pages/part/PartSchedulingDetail.tsx
Normal file
312
src/frontend/src/pages/part/PartSchedulingDetail.tsx
Normal file
@ -0,0 +1,312 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { ChartTooltipProps, LineChart } from '@mantine/charts';
|
||||
import {
|
||||
Anchor,
|
||||
Center,
|
||||
Divider,
|
||||
DrawerOverlay,
|
||||
Loader,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { formatDate } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { navigateToLink } from '../../functions/navigation';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { TableColumn } from '../../tables/Column';
|
||||
import { DateColumn, DescriptionColumn } from '../../tables/ColumnRenderers';
|
||||
import { InvenTreeTable } from '../../tables/InvenTreeTable';
|
||||
import { TableHoverCard } from '../../tables/TableHoverCard';
|
||||
|
||||
/*
|
||||
* Render a tooltip for the chart, with correct date information
|
||||
*/
|
||||
function ChartTooltip({ label, payload }: ChartTooltipProps) {
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (label && typeof label == 'number') {
|
||||
label = formatDate(new Date(label).toISOString());
|
||||
}
|
||||
|
||||
const scheduled = payload.find((item) => item.name == 'scheduled');
|
||||
const minimum = payload.find((item) => item.name == 'minimum');
|
||||
const maximum = payload.find((item) => item.name == 'maximum');
|
||||
|
||||
return (
|
||||
<Paper px="md" py="sm" withBorder shadow="md" radius="md">
|
||||
<Text key="title">{label}</Text>
|
||||
<Divider />
|
||||
<Text key="maximum" c={maximum?.color} fz="sm">
|
||||
{t`Maximum`} : {maximum?.value}
|
||||
</Text>
|
||||
<Text key="scheduled" c={scheduled?.color} fz="sm">
|
||||
{t`Scheduled`} : {scheduled?.value}
|
||||
</Text>
|
||||
<Text key="minimum" c={minimum?.color} fz="sm">
|
||||
{t`Minimum`} : {minimum?.value}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PartSchedulingDetail({ part }: { part: any }) {
|
||||
const table = useTable('part-scheduling');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'label',
|
||||
switchable: false,
|
||||
title: t`Order`,
|
||||
render: (record: any) => {
|
||||
const url = getDetailUrl(record.model, record.model_id);
|
||||
|
||||
if (url) {
|
||||
return (
|
||||
<Anchor
|
||||
href="#"
|
||||
onClick={(event: any) => navigateToLink(url, navigate, event)}
|
||||
>
|
||||
{record.label}
|
||||
</Anchor>
|
||||
);
|
||||
} else {
|
||||
return record.label;
|
||||
}
|
||||
}
|
||||
},
|
||||
DescriptionColumn({
|
||||
accessor: 'title',
|
||||
switchable: false
|
||||
}),
|
||||
DateColumn({
|
||||
sortable: false,
|
||||
switchable: false
|
||||
}),
|
||||
{
|
||||
accessor: 'quantity',
|
||||
title: t`Quantity`,
|
||||
switchable: false,
|
||||
render: (record: any) => {
|
||||
let q = record.quantity;
|
||||
let extra: ReactNode[] = [];
|
||||
|
||||
if (record.speculative_quantity != 0) {
|
||||
q = record.speculative_quantity;
|
||||
extra.push(
|
||||
<Text
|
||||
size="sm"
|
||||
key={'speculative'}
|
||||
>{t`Quantity is speculative`}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (!record.date) {
|
||||
extra.push(
|
||||
<Text
|
||||
key={'null-date'}
|
||||
size="sm"
|
||||
>{t`No date available for provided quantity`}</Text>
|
||||
);
|
||||
} else if (new Date(record.date) < new Date()) {
|
||||
extra.push(
|
||||
<Text size="sm" key={'past-date'}>{t`Date is in the past`}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableHoverCard
|
||||
value={<Text key="quantity">{q}</Text>}
|
||||
title={t`Scheduled Quantity`}
|
||||
extra={extra}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
/* Rebuild chart data whenever the table data changes.
|
||||
* Note: We assume that the data is provided in increasing date order,
|
||||
* with "null" date entries placed first.
|
||||
*/
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// Date bounds
|
||||
let min_date: Date = new Date();
|
||||
let max_date: Date = new Date();
|
||||
|
||||
// Track stock scheduling throughout time
|
||||
let stock = part.in_stock ?? 0;
|
||||
let stock_min = stock;
|
||||
let stock_max = stock;
|
||||
|
||||
// First, iterate through each entry and find any entries without an associated date, or in the past
|
||||
table.records.forEach((record) => {
|
||||
let q = record.quantity + record.speculative_quantity;
|
||||
|
||||
if (record.date == null || new Date(record.date) < today) {
|
||||
if (q < 0) {
|
||||
stock_min += q;
|
||||
} else {
|
||||
stock_max += q;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Construct initial chart entry (for today)
|
||||
let entries: any[] = [
|
||||
{
|
||||
// date: formatDate(today.toISOString()),
|
||||
date: today.valueOf(),
|
||||
delta: 0,
|
||||
scheduled: stock,
|
||||
minimum: stock_min,
|
||||
maximum: stock_max,
|
||||
low_stock: part.minimum_stock
|
||||
}
|
||||
];
|
||||
|
||||
table.records.forEach((record) => {
|
||||
let q = record.quantity + record.speculative_quantity;
|
||||
|
||||
if (!record.date) {
|
||||
return;
|
||||
}
|
||||
|
||||
const date = new Date(record.date);
|
||||
|
||||
// In the past? Ignore this entry
|
||||
if (date < today) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update date limits
|
||||
|
||||
if (date < min_date) {
|
||||
min_date = date;
|
||||
}
|
||||
|
||||
if (date > max_date) {
|
||||
max_date = date;
|
||||
}
|
||||
|
||||
// Update stock levels
|
||||
stock += record.quantity;
|
||||
|
||||
stock_min += record.quantity;
|
||||
stock_max += record.quantity;
|
||||
|
||||
// Speculative quantities expand the expected stock range
|
||||
if (record.speculative_quantity < 0) {
|
||||
stock_min += record.speculative_quantity;
|
||||
} else if (record.speculative_quantity > 0) {
|
||||
stock_max += record.speculative_quantity;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
...record,
|
||||
date: new Date(record.date).valueOf(),
|
||||
scheduled: stock,
|
||||
minimum: stock_min,
|
||||
maximum: stock_max,
|
||||
low_stock: part.minimum_stock
|
||||
});
|
||||
});
|
||||
|
||||
return entries;
|
||||
}, [part, table.records]);
|
||||
|
||||
// Calculate the date limits of the chart
|
||||
const chartLimits: number[] = useMemo(() => {
|
||||
let min_date = new Date();
|
||||
let max_date = new Date();
|
||||
|
||||
if (chartData.length > 0) {
|
||||
min_date = new Date(chartData[0].date);
|
||||
max_date = new Date(chartData[chartData.length - 1].date);
|
||||
}
|
||||
|
||||
// Expand limits by one day on either side
|
||||
min_date.setDate(min_date.getDate() - 1);
|
||||
max_date.setDate(max_date.getDate() + 1);
|
||||
|
||||
return [min_date.valueOf(), max_date.valueOf()];
|
||||
}, [chartData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SimpleGrid cols={2}>
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.part_scheduling, part.pk)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
enableSearch: false
|
||||
}}
|
||||
/>
|
||||
{table.isLoading ? (
|
||||
<Center>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
<LineChart
|
||||
data={chartData}
|
||||
dataKey="date"
|
||||
withLegend
|
||||
withYAxis
|
||||
tooltipProps={{
|
||||
content: ({ label, payload }) => (
|
||||
<ChartTooltip label={label} payload={payload} />
|
||||
)
|
||||
}}
|
||||
yAxisLabel={t`Expected Quantity`}
|
||||
xAxisLabel={t`Date`}
|
||||
xAxisProps={{
|
||||
domain: [chartLimits[0], chartLimits[1]],
|
||||
scale: 'time',
|
||||
type: 'number',
|
||||
tickFormatter: (value: number) => {
|
||||
return formatDate(new Date(value).toISOString());
|
||||
}
|
||||
}}
|
||||
series={[
|
||||
{
|
||||
name: 'scheduled',
|
||||
label: t`Scheduled`,
|
||||
color: 'blue.6'
|
||||
},
|
||||
{
|
||||
name: 'minimum',
|
||||
label: t`Minimum`,
|
||||
color: 'yellow.6'
|
||||
},
|
||||
{
|
||||
name: 'maximum',
|
||||
label: t`Maximum`,
|
||||
color: 'teal.6'
|
||||
},
|
||||
{
|
||||
name: 'low_stock',
|
||||
label: t`Low Stock`,
|
||||
color: 'red.6'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
@ -500,7 +500,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
});
|
||||
};
|
||||
|
||||
const { data, isFetching, refetch } = useQuery({
|
||||
const { data, isFetching, isLoading, refetch } = useQuery({
|
||||
queryKey: [
|
||||
tableState.page,
|
||||
props.params,
|
||||
@ -515,8 +515,13 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
tableState.setIsLoading(isFetching);
|
||||
}, [isFetching]);
|
||||
tableState.setIsLoading(
|
||||
isFetching ||
|
||||
isLoading ||
|
||||
tableOptionQuery.isFetching ||
|
||||
tableOptionQuery.isLoading
|
||||
);
|
||||
}, [isFetching, isLoading, tableOptionQuery]);
|
||||
|
||||
// Update tableState.records when new data received
|
||||
useEffect(() => {
|
||||
|
Reference in New Issue
Block a user