mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-30 04:26: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:
parent
0cd493e96e
commit
f132970ff3
@ -1,13 +1,16 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 255
|
INVENTREE_API_VERSION = 256
|
||||||
|
|
||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
|
||||||
|
v256 - 2024-09-19 : https://github.com/inventree/InvenTree/pull/7704
|
||||||
|
- Adjustments for "stocktake" (stock history) API endpoints
|
||||||
|
|
||||||
v255 - 2024-09-19 : https://github.com/inventree/InvenTree/pull/8145
|
v255 - 2024-09-19 : https://github.com/inventree/InvenTree/pull/8145
|
||||||
- Enables copying line items when duplicating an order
|
- Enables copying line items when duplicating an order
|
||||||
|
|
||||||
|
@ -436,6 +436,13 @@ class SupplierPriceBreakList(ListCreateAPI):
|
|||||||
serializer_class = SupplierPriceBreakSerializer
|
serializer_class = SupplierPriceBreakSerializer
|
||||||
filterset_class = SupplierPriceBreakFilter
|
filterset_class = SupplierPriceBreakFilter
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Return annotated queryset for the SupplierPriceBreak list endpoint."""
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
queryset = SupplierPriceBreakSerializer.annotate_queryset(queryset)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
def get_serializer(self, *args, **kwargs):
|
def get_serializer(self, *args, **kwargs):
|
||||||
"""Return serializer instance for this endpoint."""
|
"""Return serializer instance for this endpoint."""
|
||||||
try:
|
try:
|
||||||
@ -468,6 +475,13 @@ class SupplierPriceBreakDetail(RetrieveUpdateDestroyAPI):
|
|||||||
queryset = SupplierPriceBreak.objects.all()
|
queryset = SupplierPriceBreak.objects.all()
|
||||||
serializer_class = SupplierPriceBreakSerializer
|
serializer_class = SupplierPriceBreakSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Return annotated queryset for the SupplierPriceBreak list endpoint."""
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
queryset = SupplierPriceBreakSerializer.annotate_queryset(queryset)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
manufacturer_part_api_urls = [
|
manufacturer_part_api_urls = [
|
||||||
path(
|
path(
|
||||||
|
@ -512,6 +512,13 @@ class SupplierPriceBreakSerializer(
|
|||||||
if not part_detail:
|
if not part_detail:
|
||||||
self.fields.pop('part_detail', None)
|
self.fields.pop('part_detail', None)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def annotate_queryset(queryset):
|
||||||
|
"""Prefetch related fields for the queryset."""
|
||||||
|
queryset = queryset.select_related('part', 'part__supplier', 'part__part')
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
quantity = InvenTreeDecimalField()
|
quantity = InvenTreeDecimalField()
|
||||||
|
|
||||||
price = InvenTreeMoneySerializer(allow_null=True, required=True, label=_('Price'))
|
price = InvenTreeMoneySerializer(allow_null=True, required=True, label=_('Price'))
|
||||||
|
@ -991,11 +991,30 @@ class SalesOrderShipmentFilter(rest_filters.FilterSet):
|
|||||||
return queryset.filter(delivery_date=None)
|
return queryset.filter(delivery_date=None)
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderShipmentList(ListCreateAPI):
|
class SalesOrderShipmentMixin:
|
||||||
"""API list endpoint for SalesOrderShipment model."""
|
"""Mixin class for SalesOrderShipment endpoints."""
|
||||||
|
|
||||||
queryset = models.SalesOrderShipment.objects.all()
|
queryset = models.SalesOrderShipment.objects.all()
|
||||||
serializer_class = serializers.SalesOrderShipmentSerializer
|
serializer_class = serializers.SalesOrderShipmentSerializer
|
||||||
|
|
||||||
|
def get_queryset(self, *args, **kwargs):
|
||||||
|
"""Return annotated queryset for this endpoint."""
|
||||||
|
queryset = super().get_queryset(*args, **kwargs)
|
||||||
|
|
||||||
|
queryset = queryset.prefetch_related(
|
||||||
|
'order',
|
||||||
|
'order__customer',
|
||||||
|
'allocations',
|
||||||
|
'allocations__item',
|
||||||
|
'allocations__item__part',
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
class SalesOrderShipmentList(SalesOrderShipmentMixin, ListCreateAPI):
|
||||||
|
"""API list endpoint for SalesOrderShipment model."""
|
||||||
|
|
||||||
filterset_class = SalesOrderShipmentFilter
|
filterset_class = SalesOrderShipmentFilter
|
||||||
|
|
||||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||||
@ -1003,12 +1022,9 @@ class SalesOrderShipmentList(ListCreateAPI):
|
|||||||
ordering_fields = ['delivery_date', 'shipment_date']
|
ordering_fields = ['delivery_date', 'shipment_date']
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderShipmentDetail(RetrieveUpdateDestroyAPI):
|
class SalesOrderShipmentDetail(SalesOrderShipmentMixin, RetrieveUpdateDestroyAPI):
|
||||||
"""API detail endpooint for SalesOrderShipment model."""
|
"""API detail endpooint for SalesOrderShipment model."""
|
||||||
|
|
||||||
queryset = models.SalesOrderShipment.objects.all()
|
|
||||||
serializer_class = serializers.SalesOrderShipmentSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderShipmentComplete(CreateAPI):
|
class SalesOrderShipmentComplete(CreateAPI):
|
||||||
"""API endpoint for completing (shipping) a SalesOrderShipment."""
|
"""API endpoint for completing (shipping) a SalesOrderShipment."""
|
||||||
|
@ -1715,6 +1715,13 @@ class PartStocktakeReportList(ListAPI):
|
|||||||
ordering = '-pk'
|
ordering = '-pk'
|
||||||
|
|
||||||
|
|
||||||
|
class PartStocktakeReportDetail(RetrieveUpdateDestroyAPI):
|
||||||
|
"""API endpoint for detail view of a single PartStocktakeReport object."""
|
||||||
|
|
||||||
|
queryset = PartStocktakeReport.objects.all()
|
||||||
|
serializer_class = part_serializers.PartStocktakeReportSerializer
|
||||||
|
|
||||||
|
|
||||||
class PartStocktakeReportGenerate(CreateAPI):
|
class PartStocktakeReportGenerate(CreateAPI):
|
||||||
"""API endpoint for manually generating a new PartStocktakeReport."""
|
"""API endpoint for manually generating a new PartStocktakeReport."""
|
||||||
|
|
||||||
@ -2184,6 +2191,11 @@ part_api_urls = [
|
|||||||
PartStocktakeReportGenerate.as_view(),
|
PartStocktakeReportGenerate.as_view(),
|
||||||
name='api-part-stocktake-report-generate',
|
name='api-part-stocktake-report-generate',
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
'<int:pk>/',
|
||||||
|
PartStocktakeReportDetail.as_view(),
|
||||||
|
name='api-part-stocktake-report-detail',
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
'',
|
'',
|
||||||
PartStocktakeReportList.as_view(),
|
PartStocktakeReportList.as_view(),
|
||||||
|
@ -1193,6 +1193,7 @@ class PartStocktakeReportSerializer(InvenTree.serializers.InvenTreeModelSerializ
|
|||||||
|
|
||||||
model = PartStocktakeReport
|
model = PartStocktakeReport
|
||||||
fields = ['pk', 'date', 'report', 'part_count', 'user', 'user_detail']
|
fields = ['pk', 'date', 'report', 'part_count', 'user', 'user_detail']
|
||||||
|
read_only_fields = ['date', 'report', 'part_count', 'user']
|
||||||
|
|
||||||
user_detail = InvenTree.serializers.UserSerializer(
|
user_detail = InvenTree.serializers.UserSerializer(
|
||||||
source='user', read_only=True, many=False
|
source='user', read_only=True, many=False
|
||||||
|
@ -95,6 +95,8 @@ export enum ApiEndpoints {
|
|||||||
part_pricing_internal = 'part/internal-price/',
|
part_pricing_internal = 'part/internal-price/',
|
||||||
part_pricing_sale = 'part/sale-price/',
|
part_pricing_sale = 'part/sale-price/',
|
||||||
part_stocktake_list = 'part/stocktake/',
|
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_list = 'part/category/',
|
||||||
category_tree = 'part/category/tree/',
|
category_tree = 'part/category/tree/',
|
||||||
category_parameter_list = 'part/category/parameters/',
|
category_parameter_list = 'part/category/parameters/',
|
||||||
|
@ -202,3 +202,29 @@ export function usePartParameterFields({
|
|||||||
};
|
};
|
||||||
}, [editTemplate, fieldType, choices]);
|
}, [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
|
* Show a notification that the feature is not yet implemented
|
||||||
*/
|
*/
|
||||||
export function notYetImplemented() {
|
export function notYetImplemented() {
|
||||||
|
notifications.hide('not-implemented');
|
||||||
|
|
||||||
notifications.show({
|
notifications.show({
|
||||||
title: t`Not implemented`,
|
title: t`Not implemented`,
|
||||||
message: t`This feature is not yet implemented`,
|
message: t`This feature is not yet implemented`,
|
||||||
color: 'red'
|
color: 'red',
|
||||||
|
id: 'not-implemented'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@ import {
|
|||||||
Title
|
Title
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
|
IconClipboardCheck,
|
||||||
IconCoins,
|
IconCoins,
|
||||||
IconCpu,
|
IconCpu,
|
||||||
IconDevicesPc,
|
IconDevicesPc,
|
||||||
@ -91,6 +92,8 @@ const LocationTypesTable = Loadable(
|
|||||||
lazy(() => import('../../../../tables/stock/LocationTypesTable'))
|
lazy(() => import('../../../../tables/stock/LocationTypesTable'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const StocktakePanel = Loadable(lazy(() => import('./StocktakePanel')));
|
||||||
|
|
||||||
export default function AdminCenter() {
|
export default function AdminCenter() {
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
|
|
||||||
@ -162,6 +165,12 @@ export default function AdminCenter() {
|
|||||||
icon: <IconSitemap />,
|
icon: <IconSitemap />,
|
||||||
content: <PartCategoryTemplateTable />
|
content: <PartCategoryTemplateTable />
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'stocktake',
|
||||||
|
label: t`Stocktake`,
|
||||||
|
icon: <IconClipboardCheck />,
|
||||||
|
content: <StocktakePanel />
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'labels',
|
name: 'labels',
|
||||||
label: t`Label Templates`,
|
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 {
|
import {
|
||||||
IconBellCog,
|
IconBellCog,
|
||||||
IconCategory,
|
IconCategory,
|
||||||
IconClipboardCheck,
|
|
||||||
IconCurrencyDollar,
|
IconCurrencyDollar,
|
||||||
IconFileAnalytics,
|
IconFileAnalytics,
|
||||||
IconFingerprint,
|
IconFingerprint,
|
||||||
@ -225,12 +224,6 @@ export default function SystemSettings() {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'stocktake',
|
|
||||||
label: t`Stocktake`,
|
|
||||||
icon: <IconClipboardCheck />,
|
|
||||||
content: <PlaceholderPanel />
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'buildorders',
|
name: 'buildorders',
|
||||||
label: t`Build Orders`,
|
label: t`Build Orders`,
|
||||||
|
@ -2,7 +2,9 @@ import { t } from '@lingui/macro';
|
|||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
Alert,
|
Alert,
|
||||||
|
Center,
|
||||||
Grid,
|
Grid,
|
||||||
|
Loader,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Space,
|
Space,
|
||||||
Stack,
|
Stack,
|
||||||
@ -80,7 +82,10 @@ import {
|
|||||||
} from '../../hooks/UseForm';
|
} from '../../hooks/UseForm';
|
||||||
import { useInstance } from '../../hooks/UseInstance';
|
import { useInstance } from '../../hooks/UseInstance';
|
||||||
import { apiUrl } from '../../states/ApiState';
|
import { apiUrl } from '../../states/ApiState';
|
||||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
import {
|
||||||
|
useGlobalSettingsState,
|
||||||
|
useUserSettingsState
|
||||||
|
} from '../../states/SettingsState';
|
||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
import { BomTable } from '../../tables/bom/BomTable';
|
import { BomTable } from '../../tables/bom/BomTable';
|
||||||
import { UsedInTable } from '../../tables/bom/UsedInTable';
|
import { UsedInTable } from '../../tables/bom/UsedInTable';
|
||||||
@ -99,6 +104,7 @@ import { SalesOrderTable } from '../../tables/sales/SalesOrderTable';
|
|||||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||||
import { TestStatisticsTable } from '../../tables/stock/TestStatisticsTable';
|
import { TestStatisticsTable } from '../../tables/stock/TestStatisticsTable';
|
||||||
import PartPricingPanel from './PartPricingPanel';
|
import PartPricingPanel from './PartPricingPanel';
|
||||||
|
import PartStocktakeDetail from './PartStocktakeDetail';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detail view for a single Part instance
|
* Detail view for a single Part instance
|
||||||
@ -112,6 +118,7 @@ export default function PartDetail() {
|
|||||||
const [treeOpen, setTreeOpen] = useState(false);
|
const [treeOpen, setTreeOpen] = useState(false);
|
||||||
|
|
||||||
const globalSettings = useGlobalSettingsState();
|
const globalSettings = useGlobalSettingsState();
|
||||||
|
const userSettings = useUserSettingsState();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
instance: part,
|
instance: part,
|
||||||
@ -550,7 +557,7 @@ export default function PartDetail() {
|
|||||||
name: 'stock',
|
name: 'stock',
|
||||||
label: t`Stock`,
|
label: t`Stock`,
|
||||||
icon: <IconPackages />,
|
icon: <IconPackages />,
|
||||||
content: part.pk && (
|
content: part.pk ? (
|
||||||
<StockItemTable
|
<StockItemTable
|
||||||
tableName="part-stock"
|
tableName="part-stock"
|
||||||
allowAdd
|
allowAdd
|
||||||
@ -558,6 +565,10 @@ export default function PartDetail() {
|
|||||||
part: part.pk
|
part: part.pk
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Center>
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -680,17 +691,22 @@ export default function PartDetail() {
|
|||||||
hidden: !part.salable,
|
hidden: !part.salable,
|
||||||
content: part.pk ? <SalesOrderTable partId={part.pk} /> : <Skeleton />
|
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',
|
name: 'scheduling',
|
||||||
label: t`Scheduling`,
|
label: t`Scheduling`,
|
||||||
icon: <IconCalendarStats />,
|
icon: <IconCalendarStats />,
|
||||||
content: <PlaceholderPanel />
|
content: <PlaceholderPanel />,
|
||||||
},
|
hidden: !userSettings.isSet('DISPLAY_SCHEDULE_TAB')
|
||||||
{
|
|
||||||
name: 'stocktake',
|
|
||||||
label: t`Stocktake`,
|
|
||||||
icon: <IconClipboardList />,
|
|
||||||
content: <PlaceholderPanel />
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'test_templates',
|
name: 'test_templates',
|
||||||
@ -746,7 +762,7 @@ export default function PartDetail() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, [id, part, user]);
|
}, [id, part, user, globalSettings, userSettings]);
|
||||||
|
|
||||||
// Fetch information on part revision
|
// Fetch information on part revision
|
||||||
const partRevisionQuery = useQuery({
|
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]);
|
}, [table.records]);
|
||||||
|
|
||||||
const supplierPricingData = useMemo(() => {
|
const supplierPricingData = useMemo(() => {
|
||||||
return table.records.map((record: any) => {
|
return (
|
||||||
return {
|
table.records?.map((record: any) => {
|
||||||
quantity: record.quantity,
|
return {
|
||||||
supplier_price: record.price,
|
quantity: record.quantity,
|
||||||
unit_price: calculateSupplierPartUnitPrice(record),
|
supplier_price: record.price,
|
||||||
name: record.part_detail?.SKU
|
unit_price: calculateSupplierPartUnitPrice(record),
|
||||||
};
|
name: record.part_detail?.SKU
|
||||||
});
|
};
|
||||||
|
}) ?? []
|
||||||
|
);
|
||||||
}, [table.records]);
|
}, [table.records]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -5,7 +5,6 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Group,
|
Group,
|
||||||
Indicator,
|
Indicator,
|
||||||
LoadingOverlay,
|
|
||||||
Space,
|
Space,
|
||||||
Stack,
|
Stack,
|
||||||
Tooltip
|
Tooltip
|
||||||
@ -711,12 +710,6 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
<Box pos="relative">
|
<Box pos="relative">
|
||||||
<LoadingOverlay
|
|
||||||
visible={
|
|
||||||
tableOptionQuery.isLoading || tableOptionQuery.isFetching
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
withTableBorder
|
withTableBorder
|
||||||
withColumnBorders
|
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: 'Suppliers' }).click();
|
||||||
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
|
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
|
||||||
await page.getByRole('tab', { name: 'Scheduling' }).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: 'Attachments' }).click();
|
||||||
await page.getByRole('tab', { name: 'Notes' }).click();
|
await page.getByRole('tab', { name: 'Notes' }).click();
|
||||||
await page.getByRole('tab', { name: 'Related Parts' }).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: 'Labels' }).click();
|
||||||
await page.getByRole('tab', { name: 'Reporting' }).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: 'Build Orders' }).click();
|
||||||
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
|
await page.getByRole('tab', { name: 'Purchase Orders' }).click();
|
||||||
await page.getByRole('tab', { name: 'Sales Orders' }).click();
|
await page.getByRole('tab', { name: 'Sales Orders' }).click();
|
||||||
|
Loading…
x
Reference in New Issue
Block a user