[React] Purchasing and Sales (#5743)

* Skeleton layout for purchasing and sales

- Empty pages
- Nav links
- Tab panels

* Add <CompanyTable> component

- Customers
- Suppliers
- Manufacturers

* Table fxies

* Initial implementation of PurchaseOrderTable

* More tables

- Basic SalesOrderTable
- Basic ReturnOrderTable

* Improvements to InvenTreeTable component

- Handle incorrect return type from server
- Use state for recordCount

* Formatting fixes
This commit is contained in:
Oliver 2023-10-19 10:11:04 +11:00 committed by GitHub
parent 543d50149b
commit 566fef5309
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 481 additions and 17 deletions

View File

@ -347,7 +347,18 @@ export function InvenTreeTable({
setMissingRecordsText(
tableProps.noRecordsText ?? t`No records found`
);
return response.data;
// Extract returned data (accounting for pagination) and ensure it is a list
let results = response.data?.results ?? response.data ?? [];
if (!Array.isArray(results)) {
setMissingRecordsText(t`Server returned incorrect data type`);
results = [];
}
setRecordCount(response.data?.count ?? results.length);
return results;
case 400:
setMissingRecordsText(t`Bad request`);
break;
@ -389,6 +400,8 @@ export function InvenTreeTable({
refetchOnMount: true
});
const [recordCount, setRecordCount] = useState<number>(0);
/*
* Reload the table whenever the refetch changes
* this allows us to programmatically refresh the table
@ -487,7 +500,7 @@ export function InvenTreeTable({
loaderVariant="dots"
idAccessor={tableProps.idAccessor}
minHeight={200}
totalRecords={data?.count ?? data?.length ?? 0}
totalRecords={recordCount}
recordsPerPage={tableProps.pageSize ?? defaultPageSize}
page={page}
onPageChange={setPage}
@ -501,7 +514,7 @@ export function InvenTreeTable({
}
fetching={isFetching}
noRecordsText={missingRecordsText}
records={data?.results ?? data ?? []}
records={data}
columns={dataColumns}
onRowClick={tableProps.onRowClick}
/>

View File

@ -6,18 +6,18 @@ import { notifications } from '@mantine/notifications';
import { IconExternalLink, IconFileUpload } from '@tabler/icons-react';
import { ReactNode, useEffect, useMemo, useState } from 'react';
import { api } from '../../App';
import { api } from '../../../App';
import {
addAttachment,
deleteAttachment,
editAttachment
} from '../../functions/forms/AttachmentForms';
import { useTableRefresh } from '../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../states/ApiState';
import { AttachmentLink } from '../items/AttachmentLink';
import { TableColumn } from './Column';
import { InvenTreeTable } from './InvenTreeTable';
import { RowAction } from './RowActions';
} from '../../../functions/forms/AttachmentForms';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { AttachmentLink } from '../../items/AttachmentLink';
import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction } from '../RowActions';
/**
* Define set of columns to display for the attachment table

View File

@ -0,0 +1,63 @@
import { t } from '@lingui/macro';
import { Group, Text } from '@mantine/core';
import { useMemo } from 'react';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { Thumbnail } from '../../images/Thumbnail';
import { InvenTreeTable } from '../InvenTreeTable';
/**
* A table which displays a list of company records,
* based on the provided filter parameters
*/
export function CompanyTable({ params }: { params?: any }) {
const { tableKey } = useTableRefresh('company');
const columns = useMemo(() => {
return [
{
accessor: 'name',
title: t`Company Name`,
sortable: true,
render: (record: any) => {
return (
<Group spacing="xs" noWrap={true}>
<Thumbnail
src={record.thumbnail ?? record.image}
alt={record.name}
size={24}
/>
<Text>{record.name}</Text>
</Group>
);
}
},
{
accessor: 'description',
title: t`Description`,
sortable: false,
switchable: true
},
{
accessor: 'website',
title: t`Website`,
sortable: false,
switchable: true
}
];
}, []);
return (
<InvenTreeTable
url={apiUrl(ApiPaths.company_list)}
tableKey={tableKey}
columns={columns}
props={{
params: {
...params
}
}}
/>
);
}

View File

@ -0,0 +1,101 @@
import { t } from '@lingui/macro';
import { Group, Text } from '@mantine/core';
import { useMemo } from 'react';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { Thumbnail } from '../../images/Thumbnail';
import { InvenTreeTable } from '../InvenTreeTable';
export function PurchaseOrderTable({ params }: { params?: any }) {
const { tableKey } = useTableRefresh('purchase-order');
// TODO: Custom filters
// TODO: Row actions
// TODO: Table actions (e.g. create new purchase order)
const tableColumns = useMemo(() => {
return [
{
accessor: 'reference',
title: t`Reference`,
sortable: true,
switchable: false
},
{
accessor: 'description',
title: t`Description`,
switchable: true
},
{
accessor: 'supplier__name',
title: t`Supplier`,
sortable: true,
render: function (record: any) {
let supplier = record.supplier_detail ?? {};
return (
<Group spacing="xs" noWrap={true}>
<Thumbnail src={supplier?.image} alt={supplier.name} />
<Text>{supplier?.name}</Text>
</Group>
);
}
},
{
accessor: 'supplier_reference',
title: t`Supplier Reference`,
switchable: true
},
{
accessor: 'project_code',
title: t`Project Code`,
switchable: true
// TODO: Custom formatter
},
{
accessor: 'status',
title: t`Status`,
sortable: true,
switchable: true
// TODO: Custom formatter
},
{
accessor: 'creation_date',
title: t`Created`,
switchable: true
// TODO: Custom formatter
},
{
accessor: 'target_date',
title: t`Target Date`,
switchable: true
// TODO: Custom formatter
},
{
accessor: 'line_items',
title: t`Line Items`,
sortable: true,
switchable: true
}
// TODO: total_price
// TODO: responsible
];
}, []);
return (
<InvenTreeTable
url={apiUrl(ApiPaths.purchase_order_list)}
tableKey={tableKey}
columns={tableColumns}
props={{
params: {
...params,
supplier_detail: true
}
}}
/>
);
}

View File

@ -0,0 +1,85 @@
import { t } from '@lingui/macro';
import { Group, Text } from '@mantine/core';
import { useMemo } from 'react';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { Thumbnail } from '../../images/Thumbnail';
import { InvenTreeTable } from '../InvenTreeTable';
export function ReturnOrderTable({ params }: { params?: any }) {
const { tableKey } = useTableRefresh('return-orders');
// TODO: Custom filters
// TODO: Row actions
// TODO: Table actions (e.g. create new return order)
const tableColumns = useMemo(() => {
return [
{
accessor: 'reference',
title: t`Return Order`,
sortable: true
},
{
accessor: 'description',
title: t`Description`,
switchable: true
},
{
accessor: 'customer__name',
title: t`Customer`,
sortable: true,
render: function (record: any) {
let customer = record.customer_detail ?? {};
return (
<Group spacing="xs" noWrap={true}>
<Thumbnail src={customer?.image} alt={customer.name} />
<Text>{customer?.name}</Text>
</Group>
);
}
},
{
accessor: 'customer_reference',
title: t`Customer Reference`,
switchable: true
},
{
accessor: 'project_code',
title: t`Project Code`,
switchable: true
// TODO: Custom formatter
},
{
accessor: 'status',
title: t`Status`,
sortable: true,
switchable: true
// TODO: Custom formatter
}
// TODO: Creation date
// TODO: Target date
// TODO: Line items
// TODO: Responsible
// TODO: Total cost
];
}, []);
return (
<InvenTreeTable
url={apiUrl(ApiPaths.return_order_list)}
tableKey={tableKey}
columns={tableColumns}
props={{
params: {
...params,
customer_detail: true
}
}}
/>
);
}

View File

@ -0,0 +1,87 @@
import { t } from '@lingui/macro';
import { Group, Text } from '@mantine/core';
import { useMemo } from 'react';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { Thumbnail } from '../../images/Thumbnail';
import { InvenTreeTable } from '../InvenTreeTable';
export function SalesOrderTable({ params }: { params?: any }) {
const { tableKey } = useTableRefresh('sales-order');
// TODO: Custom filters
// TODO: Row actions
// TODO: Table actions (e.g. create new sales order)
const tableColumns = useMemo(() => {
return [
{
accessor: 'reference',
title: t`Sales Order`,
sortable: true,
switchable: false
},
{
accessor: 'description',
title: t`Description`,
switchable: true
},
{
accessor: 'customer__name',
title: t`Customer`,
sortable: true,
render: function (record: any) {
let customer = record.customer_detail ?? {};
return (
<Group spacing="xs" noWrap={true}>
<Thumbnail src={customer?.image} alt={customer.name} />
<Text>{customer?.name}</Text>
</Group>
);
}
},
{
accessor: 'customer_reference',
title: t`Customer Reference`,
switchable: true
},
{
accessor: 'project_code',
title: t`Project Code`,
switchable: true
// TODO: Custom formatter
},
{
accessor: 'status',
title: t`Status`,
sortable: true,
switchable: true
// TODO: Custom formatter
}
// TODO: Creation date
// TODO: Target date
// TODO: Shipment date
// TODO: Line items
// TODO: Total price
];
}, []);
return (
<InvenTreeTable
url={apiUrl(ApiPaths.sales_order_list)}
tableKey={tableKey}
columns={tableColumns}
props={{
params: {
...params,
customer_detail: true
}
}}
/>
);
}

View File

@ -1,6 +1,5 @@
import { t } from '@lingui/macro';
import { ActionIcon, Text, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { IconCirclePlus } from '@tabler/icons-react';
import { useCallback, useMemo } from 'react';
@ -9,7 +8,6 @@ import {
openDeleteApiForm,
openEditApiForm
} from '../../../functions/forms';
import { notYetImplemented } from '../../../functions/notifications';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { TableColumn } from '../Column';

View File

@ -25,7 +25,9 @@ export const navTabs = [
{ text: <Trans>Dashboard</Trans>, name: 'dashboard' },
{ text: <Trans>Parts</Trans>, name: 'part' },
{ text: <Trans>Stock</Trans>, name: 'stock' },
{ text: <Trans>Build</Trans>, name: 'build' }
{ text: <Trans>Build</Trans>, name: 'build' },
{ text: <Trans>Purchasing</Trans>, name: 'purchasing' },
{ text: <Trans>Sales</Trans>, name: 'sales' }
];
if (IS_DEV_OR_DEMO) {
navTabs.push({ text: <Trans>Playground</Trans>, name: 'playground' });

View File

@ -19,8 +19,8 @@ import {
} from '../../components/items/Placeholder';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { AttachmentTable } from '../../components/tables/AttachmentTable';
import { BuildOrderTable } from '../../components/tables/build/BuildOrderTable';
import { AttachmentTable } from '../../components/tables/general/AttachmentTable';
import { StockItemTable } from '../../components/tables/stock/StockItemTable';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { useInstance } from '../../hooks/UseInstance';

View File

@ -31,7 +31,7 @@ import { ApiImage } from '../../components/images/ApiImage';
import { PlaceholderPanel } from '../../components/items/Placeholder';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { AttachmentTable } from '../../components/tables/AttachmentTable';
import { AttachmentTable } from '../../components/tables/general/AttachmentTable';
import { PartParameterTable } from '../../components/tables/part/PartParameterTable';
import { PartVariantTable } from '../../components/tables/part/PartVariantTable';
import { RelatedPartTable } from '../../components/tables/part/RelatedPartTable';

View File

@ -0,0 +1,48 @@
import { t } from '@lingui/macro';
import { Stack } from '@mantine/core';
import {
IconBuildingFactory2,
IconBuildingStore,
IconShoppingCart
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup } from '../../components/nav/PanelGroup';
import { CompanyTable } from '../../components/tables/general/CompanyTable';
import { PurchaseOrderTable } from '../../components/tables/purchasing/PurchaseOrderTable';
export default function PurchasingIndex() {
const panels = useMemo(() => {
return [
{
name: 'purchaseorders',
label: t`Purchase Orders`,
icon: <IconShoppingCart />,
content: <PurchaseOrderTable />
// TODO: Add optional "calendar" display here...
},
{
name: 'suppliers',
label: t`Suppliers`,
icon: <IconBuildingStore />,
content: <CompanyTable params={{ is_supplier: true }} />
},
{
name: 'manufacturer',
label: t`Manufacturers`,
icon: <IconBuildingFactory2 />,
content: <CompanyTable params={{ is_manufacturer: true }} />
}
];
}, []);
return (
<>
<Stack>
<PageDetail title={t`Purchasing`} />
<PanelGroup pageKey="purchasing-index" panels={panels} />
</Stack>
</>
);
}

View File

@ -0,0 +1,48 @@
import { t } from '@lingui/macro';
import { Stack } from '@mantine/core';
import {
IconBuildingStore,
IconTruckDelivery,
IconTruckReturn
} from '@tabler/icons-react';
import { useMemo } from 'react';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup } from '../../components/nav/PanelGroup';
import { CompanyTable } from '../../components/tables/general/CompanyTable';
import { ReturnOrderTable } from '../../components/tables/sales/ReturnOrderTable';
import { SalesOrderTable } from '../../components/tables/sales/SalesOrderTable';
export default function PurchasingIndex() {
const panels = useMemo(() => {
return [
{
name: 'salesorders',
label: t`Sales Orders`,
icon: <IconTruckDelivery />,
content: <SalesOrderTable />
},
{
name: 'returnorders',
label: t`Return Orders`,
icon: <IconTruckReturn />,
content: <ReturnOrderTable />
},
{
name: 'suppliers',
label: t`Customers`,
icon: <IconBuildingStore />,
content: <CompanyTable params={{ is_customer: true }} />
}
];
}, []);
return (
<>
<Stack>
<PageDetail title={t`Sales`} />
<PanelGroup pageKey="sales-index" panels={panels} />
</Stack>
</>
);
}

View File

@ -15,7 +15,7 @@ import { useParams } from 'react-router-dom';
import { PlaceholderPanel } from '../../components/items/Placeholder';
import { PageDetail } from '../../components/nav/PageDetail';
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { AttachmentTable } from '../../components/tables/AttachmentTable';
import { AttachmentTable } from '../../components/tables/general/AttachmentTable';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { useInstance } from '../../hooks/UseInstance';
import { ApiPaths, apiUrl } from '../../states/ApiState';

View File

@ -34,6 +34,14 @@ export const BuildDetail = Loadable(
lazy(() => import('./pages/build/BuildDetail'))
);
export const PurchasingIndex = Loadable(
lazy(() => import('./pages/purchasing/PurchasingIndex'))
);
export const SalesIndex = Loadable(
lazy(() => import('./pages/sales/SalesIndex'))
);
export const Scan = Loadable(lazy(() => import('./pages/Index/Scan')));
export const Dashboard = Loadable(
@ -99,6 +107,12 @@ export const routes = (
<Route index element={<BuildIndex />} />
<Route path=":id/" element={<BuildDetail />} />
</Route>
<Route path="purchasing/">
<Route index element={<PurchasingIndex />} />
</Route>
<Route path="sales/">
<Route index element={<SalesIndex />} />
</Route>
<Route path="/profile/:tabValue" element={<Profile />} />
</Route>
<Route path="/" errorElement={<ErrorPage />}>

View File

@ -68,6 +68,9 @@ export enum ApiPaths {
// Sales Order URLs
sales_order_list = 'api-sales-order-list',
// Return Order URLs
return_order_list = 'api-return-order-list',
// Plugin URLs
plugin_list = 'api-plugin-list',
@ -146,6 +149,8 @@ export function apiEndpoint(path: ApiPaths): string {
return 'order/po/';
case ApiPaths.sales_order_list:
return 'order/so/';
case ApiPaths.return_order_list:
return 'order/ro/';
case ApiPaths.plugin_list:
return 'plugins/';
case ApiPaths.project_code_list: