mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-18 04:55:44 +00:00
[PUI] Stocktake (#7704)
* Adjust playwright test * Add StocktakeReport table in the "admin" section * Allow deletion of individual stocktake reports * Add placeholder buttons * Adds placeholder panel for stocktake data * Implement <PartStocktakeTable /> * Add modal to generate a new report * Generate stocktake report from part table * Adjust table value * panel display tweaks * Improve query efficiency for supplier price breaks * Refator part stocktake detail panel * Fix role checks * Cleanup code * Fix "double loader" in <InvenTreeTable /> * API efficiency improvements * Bump API version * Tweak playwright test * Update playwright test --------- Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
@ -95,6 +95,8 @@ export enum ApiEndpoints {
|
||||
part_pricing_internal = 'part/internal-price/',
|
||||
part_pricing_sale = 'part/sale-price/',
|
||||
part_stocktake_list = 'part/stocktake/',
|
||||
part_stocktake_report_list = 'part/stocktake/report/',
|
||||
part_stocktake_report_generate = 'part/stocktake/report/generate/',
|
||||
category_list = 'part/category/',
|
||||
category_tree = 'part/category/tree/',
|
||||
category_parameter_list = 'part/category/parameters/',
|
||||
|
@ -202,3 +202,29 @@ export function usePartParameterFields({
|
||||
};
|
||||
}, [editTemplate, fieldType, choices]);
|
||||
}
|
||||
|
||||
export function partStocktakeFields(): ApiFormFieldSet {
|
||||
return {
|
||||
part: {
|
||||
hidden: true
|
||||
},
|
||||
quantity: {},
|
||||
item_count: {},
|
||||
cost_min: {},
|
||||
cost_min_currency: {},
|
||||
cost_max: {},
|
||||
cost_max_currency: {},
|
||||
note: {}
|
||||
};
|
||||
}
|
||||
|
||||
export function generateStocktakeReportFields(): ApiFormFieldSet {
|
||||
return {
|
||||
part: {},
|
||||
category: {},
|
||||
location: {},
|
||||
exclude_external: {},
|
||||
generate_report: {},
|
||||
update_parts: {}
|
||||
};
|
||||
}
|
||||
|
@ -6,10 +6,13 @@ import { IconCircleCheck, IconExclamationCircle } from '@tabler/icons-react';
|
||||
* Show a notification that the feature is not yet implemented
|
||||
*/
|
||||
export function notYetImplemented() {
|
||||
notifications.hide('not-implemented');
|
||||
|
||||
notifications.show({
|
||||
title: t`Not implemented`,
|
||||
message: t`This feature is not yet implemented`,
|
||||
color: 'red'
|
||||
color: 'red',
|
||||
id: 'not-implemented'
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconClipboardCheck,
|
||||
IconCoins,
|
||||
IconCpu,
|
||||
IconDevicesPc,
|
||||
@ -91,6 +92,8 @@ const LocationTypesTable = Loadable(
|
||||
lazy(() => import('../../../../tables/stock/LocationTypesTable'))
|
||||
);
|
||||
|
||||
const StocktakePanel = Loadable(lazy(() => import('./StocktakePanel')));
|
||||
|
||||
export default function AdminCenter() {
|
||||
const user = useUserState();
|
||||
|
||||
@ -162,6 +165,12 @@ export default function AdminCenter() {
|
||||
icon: <IconSitemap />,
|
||||
content: <PartCategoryTemplateTable />
|
||||
},
|
||||
{
|
||||
name: 'stocktake',
|
||||
label: t`Stocktake`,
|
||||
icon: <IconClipboardCheck />,
|
||||
content: <StocktakePanel />
|
||||
},
|
||||
{
|
||||
name: 'labels',
|
||||
label: t`Label Templates`,
|
||||
|
@ -0,0 +1,31 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Divider, Stack } from '@mantine/core';
|
||||
import { lazy } from 'react';
|
||||
|
||||
import { StylishText } from '../../../../components/items/StylishText';
|
||||
import { GlobalSettingList } from '../../../../components/settings/SettingList';
|
||||
import { Loadable } from '../../../../functions/loading';
|
||||
|
||||
const StocktakeReportTable = Loadable(
|
||||
lazy(() => import('../../../../tables/settings/StocktakeReportTable'))
|
||||
);
|
||||
|
||||
export default function StocktakePanel() {
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<GlobalSettingList
|
||||
keys={[
|
||||
'STOCKTAKE_ENABLE',
|
||||
'STOCKTAKE_EXCLUDE_EXTERNAL',
|
||||
'STOCKTAKE_AUTO_DAYS',
|
||||
'STOCKTAKE_DELETE_REPORT_DAYS'
|
||||
]}
|
||||
/>
|
||||
<StylishText size="lg">
|
||||
<Trans>Stocktake Reports</Trans>
|
||||
</StylishText>
|
||||
<Divider />
|
||||
<StocktakeReportTable />
|
||||
</Stack>
|
||||
);
|
||||
}
|
@ -3,7 +3,6 @@ import { Skeleton, Stack } from '@mantine/core';
|
||||
import {
|
||||
IconBellCog,
|
||||
IconCategory,
|
||||
IconClipboardCheck,
|
||||
IconCurrencyDollar,
|
||||
IconFileAnalytics,
|
||||
IconFingerprint,
|
||||
@ -225,12 +224,6 @@ export default function SystemSettings() {
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'stocktake',
|
||||
label: t`Stocktake`,
|
||||
icon: <IconClipboardCheck />,
|
||||
content: <PlaceholderPanel />
|
||||
},
|
||||
{
|
||||
name: 'buildorders',
|
||||
label: t`Build Orders`,
|
||||
|
@ -2,7 +2,9 @@ import { t } from '@lingui/macro';
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Center,
|
||||
Grid,
|
||||
Loader,
|
||||
Skeleton,
|
||||
Space,
|
||||
Stack,
|
||||
@ -80,7 +82,10 @@ import {
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||
import {
|
||||
useGlobalSettingsState,
|
||||
useUserSettingsState
|
||||
} from '../../states/SettingsState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { BomTable } from '../../tables/bom/BomTable';
|
||||
import { UsedInTable } from '../../tables/bom/UsedInTable';
|
||||
@ -99,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 PartStocktakeDetail from './PartStocktakeDetail';
|
||||
|
||||
/**
|
||||
* Detail view for a single Part instance
|
||||
@ -112,6 +118,7 @@ export default function PartDetail() {
|
||||
const [treeOpen, setTreeOpen] = useState(false);
|
||||
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
const userSettings = useUserSettingsState();
|
||||
|
||||
const {
|
||||
instance: part,
|
||||
@ -550,7 +557,7 @@ export default function PartDetail() {
|
||||
name: 'stock',
|
||||
label: t`Stock`,
|
||||
icon: <IconPackages />,
|
||||
content: part.pk && (
|
||||
content: part.pk ? (
|
||||
<StockItemTable
|
||||
tableName="part-stock"
|
||||
allowAdd
|
||||
@ -558,6 +565,10 @@ export default function PartDetail() {
|
||||
part: part.pk
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Center>
|
||||
<Loader />
|
||||
</Center>
|
||||
)
|
||||
},
|
||||
{
|
||||
@ -680,17 +691,22 @@ export default function PartDetail() {
|
||||
hidden: !part.salable,
|
||||
content: part.pk ? <SalesOrderTable partId={part.pk} /> : <Skeleton />
|
||||
},
|
||||
{
|
||||
name: 'stocktake',
|
||||
label: t`Stock History`,
|
||||
icon: <IconClipboardList />,
|
||||
content: part ? <PartStocktakeDetail partId={part.pk} /> : <Skeleton />,
|
||||
hidden:
|
||||
!user.hasViewRole(UserRoles.stocktake) ||
|
||||
!globalSettings.isSet('STOCKTAKE_ENABLE') ||
|
||||
!userSettings.isSet('DISPLAY_STOCKTAKE_TAB')
|
||||
},
|
||||
{
|
||||
name: 'scheduling',
|
||||
label: t`Scheduling`,
|
||||
icon: <IconCalendarStats />,
|
||||
content: <PlaceholderPanel />
|
||||
},
|
||||
{
|
||||
name: 'stocktake',
|
||||
label: t`Stocktake`,
|
||||
icon: <IconClipboardList />,
|
||||
content: <PlaceholderPanel />
|
||||
content: <PlaceholderPanel />,
|
||||
hidden: !userSettings.isSet('DISPLAY_SCHEDULE_TAB')
|
||||
},
|
||||
{
|
||||
name: 'test_templates',
|
||||
@ -746,7 +762,7 @@ export default function PartDetail() {
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [id, part, user]);
|
||||
}, [id, part, user, globalSettings, userSettings]);
|
||||
|
||||
// Fetch information on part revision
|
||||
const partRevisionQuery = useQuery({
|
||||
|
200
src/frontend/src/pages/part/PartStocktakeDetail.tsx
Normal file
200
src/frontend/src/pages/part/PartStocktakeDetail.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { LineChart } from '@mantine/charts';
|
||||
import { Center, Loader, SimpleGrid } from '@mantine/core';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { formatPriceRange } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import {
|
||||
generateStocktakeReportFields,
|
||||
partStocktakeFields
|
||||
} from '../../forms/PartForms';
|
||||
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';
|
||||
|
||||
export default function PartStocktakeDetail({ partId }: { partId: number }) {
|
||||
const user = useUserState();
|
||||
const table = useTable('part-stocktake');
|
||||
|
||||
const stocktakeFields = useMemo(() => partStocktakeFields(), []);
|
||||
|
||||
const [selectedStocktake, setSelectedStocktake] = useState<
|
||||
number | undefined
|
||||
>(undefined);
|
||||
|
||||
const editStocktakeEntry = useEditApiFormModal({
|
||||
pk: selectedStocktake,
|
||||
url: ApiEndpoints.part_stocktake_list,
|
||||
title: t`Edit Stocktake Entry`,
|
||||
fields: stocktakeFields,
|
||||
table: table
|
||||
});
|
||||
|
||||
const deleteStocktakeEntry = useDeleteApiFormModal({
|
||||
pk: selectedStocktake,
|
||||
url: ApiEndpoints.part_stocktake_list,
|
||||
title: t`Delete Stocktake Entry`,
|
||||
table: table
|
||||
});
|
||||
|
||||
const generateReport = useCreateApiFormModal({
|
||||
url: ApiEndpoints.part_stocktake_report_generate,
|
||||
title: t`Generate Stocktake Report`,
|
||||
fields: generateStocktakeReportFields(),
|
||||
initialData: {
|
||||
part: partId
|
||||
},
|
||||
successMessage: t`Stocktake report scheduled`
|
||||
});
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'quantity',
|
||||
sortable: true,
|
||||
switchable: false
|
||||
},
|
||||
{
|
||||
accessor: 'item_count',
|
||||
title: t`Stock Items`,
|
||||
switchable: true,
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
accessor: 'cost',
|
||||
title: t`Stock Value`,
|
||||
render: (record: any) => {
|
||||
return formatPriceRange(record.cost_min, record.cost_max, {
|
||||
currency: record.cost_min_currency
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
accessor: 'date',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
accessor: 'note'
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<AddItemButton
|
||||
tooltip={t`New Stocktake Report`}
|
||||
onClick={() => generateReport.open()}
|
||||
hidden={!user.hasAddRole(UserRoles.stocktake)}
|
||||
/>
|
||||
];
|
||||
}, [user]);
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any) => {
|
||||
return [
|
||||
RowEditAction({
|
||||
hidden: !user.hasChangeRole(UserRoles.stocktake),
|
||||
onClick: () => {
|
||||
setSelectedStocktake(record.pk);
|
||||
editStocktakeEntry.open();
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
hidden: !user.hasDeleteRole(UserRoles.stocktake),
|
||||
onClick: () => {
|
||||
setSelectedStocktake(record.pk);
|
||||
deleteStocktakeEntry.open();
|
||||
}
|
||||
})
|
||||
];
|
||||
},
|
||||
[user]
|
||||
);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
let records =
|
||||
table.records?.map((record: any) => {
|
||||
return {
|
||||
date: record.date,
|
||||
quantity: record.quantity,
|
||||
value_min: record.cost_min,
|
||||
value_max: record.cost_max
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
// Sort records to ensure correct date order
|
||||
records.sort((a, b) => {
|
||||
return new Date(a.date) < new Date(b.date) ? -1 : 1;
|
||||
});
|
||||
|
||||
return records;
|
||||
}, [table.records]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{generateReport.modal}
|
||||
{editStocktakeEntry.modal}
|
||||
{deleteStocktakeEntry.modal}
|
||||
<SimpleGrid cols={2}>
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.part_stocktake_list)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
params: {
|
||||
part: partId
|
||||
},
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions
|
||||
}}
|
||||
/>
|
||||
{table.isLoading ? (
|
||||
<Center>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (
|
||||
<LineChart
|
||||
data={chartData}
|
||||
dataKey="date"
|
||||
withLegend
|
||||
withYAxis
|
||||
withRightYAxis
|
||||
yAxisLabel={t`Quantity`}
|
||||
rightYAxisLabel={t`Stock Value`}
|
||||
series={[
|
||||
{
|
||||
name: 'quantity',
|
||||
label: t`Quantity`,
|
||||
color: 'blue.6',
|
||||
yAxisId: 'left'
|
||||
},
|
||||
{
|
||||
name: 'value_min',
|
||||
label: t`Min Value`,
|
||||
color: 'teal.6',
|
||||
yAxisId: 'right'
|
||||
},
|
||||
{
|
||||
name: 'value_max',
|
||||
label: t`Max Value`,
|
||||
color: 'red.6',
|
||||
yAxisId: 'right'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
@ -32,14 +32,16 @@ export default function SupplierPricingPanel({
|
||||
}, [table.records]);
|
||||
|
||||
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
|
||||
};
|
||||
});
|
||||
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 (
|
||||
|
@ -5,7 +5,6 @@ import {
|
||||
Box,
|
||||
Group,
|
||||
Indicator,
|
||||
LoadingOverlay,
|
||||
Space,
|
||||
Stack,
|
||||
Tooltip
|
||||
@ -711,12 +710,6 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
||||
</Group>
|
||||
</Group>
|
||||
<Box pos="relative">
|
||||
<LoadingOverlay
|
||||
visible={
|
||||
tableOptionQuery.isLoading || tableOptionQuery.isFetching
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
withTableBorder
|
||||
withColumnBorders
|
||||
|
110
src/frontend/src/tables/settings/StocktakeReportTable.tsx
Normal file
110
src/frontend/src/tables/settings/StocktakeReportTable.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
||||
import { AttachmentLink } from '../../components/items/AttachmentLink';
|
||||
import { RenderUser } from '../../components/render/User';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { generateStocktakeReportFields } from '../../forms/PartForms';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { TableColumn } from '../Column';
|
||||
import { DateColumn } from '../ColumnRenderers';
|
||||
import { InvenTreeTable } from '../InvenTreeTable';
|
||||
import { RowAction, RowDeleteAction } from '../RowActions';
|
||||
|
||||
export default function StocktakeReportTable() {
|
||||
const table = useTable('stocktake-report');
|
||||
|
||||
const tableColumns: TableColumn[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: 'report',
|
||||
title: t`Report`,
|
||||
sortable: false,
|
||||
switchable: false,
|
||||
render: (record: any) => <AttachmentLink attachment={record.report} />
|
||||
},
|
||||
{
|
||||
accessor: 'part_count',
|
||||
title: t`Part Count`,
|
||||
sortable: false
|
||||
},
|
||||
DateColumn({
|
||||
accessor: 'date',
|
||||
title: t`Date`
|
||||
}),
|
||||
{
|
||||
accessor: 'user',
|
||||
title: t`User`,
|
||||
sortable: false,
|
||||
render: (record: any) => RenderUser({ instance: record.user_detail })
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
const [selectedReport, setSelectedReport] = useState<number | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
const deleteReport = useDeleteApiFormModal({
|
||||
url: ApiEndpoints.part_stocktake_report_list,
|
||||
pk: selectedReport,
|
||||
title: t`Delete Report`,
|
||||
onFormSuccess: () => table.refreshTable()
|
||||
});
|
||||
|
||||
const generateFields: ApiFormFieldSet = useMemo(
|
||||
() => generateStocktakeReportFields(),
|
||||
[]
|
||||
);
|
||||
|
||||
const generateReport = useCreateApiFormModal({
|
||||
url: ApiEndpoints.part_stocktake_report_generate,
|
||||
title: t`Generate Stocktake Report`,
|
||||
fields: generateFields,
|
||||
successMessage: t`Stocktake report scheduled`
|
||||
});
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<AddItemButton
|
||||
tooltip={t`New Stocktake Report`}
|
||||
onClick={() => generateReport.open()}
|
||||
/>
|
||||
];
|
||||
}, []);
|
||||
|
||||
const rowActions = useCallback((record: any): RowAction[] => {
|
||||
return [
|
||||
RowDeleteAction({
|
||||
onClick: () => {
|
||||
setSelectedReport(record.pk);
|
||||
deleteReport.open();
|
||||
}
|
||||
})
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{generateReport.modal}
|
||||
{deleteReport.modal}
|
||||
<InvenTreeTable
|
||||
url={apiUrl(ApiEndpoints.part_stocktake_report_list)}
|
||||
tableState={table}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
enableSearch: false,
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -18,7 +18,7 @@ test('PUI - Parts', async ({ page }) => {
|
||||
await page.getByRole('tab', { name: 'Suppliers' }).click();
|
||||
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
|
||||
await page.getByRole('tab', { name: 'Scheduling' }).click();
|
||||
await page.getByRole('tab', { name: 'Stocktake' }).click();
|
||||
await page.getByRole('tab', { name: 'Stock History' }).click();
|
||||
await page.getByRole('tab', { name: 'Attachments' }).click();
|
||||
await page.getByRole('tab', { name: 'Notes' }).click();
|
||||
await page.getByRole('tab', { name: 'Related Parts' }).click();
|
||||
|
@ -29,7 +29,6 @@ test('PUI - Admin', async ({ page }) => {
|
||||
await page.getByRole('tab', { name: 'Labels' }).click();
|
||||
await page.getByRole('tab', { name: 'Reporting' }).click();
|
||||
|
||||
await page.getByRole('tab', { name: 'Stocktake' }).click();
|
||||
await page.getByRole('tab', { name: 'Build Orders' }).click();
|
||||
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
|
||||
await page.getByRole('tab', { name: 'Sales Orders' }).click();
|
||||
|
Reference in New Issue
Block a user