diff --git a/src/frontend/src/components/items/ActionDropdown.tsx b/src/frontend/src/components/items/ActionDropdown.tsx
index 530019b47c..e8b937fcac 100644
--- a/src/frontend/src/components/items/ActionDropdown.tsx
+++ b/src/frontend/src/components/items/ActionDropdown.tsx
@@ -1,4 +1,6 @@
+import { t } from '@lingui/macro';
import { ActionIcon, Menu, Tooltip } from '@mantine/core';
+import { IconQrcode } from '@tabler/icons-react';
import { ReactNode, useMemo } from 'react';
import { notYetImplemented } from '../../functions/notifications';
@@ -32,7 +34,7 @@ export function ActionDropdown({
return hasActions ? (
) : null;
}
+
+// Dropdown menu for barcode actions
+export function BarcodeActionDropdown({
+ actions
+}: {
+ actions: ActionDropdownItem[];
+}) {
+ return (
+ }
+ actions={actions}
+ />
+ );
+}
diff --git a/src/frontend/src/components/tables/part/PartTable.tsx b/src/frontend/src/components/tables/part/PartTable.tsx
index ca76d7b521..d59ded46c6 100644
--- a/src/frontend/src/components/tables/part/PartTable.tsx
+++ b/src/frontend/src/components/tables/part/PartTable.tsx
@@ -75,16 +75,19 @@ function partTableColumns(): TableColumn[] {
let extra: ReactNode[] = [];
let stock = record?.total_in_stock ?? 0;
+ let allocated =
+ (record?.allocated_to_build_orders ?? 0) +
+ (record?.allocated_to_sales_orders ?? 0);
+ let available = Math.max(0, stock - allocated);
+ let min_stock = record?.minimum_stock ?? 0;
let text = String(stock);
let color: string | undefined = undefined;
- if (record.minimum_stock > stock) {
+ if (min_stock > stock) {
extra.push(
-
- {t`Minimum stock` + `: ${record.minimum_stock}`}
-
+ {t`Minimum stock` + `: ${min_stock}`}
);
color = 'orange';
@@ -116,11 +119,19 @@ function partTableColumns(): TableColumn[] {
);
}
- // TODO: Add extra information on stock "deman"
+ if (available != stock) {
+ extra.push({t`Available` + `: ${available}`});
+ }
- if (stock == 0) {
+ // TODO: Add extra information on stock "demand"
+
+ if (stock <= 0) {
color = 'red';
text = t`No stock`;
+ } else if (available <= 0) {
+ color = 'orange';
+ } else if (available < min_stock) {
+ color = 'yellow';
}
return (
@@ -129,7 +140,7 @@ function partTableColumns(): TableColumn[] {
{text}
{record.units && (
-
+
[{record.units}]
)}
diff --git a/src/frontend/src/components/tables/purchasing/PurchaseOrderTable.tsx b/src/frontend/src/components/tables/purchasing/PurchaseOrderTable.tsx
index 18e0783903..57111ee6ec 100644
--- a/src/frontend/src/components/tables/purchasing/PurchaseOrderTable.tsx
+++ b/src/frontend/src/components/tables/purchasing/PurchaseOrderTable.tsx
@@ -61,7 +61,7 @@ export function PurchaseOrderTable({ params }: { params?: any }) {
accessor: 'project_code',
title: t`Project Code`,
switchable: true
- // TODO: Custom formatter
+ // TODO: Custom project code formatter
},
{
accessor: 'status',
@@ -78,22 +78,34 @@ export function PurchaseOrderTable({ params }: { params?: any }) {
accessor: 'creation_date',
title: t`Created`,
switchable: true
- // TODO: Custom formatter
+ // TODO: Custom date formatter
},
{
accessor: 'target_date',
title: t`Target Date`,
switchable: true
- // TODO: Custom formatter
+ // TODO: Custom date formatter
},
{
accessor: 'line_items',
title: t`Line Items`,
sortable: true,
switchable: true
+ },
+ {
+ accessor: 'total_price',
+ title: t`Total Price`,
+ sortable: true,
+ switchable: true
+ // TODO: Custom money formatter
+ },
+ {
+ accessor: 'responsible',
+ title: t`Responsible`,
+ sortable: true,
+ switchable: true
+ // TODO: custom 'owner' formatter
}
- // TODO: total_price
- // TODO: responsible
];
}, []);
diff --git a/src/frontend/src/components/tables/stock/StockItemTable.tsx b/src/frontend/src/components/tables/stock/StockItemTable.tsx
index 776ac66f4c..ad342c835c 100644
--- a/src/frontend/src/components/tables/stock/StockItemTable.tsx
+++ b/src/frontend/src/components/tables/stock/StockItemTable.tsx
@@ -1,6 +1,6 @@
import { t } from '@lingui/macro';
-import { Group, Text } from '@mantine/core';
-import { useMemo } from 'react';
+import { Group, Stack, Text } from '@mantine/core';
+import { ReactNode, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { notYetImplemented } from '../../../functions/notifications';
@@ -12,6 +12,7 @@ import { TableStatusRenderer } from '../../renderers/StatusRenderer';
import { TableColumn } from '../Column';
import { TableFilter } from '../Filter';
import { RowAction } from '../RowActions';
+import { TableHoverCard } from '../TableHoverCard';
import { InvenTreeTable } from './../InvenTreeTable';
/**
@@ -46,8 +47,109 @@ function stockItemTableColumns(): TableColumn[] {
{
accessor: 'quantity',
sortable: true,
- title: t`Stock`
- // TODO: Custom renderer for stock quantity
+ title: t`Stock`,
+ render: (record) => {
+ // TODO: Push this out into a custom renderer
+ let quantity = record?.quantity ?? 0;
+ let allocated = record?.allocated ?? 0;
+ let available = quantity - allocated;
+ let text = quantity;
+ let part = record?.part_detail ?? {};
+ let extra: ReactNode[] = [];
+ let color = undefined;
+
+ if (record.serial && quantity == 1) {
+ text = `# ${record.serial}`;
+ }
+
+ if (record.is_building) {
+ color = 'blue';
+ extra.push(
+ {t`This stock item is in production`}
+ );
+ }
+
+ if (record.sales_order) {
+ extra.push(
+ {t`This stock item has been assigned to a sales order`}
+ );
+ }
+
+ if (record.customer) {
+ extra.push(
+ {t`This stock item has been assigned to a customer`}
+ );
+ }
+
+ if (record.belongs_to) {
+ extra.push(
+ {t`This stock item is installed in another stock item`}
+ );
+ }
+
+ if (record.consumed_by) {
+ extra.push(
+ {t`This stock item has been consumed by a build order`}
+ );
+ }
+
+ if (record.expired) {
+ extra.push({t`This stock item has expired`});
+ } else if (record.stale) {
+ extra.push({t`This stock item is stale`});
+ }
+
+ if (allocated > 0) {
+ if (allocated >= quantity) {
+ color = 'orange';
+ extra.push(
+ {t`This stock item is fully allocated`}
+ );
+ } else {
+ extra.push(
+ {t`This stock item is partially allocated`}
+ );
+ }
+ }
+
+ if (available != quantity) {
+ if (available > 0) {
+ extra.push(
+
+ {t`Available` + `: ${available}`}
+
+ );
+ } else {
+ extra.push(
+ {t`No stock available`}
+ );
+ }
+ }
+
+ if (quantity <= 0) {
+ color = 'red';
+ extra.push(
+ {t`This stock item has been depleted`}
+ );
+ }
+
+ return (
+
+ {text}
+ {part.units && (
+
+ [{part.units}]
+
+ )}
+
+ }
+ title={t`Stock Information`}
+ extra={extra.length > 0 && {extra}}
+ />
+ );
+ }
},
{
accessor: 'status',
diff --git a/src/frontend/src/functions/forms/PartForms.tsx b/src/frontend/src/functions/forms/PartForms.tsx
index a20700e7d6..00fc4f3d99 100644
--- a/src/frontend/src/functions/forms/PartForms.tsx
+++ b/src/frontend/src/functions/forms/PartForms.tsx
@@ -94,8 +94,8 @@ export function editPart({
title: t`Edit Part`,
url: ApiPaths.part_list,
pk: part_id,
- successMessage: t`Part updated`,
fields: partFields({ editing: true }),
+ successMessage: t`Part updated`,
onFormSuccess: callback
});
}
diff --git a/src/frontend/src/functions/forms/StockForms.tsx b/src/frontend/src/functions/forms/StockForms.tsx
index aa3d9964ee..83d00da992 100644
--- a/src/frontend/src/functions/forms/StockForms.tsx
+++ b/src/frontend/src/functions/forms/StockForms.tsx
@@ -11,12 +11,16 @@ import { openCreateApiForm, openEditApiForm } from '../forms';
/**
* Construct a set of fields for creating / editing a StockItem instance
*/
-export function stockFields({}: {}): ApiFormFieldSet {
+export function stockFields({
+ create = false
+}: {
+ create: boolean;
+}): ApiFormFieldSet {
let fields: ApiFormFieldSet = {
part: {
+ hidden: !create,
onValueChange: (change: ApiFormChangeCallback) => {
// TODO: implement remaining functionality from old stock.py
- console.log('part changed: ', change.value);
// Clear the 'supplier_part' field if the part is changed
change.form.setValues({
@@ -41,15 +45,18 @@ export function stockFields({}: {}): ApiFormFieldSet {
}
},
use_pack_size: {
+ hidden: !create,
description: t`Add given quantity as packs instead of individual items`
},
location: {
+ hidden: !create,
filters: {
structural: false
}
// TODO: icon
},
quantity: {
+ hidden: !create,
description: t`Enter initial quantity for this stock item`
},
serial_numbers: {
@@ -57,9 +64,11 @@ export function stockFields({}: {}): ApiFormFieldSet {
field_type: 'string',
label: t`Serial Numbers`,
description: t`Enter serial numbers for new stock (or leave blank)`,
- required: false
+ required: false,
+ hidden: !create
},
serial: {
+ hidden: create
// TODO: icon
},
batch: {
@@ -100,7 +109,7 @@ export function createStockItem() {
openCreateApiForm({
name: 'stockitem-create',
url: ApiPaths.stock_item_list,
- fields: stockFields({}),
+ fields: stockFields({ create: true }),
title: t`Create Stock Item`
});
}
@@ -109,12 +118,20 @@ export function createStockItem() {
* Launch a form to edit an existing StockItem instance
* @param item : primary key of the StockItem to edit
*/
-export function editStockItem(item: number) {
+export function editStockItem({
+ item_id,
+ callback
+}: {
+ item_id: number;
+ callback?: () => void;
+}) {
openEditApiForm({
name: 'stockitem-edit',
url: ApiPaths.stock_item_list,
- pk: item,
- fields: stockFields({}),
- title: t`Edit Stock Item`
+ pk: item_id,
+ fields: stockFields({ create: false }),
+ title: t`Edit Stock Item`,
+ successMessage: t`Stock item updated`,
+ onFormSuccess: callback
});
}
diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx
index d78a36b147..2864f0063e 100644
--- a/src/frontend/src/pages/part/PartDetail.tsx
+++ b/src/frontend/src/pages/part/PartDetail.tsx
@@ -30,7 +30,10 @@ import {
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
-import { ActionDropdown } from '../../components/items/ActionDropdown';
+import {
+ ActionDropdown,
+ BarcodeActionDropdown
+} from '../../components/items/ActionDropdown';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { PartCategoryTree } from '../../components/nav/PartCategoryTree';
@@ -236,10 +239,7 @@ export default function PartDetail() {
const partActions = useMemo(() => {
// TODO: Disable actions based on user permissions
return [
- }
+ ,
diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx
index ea953bd6ac..e02ceb49b5 100644
--- a/src/frontend/src/pages/stock/StockDetail.tsx
+++ b/src/frontend/src/pages/stock/StockDetail.tsx
@@ -3,27 +3,48 @@ import { Alert, LoadingOverlay, Stack, Text } from '@mantine/core';
import {
IconBookmark,
IconBoxPadding,
+ IconChecklist,
+ IconCircleCheck,
+ IconCircleMinus,
+ IconCirclePlus,
+ IconCopy,
+ IconDots,
+ IconEdit,
IconHistory,
IconInfoCircle,
+ IconLink,
IconNotes,
+ IconPackages,
IconPaperclip,
- IconSitemap
+ IconQrcode,
+ IconSitemap,
+ IconTransfer,
+ IconTrash,
+ IconUnlink
} from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
+import {
+ ActionDropdown,
+ BarcodeActionDropdown
+} from '../../components/items/ActionDropdown';
import { PlaceholderPanel } from '../../components/items/Placeholder';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { StockLocationTree } from '../../components/nav/StockLocationTree';
import { AttachmentTable } from '../../components/tables/general/AttachmentTable';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
+import { editStockItem } from '../../functions/forms/StockForms';
import { useInstance } from '../../hooks/UseInstance';
import { ApiPaths, apiUrl } from '../../states/ApiState';
+import { useUserState } from '../../states/UserState';
export default function StockDetail() {
const { id } = useParams();
+ const user = useUserState();
+
const [treeOpen, setTreeOpen] = useState(false);
const {
@@ -58,13 +79,22 @@ export default function StockDetail() {
name: 'allocations',
label: t`Allocations`,
icon: ,
- content:
+ content: ,
+ hidden:
+ !stockitem?.part_detail?.salable && !stockitem?.part_detail?.component
+ },
+ {
+ name: 'testdata',
+ label: t`Test Data`,
+ icon: ,
+ hidden: !stockitem?.part_detail?.trackable
},
{
name: 'installed_items',
label: t`Installed Items`,
icon: ,
- content:
+ content: ,
+ hidden: !stockitem?.part_detail?.assembly
},
{
name: 'child_items',
@@ -110,6 +140,89 @@ export default function StockDetail() {
[stockitem]
);
+ const stockActions = useMemo(
+ () => /* TODO: Disable actions based on user permissions*/ [
+ ,
+ name: t`View`,
+ tooltip: t`View part barcode`
+ },
+ {
+ icon: ,
+ name: t`Link Barcode`,
+ tooltip: t`Link custom barcode to stock item`,
+ disabled: stockitem?.barcode_hash
+ },
+ {
+ icon: ,
+ name: t`Unlink Barcode`,
+ tooltip: t`Unlink custom barcode from stock item`,
+ disabled: !stockitem?.barcode_hash
+ }
+ ]}
+ />,
+ }
+ actions={[
+ {
+ name: t`Count`,
+ tooltip: t`Count stock`,
+ icon:
+ },
+ {
+ name: t`Add`,
+ tooltip: t`Add stock`,
+ icon:
+ },
+ {
+ name: t`Remove`,
+ tooltip: t`Remove stock`,
+ icon:
+ },
+ {
+ name: t`Transfer`,
+ tooltip: t`Transfer stock`,
+ icon:
+ }
+ ]}
+ />,
+ }
+ actions={[
+ {
+ name: t`Duplicate`,
+ tooltip: t`Duplicate stock item`,
+ icon:
+ },
+ {
+ name: t`Edit`,
+ tooltip: t`Edit stock item`,
+ icon: ,
+ onClick: () => {
+ stockitem.pk &&
+ editStockItem({
+ item_id: stockitem.pk,
+ callback: () => refreshInstance
+ });
+ }
+ },
+ {
+ name: t`Delete`,
+ tooltip: t`Delete stock item`,
+ icon:
+ }
+ ]}
+ />
+ ],
+ [id, stockitem, user]
+ );
+
return (
@@ -119,8 +232,9 @@ export default function StockDetail() {
selectedLocation={stockitem?.location}
/>
Quantity: {stockitem.quantity ?? 'idk'}
@@ -130,6 +244,7 @@ export default function StockDetail() {
breadcrumbAction={() => {
setTreeOpen(true);
}}
+ actions={stockActions}
/>