mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
React UI improvements (#5756)
* Add CompanyDetail page * Add "edit company" button (and associated form) * Implement dropdown actions for company page * Update actions for PartDetail * Adds overlay while modal is loading - Lets the user know that something is at least happening in the background * Update panels * Implement separate pages for different company types - Mostly for better breadcrumbs * Placeholder actions for build detail * Add stock table to company page * typescript linting * Fix unused variables * remove dodgy improt
This commit is contained in:
parent
ae063d2722
commit
e57b69be48
@ -1,11 +1,5 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Alert,
|
||||
Divider,
|
||||
LoadingOverlay,
|
||||
ScrollArea,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { Alert, Divider, LoadingOverlay, Text } from '@mantine/core';
|
||||
import { Button, Group, Stack } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { modals } from '@mantine/modals';
|
||||
@ -277,24 +271,22 @@ export function ApiForm({
|
||||
</Alert>
|
||||
)}
|
||||
{preFormElement}
|
||||
<ScrollArea>
|
||||
<Stack spacing="xs">
|
||||
{Object.entries(props.fields ?? {}).map(
|
||||
([fieldName, field]) =>
|
||||
!field.hidden && (
|
||||
<ApiFormField
|
||||
key={fieldName}
|
||||
field={field}
|
||||
fieldName={fieldName}
|
||||
formProps={props}
|
||||
form={form}
|
||||
error={form.errors[fieldName] ?? null}
|
||||
definitions={fieldDefinitions}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
<Stack spacing="xs">
|
||||
{Object.entries(props.fields ?? {}).map(
|
||||
([fieldName, field]) =>
|
||||
!field.hidden && (
|
||||
<ApiFormField
|
||||
key={fieldName}
|
||||
field={field}
|
||||
fieldName={fieldName}
|
||||
formProps={props}
|
||||
form={form}
|
||||
error={form.errors[fieldName] ?? null}
|
||||
definitions={fieldDefinitions}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Stack>
|
||||
{postFormElement}
|
||||
</Stack>
|
||||
<Divider />
|
||||
|
@ -19,7 +19,7 @@ export function Thumbnail({
|
||||
|
||||
return (
|
||||
<ApiImage
|
||||
src={src}
|
||||
src={src || '/static/img/blank_image.png'}
|
||||
alt={alt}
|
||||
width={size}
|
||||
fit="contain"
|
||||
|
65
src/frontend/src/components/items/ActionDropdown.tsx
Normal file
65
src/frontend/src/components/items/ActionDropdown.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { ActionIcon, Menu, Tooltip } from '@mantine/core';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
|
||||
import { notYetImplemented } from '../../functions/notifications';
|
||||
|
||||
export type ActionDropdownItem = {
|
||||
icon: ReactNode;
|
||||
name: string;
|
||||
tooltip?: string;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A simple Menu component which renders a set of actions.
|
||||
*
|
||||
* If no "active" actions are provided, the menu will not be rendered
|
||||
*/
|
||||
export function ActionDropdown({
|
||||
icon,
|
||||
tooltip,
|
||||
actions
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
tooltip?: string;
|
||||
actions: ActionDropdownItem[];
|
||||
}) {
|
||||
const hasActions = useMemo(() => {
|
||||
return actions.some((action) => !action.disabled);
|
||||
}, [actions]);
|
||||
|
||||
return hasActions ? (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<Tooltip label={tooltip}>
|
||||
<ActionIcon size="lg" radius="sm" variant="outline">
|
||||
{icon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{actions.map((action, index) =>
|
||||
action.disabled ? null : (
|
||||
<Tooltip label={action.tooltip}>
|
||||
<Menu.Item
|
||||
icon={action.icon}
|
||||
key={index}
|
||||
onClick={() => {
|
||||
if (action.onClick != undefined) {
|
||||
action.onClick();
|
||||
} else {
|
||||
notYetImplemented();
|
||||
}
|
||||
}}
|
||||
disabled={action.disabled}
|
||||
>
|
||||
{action.name}
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
) : null;
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
import { Container, Flex, Space } from '@mantine/core';
|
||||
import { Container, Flex, LoadingOverlay, Space } from '@mantine/core';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
|
||||
import { InvenTreeStyle } from '../../globalStyle';
|
||||
import { useModalState } from '../../states/ModalState';
|
||||
import { useSessionState } from '../../states/SessionState';
|
||||
import { Footer } from './Footer';
|
||||
import { Header } from './Header';
|
||||
@ -19,9 +20,12 @@ export const ProtectedRoute = ({ children }: { children: JSX.Element }) => {
|
||||
export default function LayoutComponent() {
|
||||
const { classes } = InvenTreeStyle();
|
||||
|
||||
const modalState = useModalState();
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<Flex direction="column" mih="100vh">
|
||||
<LoadingOverlay visible={modalState.loading} />
|
||||
<Header />
|
||||
<Container className={classes.layoutContent} size="100%">
|
||||
<Outlet />
|
||||
|
@ -41,7 +41,11 @@ export function PageDetail({
|
||||
</Stack>
|
||||
</Group>
|
||||
<Space />
|
||||
{actions && <Group position="right">{actions}</Group>}
|
||||
{actions && (
|
||||
<Group spacing={5} position="right">
|
||||
{actions}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
@ -224,20 +224,22 @@ export function AttachmentTable({
|
||||
|
||||
return (
|
||||
<Stack spacing="xs">
|
||||
<InvenTreeTable
|
||||
url={url}
|
||||
tableKey={tableKey}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
noRecordsText: t`No attachments found`,
|
||||
enableSelection: true,
|
||||
customActionGroups: customActionGroups,
|
||||
rowActions: allowEdit && allowDelete ? rowActions : undefined,
|
||||
params: {
|
||||
[model]: pk
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{pk && pk > 0 && (
|
||||
<InvenTreeTable
|
||||
url={url}
|
||||
tableKey={tableKey}
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
noRecordsText: t`No attachments found`,
|
||||
enableSelection: true,
|
||||
customActionGroups: customActionGroups,
|
||||
rowActions: allowEdit && allowDelete ? rowActions : undefined,
|
||||
params: {
|
||||
[model]: pk
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{allowEdit && validPk && (
|
||||
<Dropzone onDrop={uploadFiles}>
|
||||
<Dropzone.Idle>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Group, Text } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useTableRefresh } from '../../../hooks/TableRefresh';
|
||||
import { ApiPaths, apiUrl } from '../../../states/ApiState';
|
||||
@ -11,9 +12,17 @@ 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 }) {
|
||||
export function CompanyTable({
|
||||
params,
|
||||
path
|
||||
}: {
|
||||
params?: any;
|
||||
path?: string;
|
||||
}) {
|
||||
const { tableKey } = useTableRefresh('company');
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@ -24,7 +33,7 @@ export function CompanyTable({ params }: { params?: any }) {
|
||||
return (
|
||||
<Group spacing="xs" noWrap={true}>
|
||||
<Thumbnail
|
||||
src={record.thumbnail ?? record.image}
|
||||
src={record.thumbnail ?? record.image ?? ''}
|
||||
alt={record.name}
|
||||
size={24}
|
||||
/>
|
||||
@ -56,6 +65,10 @@ export function CompanyTable({ params }: { params?: any }) {
|
||||
props={{
|
||||
params: {
|
||||
...params
|
||||
},
|
||||
onRowClick: (row: any) => {
|
||||
let base = path ?? 'company';
|
||||
navigate(`/${base}/${row.pk}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
@ -7,6 +7,7 @@ import { api } from '../App';
|
||||
import { ApiForm, ApiFormProps } from '../components/forms/ApiForm';
|
||||
import { ApiFormFieldType } from '../components/forms/fields/ApiFormField';
|
||||
import { apiUrl } from '../states/ApiState';
|
||||
import { useModalState } from '../states/ModalState';
|
||||
import { invalidResponse, permissionDenied } from './notifications';
|
||||
import { generateUniqueId } from './uid';
|
||||
|
||||
@ -97,6 +98,10 @@ export function openModalApiForm(props: ApiFormProps) {
|
||||
|
||||
let url = constructFormUrl(props);
|
||||
|
||||
// let modalState = useModalState();
|
||||
|
||||
useModalState.getState().lock();
|
||||
|
||||
// Make OPTIONS request first
|
||||
api
|
||||
.options(url)
|
||||
@ -119,6 +124,7 @@ export function openModalApiForm(props: ApiFormProps) {
|
||||
modals.open({
|
||||
title: props.title,
|
||||
modalId: modalId,
|
||||
size: 'xl',
|
||||
onClose: () => {
|
||||
props.onClose ? props.onClose() : null;
|
||||
},
|
||||
@ -126,8 +132,12 @@ export function openModalApiForm(props: ApiFormProps) {
|
||||
<ApiForm modalId={modalId} props={props} fieldDefinitions={fields} />
|
||||
)
|
||||
});
|
||||
|
||||
useModalState.getState().unlock();
|
||||
})
|
||||
.catch((error) => {
|
||||
useModalState.getState().unlock();
|
||||
|
||||
console.log('Error:', error);
|
||||
if (error.response) {
|
||||
invalidResponse(error.response.status);
|
||||
|
57
src/frontend/src/functions/forms/CompanyForms.tsx
Normal file
57
src/frontend/src/functions/forms/CompanyForms.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
IconAt,
|
||||
IconCurrencyDollar,
|
||||
IconGlobe,
|
||||
IconPhone
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
||||
import { ApiPaths } from '../../states/ApiState';
|
||||
import { openEditApiForm } from '../forms';
|
||||
|
||||
/**
|
||||
* Field set for editing a company instance
|
||||
*/
|
||||
export function companyFields(): ApiFormFieldSet {
|
||||
return {
|
||||
name: {},
|
||||
description: {},
|
||||
website: {
|
||||
icon: <IconGlobe />
|
||||
},
|
||||
currency: {
|
||||
icon: <IconCurrencyDollar />
|
||||
},
|
||||
phone: {
|
||||
icon: <IconPhone />
|
||||
},
|
||||
email: {
|
||||
icon: <IconAt />
|
||||
},
|
||||
is_supplier: {},
|
||||
is_manufacturer: {},
|
||||
is_customer: {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a company instance
|
||||
*/
|
||||
export function editCompany({
|
||||
pk,
|
||||
callback
|
||||
}: {
|
||||
pk: number;
|
||||
callback?: () => void;
|
||||
}) {
|
||||
openEditApiForm({
|
||||
name: 'company-edit',
|
||||
title: t`Edit Company`,
|
||||
url: ApiPaths.company_list,
|
||||
pk: pk,
|
||||
fields: companyFields(),
|
||||
successMessage: t`Company updated`,
|
||||
onFormSuccess: callback
|
||||
});
|
||||
}
|
@ -3,16 +3,26 @@ import { Alert, LoadingOverlay, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
IconClipboardCheck,
|
||||
IconClipboardList,
|
||||
IconCopy,
|
||||
IconDots,
|
||||
IconEdit,
|
||||
IconFileTypePdf,
|
||||
IconInfoCircle,
|
||||
IconLink,
|
||||
IconList,
|
||||
IconListCheck,
|
||||
IconNotes,
|
||||
IconPaperclip,
|
||||
IconSitemap
|
||||
IconPrinter,
|
||||
IconQrcode,
|
||||
IconSitemap,
|
||||
IconTrash,
|
||||
IconUnlink
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { ActionDropdown } from '../../components/items/ActionDropdown';
|
||||
import {
|
||||
PlaceholderPanel,
|
||||
PlaceholderPill
|
||||
@ -25,6 +35,7 @@ import { StockItemTable } from '../../components/tables/stock/StockItemTable';
|
||||
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { ApiPaths, apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
|
||||
/**
|
||||
* Detail page for a single Build Order
|
||||
@ -44,6 +55,8 @@ export default function BuildDetail() {
|
||||
}
|
||||
});
|
||||
|
||||
const user = useUserState();
|
||||
|
||||
const buildPanels: PanelType[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@ -130,22 +143,78 @@ export default function BuildDetail() {
|
||||
];
|
||||
}, [build]);
|
||||
|
||||
const buildActions = useMemo(() => {
|
||||
// TODO: Disable certain actions based on user permissions
|
||||
return [
|
||||
<ActionDropdown
|
||||
tooltip={t`Barcode Actions`}
|
||||
icon={<IconQrcode />}
|
||||
actions={[
|
||||
{
|
||||
icon: <IconQrcode />,
|
||||
name: t`View`,
|
||||
tooltip: t`View part barcode`
|
||||
},
|
||||
{
|
||||
icon: <IconLink />,
|
||||
name: t`Link Barcode`,
|
||||
tooltip: t`Link custom barcode to part`,
|
||||
disabled: build?.barcode_hash
|
||||
},
|
||||
{
|
||||
icon: <IconUnlink />,
|
||||
name: t`Unlink Barcode`,
|
||||
tooltip: t`Unlink custom barcode from part`,
|
||||
disabled: !build?.barcode_hash
|
||||
}
|
||||
]}
|
||||
/>,
|
||||
<ActionDropdown
|
||||
tooltip={t`Reporting Actions`}
|
||||
icon={<IconPrinter />}
|
||||
actions={[
|
||||
{
|
||||
icon: <IconFileTypePdf />,
|
||||
name: t`Report`,
|
||||
tooltip: t`Print build report`
|
||||
}
|
||||
]}
|
||||
/>,
|
||||
<ActionDropdown
|
||||
tooltip={t`Build Order Actions`}
|
||||
icon={<IconDots />}
|
||||
actions={[
|
||||
{
|
||||
icon: <IconEdit color="blue" />,
|
||||
name: t`Edit`,
|
||||
tooltip: t`Edit build order`
|
||||
},
|
||||
{
|
||||
icon: <IconCopy color="green" />,
|
||||
name: t`Duplicate`,
|
||||
tooltip: t`Duplicate build order`
|
||||
},
|
||||
{
|
||||
icon: <IconTrash color="red" />,
|
||||
name: t`Delete`,
|
||||
tooltip: t`Delete build order`
|
||||
}
|
||||
]}
|
||||
/>
|
||||
];
|
||||
}, [id, build, user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack spacing="xs">
|
||||
<PageDetail
|
||||
title={t`Build Order`}
|
||||
subtitle={build.reference}
|
||||
detail={
|
||||
<Alert color="teal" title="Build order detail goes here">
|
||||
<Text>TODO: Build details</Text>
|
||||
</Alert>
|
||||
}
|
||||
breadcrumbs={[
|
||||
{ name: t`Build Orders`, url: '/build' },
|
||||
{ name: build.reference, url: `/build/${build.pk}` }
|
||||
]}
|
||||
actions={[<PlaceholderPill key="1" />]}
|
||||
actions={buildActions}
|
||||
/>
|
||||
<LoadingOverlay visible={instanceQuery.isFetching} />
|
||||
<PanelGroup pageKey="build" panels={buildPanels} />
|
||||
|
226
src/frontend/src/pages/company/CompanyDetail.tsx
Normal file
226
src/frontend/src/pages/company/CompanyDetail.tsx
Normal file
@ -0,0 +1,226 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Group, LoadingOverlay, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
IconBuildingFactory2,
|
||||
IconBuildingWarehouse,
|
||||
IconDots,
|
||||
IconEdit,
|
||||
IconInfoCircle,
|
||||
IconMap2,
|
||||
IconNotes,
|
||||
IconPackageExport,
|
||||
IconPackages,
|
||||
IconPaperclip,
|
||||
IconShoppingCart,
|
||||
IconTrash,
|
||||
IconTruckDelivery,
|
||||
IconTruckReturn,
|
||||
IconUsersGroup
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { Thumbnail } from '../../components/images/Thumbnail';
|
||||
import { ActionDropdown } from '../../components/items/ActionDropdown';
|
||||
import { Breadcrumb } from '../../components/nav/BreadcrumbList';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup } from '../../components/nav/PanelGroup';
|
||||
import { PanelType } from '../../components/nav/PanelGroup';
|
||||
import { AttachmentTable } from '../../components/tables/general/AttachmentTable';
|
||||
import { PurchaseOrderTable } from '../../components/tables/purchasing/PurchaseOrderTable';
|
||||
import { ReturnOrderTable } from '../../components/tables/sales/ReturnOrderTable';
|
||||
import { SalesOrderTable } from '../../components/tables/sales/SalesOrderTable';
|
||||
import { StockItemTable } from '../../components/tables/stock/StockItemTable';
|
||||
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
|
||||
import { editCompany } from '../../functions/forms/CompanyForms';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { ApiPaths, apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
|
||||
export type CompanyDetailProps = {
|
||||
title: string;
|
||||
breadcrumbs: Breadcrumb[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Detail view for a single company instance
|
||||
*/
|
||||
export default function CompanyDetail(props: CompanyDetailProps) {
|
||||
const { id } = useParams();
|
||||
|
||||
const user = useUserState();
|
||||
|
||||
const {
|
||||
instance: company,
|
||||
refreshInstance,
|
||||
instanceQuery
|
||||
} = useInstance({
|
||||
endpoint: ApiPaths.company_list,
|
||||
pk: id,
|
||||
params: {},
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
const companyPanels: PanelType[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: 'details',
|
||||
label: t`Details`,
|
||||
icon: <IconInfoCircle />
|
||||
},
|
||||
{
|
||||
name: 'manufactured-parts',
|
||||
label: t`Manufactured Parts`,
|
||||
icon: <IconBuildingFactory2 />,
|
||||
hidden: !company?.is_manufacturer
|
||||
},
|
||||
{
|
||||
name: 'supplied-parts',
|
||||
label: t`Supplied Parts`,
|
||||
icon: <IconBuildingWarehouse />,
|
||||
hidden: !company?.is_supplier
|
||||
},
|
||||
{
|
||||
name: 'purchase-orders',
|
||||
label: t`Purchase Orders`,
|
||||
icon: <IconShoppingCart />,
|
||||
hidden: !company?.is_supplier,
|
||||
content: company?.pk && (
|
||||
<PurchaseOrderTable params={{ supplier: company.pk }} />
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'stock-items',
|
||||
label: t`Stock Items`,
|
||||
icon: <IconPackages />,
|
||||
hidden: !company?.is_manufacturer && !company?.is_supplier,
|
||||
content: company?.pk && (
|
||||
<StockItemTable params={{ company: company.pk }} />
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'sales-orders',
|
||||
label: t`Sales Orders`,
|
||||
icon: <IconTruckDelivery />,
|
||||
hidden: !company?.is_customer,
|
||||
content: company?.pk && (
|
||||
<SalesOrderTable params={{ customer: company.pk }} />
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'return-orders',
|
||||
label: t`Return Orders`,
|
||||
icon: <IconTruckReturn />,
|
||||
hidden: !company?.is_customer,
|
||||
content: company.pk && (
|
||||
<ReturnOrderTable params={{ customer: company.pk }} />
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'assigned-stock',
|
||||
label: t`Assigned Stock`,
|
||||
icon: <IconPackageExport />,
|
||||
hidden: !company?.is_customer
|
||||
},
|
||||
{
|
||||
name: 'contacts',
|
||||
label: t`Contacts`,
|
||||
icon: <IconUsersGroup />
|
||||
},
|
||||
{
|
||||
name: 'addresses',
|
||||
label: t`Addresses`,
|
||||
icon: <IconMap2 />
|
||||
},
|
||||
{
|
||||
name: 'attachments',
|
||||
label: t`Attachments`,
|
||||
icon: <IconPaperclip />,
|
||||
content: (
|
||||
<AttachmentTable
|
||||
endpoint={ApiPaths.company_attachment_list}
|
||||
model="company"
|
||||
pk={company.pk ?? -1}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'notes',
|
||||
label: t`Notes`,
|
||||
icon: <IconNotes />,
|
||||
content: (
|
||||
<NotesEditor
|
||||
url={apiUrl(ApiPaths.company_list, company.pk)}
|
||||
data={company?.notes ?? ''}
|
||||
allowEdit={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [id, company]);
|
||||
|
||||
const companyDetail = useMemo(() => {
|
||||
return (
|
||||
<Group spacing="xs" noWrap={true}>
|
||||
<Thumbnail
|
||||
src={String(company.image || '')}
|
||||
size={128}
|
||||
alt={company?.name}
|
||||
/>
|
||||
<Stack spacing="xs">
|
||||
<Text size="lg" weight={500}>
|
||||
{company.name}
|
||||
</Text>
|
||||
<Text size="sm">{company.description}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}, [id, company]);
|
||||
|
||||
const companyActions = useMemo(() => {
|
||||
// TODO: Finer fidelity on these permissions, perhaps?
|
||||
let canEdit = user.checkUserRole('purchase_order', 'change');
|
||||
let canDelete = user.checkUserRole('purchase_order', 'delete');
|
||||
|
||||
return [
|
||||
<ActionDropdown
|
||||
tooltip={t`Company Actions`}
|
||||
icon={<IconDots />}
|
||||
actions={[
|
||||
{
|
||||
icon: <IconEdit color="blue" />,
|
||||
name: t`Edit`,
|
||||
tooltip: t`Edit company`,
|
||||
disabled: !canEdit,
|
||||
onClick: () => {
|
||||
if (company?.pk) {
|
||||
editCompany({
|
||||
pk: company?.pk,
|
||||
callback: refreshInstance
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: <IconTrash color="red" />,
|
||||
name: t`Delete`,
|
||||
tooltip: t`Delete company`,
|
||||
disabled: !canDelete
|
||||
}
|
||||
]}
|
||||
/>
|
||||
];
|
||||
}, [id, company, user]);
|
||||
|
||||
return (
|
||||
<Stack spacing="xs">
|
||||
<LoadingOverlay visible={instanceQuery.isFetching} />
|
||||
<PageDetail
|
||||
detail={companyDetail}
|
||||
actions={companyActions}
|
||||
breadcrumbs={props.breadcrumbs}
|
||||
/>
|
||||
<PanelGroup pageKey="company" panels={companyPanels} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
12
src/frontend/src/pages/company/CustomerDetail.tsx
Normal file
12
src/frontend/src/pages/company/CustomerDetail.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import CompanyDetail from './CompanyDetail';
|
||||
|
||||
export default function CustomerDetail() {
|
||||
return (
|
||||
<CompanyDetail
|
||||
title={t`Customer`}
|
||||
breadcrumbs={[{ name: t`Sales`, url: '/sales/' }]}
|
||||
/>
|
||||
);
|
||||
}
|
12
src/frontend/src/pages/company/ManufacturerDetail.tsx
Normal file
12
src/frontend/src/pages/company/ManufacturerDetail.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import CompanyDetail from './CompanyDetail';
|
||||
|
||||
export default function ManufacturerDetail() {
|
||||
return (
|
||||
<CompanyDetail
|
||||
title={t`Manufacturer`}
|
||||
breadcrumbs={[{ name: t`Purchasing`, url: '/purchasing/' }]}
|
||||
/>
|
||||
);
|
||||
}
|
12
src/frontend/src/pages/company/SupplierDetail.tsx
Normal file
12
src/frontend/src/pages/company/SupplierDetail.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import CompanyDetail from './CompanyDetail';
|
||||
|
||||
export default function SupplierDetail() {
|
||||
return (
|
||||
<CompanyDetail
|
||||
title={t`Supplier`}
|
||||
breadcrumbs={[{ name: t`Purchasing`, url: '/purchasing/' }]}
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,34 +1,37 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Stack,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { Group, LoadingOverlay, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
IconBuilding,
|
||||
IconCalendarStats,
|
||||
IconClipboardList,
|
||||
IconCopy,
|
||||
IconCurrencyDollar,
|
||||
IconDots,
|
||||
IconEdit,
|
||||
IconInfoCircle,
|
||||
IconLayersLinked,
|
||||
IconLink,
|
||||
IconList,
|
||||
IconListTree,
|
||||
IconNotes,
|
||||
IconPackages,
|
||||
IconPaperclip,
|
||||
IconQrcode,
|
||||
IconShoppingCart,
|
||||
IconStack2,
|
||||
IconTestPipe,
|
||||
IconTools,
|
||||
IconTransfer,
|
||||
IconTrash,
|
||||
IconTruckDelivery,
|
||||
IconUnlink,
|
||||
IconVersions
|
||||
} from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { ApiImage } from '../../components/images/ApiImage';
|
||||
import { PlaceholderPanel } from '../../components/items/Placeholder';
|
||||
import { ActionDropdown } from '../../components/items/ActionDropdown';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { AttachmentTable } from '../../components/tables/general/AttachmentTable';
|
||||
@ -40,6 +43,7 @@ import { NotesEditor } from '../../components/widgets/MarkdownEditor';
|
||||
import { editPart } from '../../functions/forms/PartForms';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { ApiPaths, apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
|
||||
/**
|
||||
* Detail view for a single Part instance
|
||||
@ -47,6 +51,8 @@ import { ApiPaths, apiUrl } from '../../states/ApiState';
|
||||
export default function PartDetail() {
|
||||
const { id } = useParams();
|
||||
|
||||
const user = useUserState();
|
||||
|
||||
const {
|
||||
instance: part,
|
||||
refreshInstance,
|
||||
@ -66,8 +72,7 @@ export default function PartDetail() {
|
||||
{
|
||||
name: 'details',
|
||||
label: t`Details`,
|
||||
icon: <IconInfoCircle />,
|
||||
content: <PlaceholderPanel />
|
||||
icon: <IconInfoCircle />
|
||||
},
|
||||
{
|
||||
name: 'parameters',
|
||||
@ -98,55 +103,57 @@ export default function PartDetail() {
|
||||
name: 'bom',
|
||||
label: t`Bill of Materials`,
|
||||
icon: <IconListTree />,
|
||||
hidden: !part.assembly,
|
||||
content: <PlaceholderPanel />
|
||||
hidden: !part.assembly
|
||||
},
|
||||
{
|
||||
name: 'builds',
|
||||
label: t`Build Orders`,
|
||||
icon: <IconTools />,
|
||||
hidden: !part.assembly && !part.component,
|
||||
content: <PlaceholderPanel />
|
||||
hidden: !part.assembly && !part.component
|
||||
},
|
||||
{
|
||||
name: 'used_in',
|
||||
label: t`Used In`,
|
||||
icon: <IconStack2 />,
|
||||
hidden: !part.component,
|
||||
content: <PlaceholderPanel />
|
||||
hidden: !part.component
|
||||
},
|
||||
{
|
||||
name: 'pricing',
|
||||
label: t`Pricing`,
|
||||
icon: <IconCurrencyDollar />,
|
||||
content: <PlaceholderPanel />
|
||||
icon: <IconCurrencyDollar />
|
||||
},
|
||||
{
|
||||
name: 'suppliers',
|
||||
label: t`Suppliers`,
|
||||
icon: <IconBuilding />,
|
||||
hidden: !part.purchaseable,
|
||||
content: <PlaceholderPanel />
|
||||
hidden: !part.purchaseable
|
||||
},
|
||||
{
|
||||
name: 'purchase_orders',
|
||||
label: t`Purchase Orders`,
|
||||
icon: <IconShoppingCart />,
|
||||
content: <PlaceholderPanel />,
|
||||
hidden: !part.purchaseable
|
||||
},
|
||||
{
|
||||
name: 'sales_orders',
|
||||
label: t`Sales Orders`,
|
||||
icon: <IconTruckDelivery />,
|
||||
content: <PlaceholderPanel />,
|
||||
hidden: !part.salable
|
||||
},
|
||||
{
|
||||
name: 'scheduling',
|
||||
label: t`Scheduling`,
|
||||
icon: <IconCalendarStats />
|
||||
},
|
||||
{
|
||||
name: 'stocktake',
|
||||
label: t`Stocktake`,
|
||||
icon: <IconClipboardList />
|
||||
},
|
||||
{
|
||||
name: 'test_templates',
|
||||
label: t`Test Templates`,
|
||||
icon: <IconTestPipe />,
|
||||
content: <PlaceholderPanel />,
|
||||
hidden: !part.trackable
|
||||
},
|
||||
{
|
||||
@ -212,6 +219,79 @@ export default function PartDetail() {
|
||||
);
|
||||
}, [part, id]);
|
||||
|
||||
const partActions = useMemo(() => {
|
||||
// TODO: Disable actions based on user permissions
|
||||
return [
|
||||
<ActionDropdown
|
||||
tooltip={t`Barcode Actions`}
|
||||
icon={<IconQrcode />}
|
||||
actions={[
|
||||
{
|
||||
icon: <IconQrcode />,
|
||||
name: t`View`,
|
||||
tooltip: t`View part barcode`
|
||||
},
|
||||
{
|
||||
icon: <IconLink />,
|
||||
name: t`Link Barcode`,
|
||||
tooltip: t`Link custom barcode to part`,
|
||||
disabled: part?.barcode_hash
|
||||
},
|
||||
{
|
||||
icon: <IconUnlink />,
|
||||
name: t`Unlink Barcode`,
|
||||
tooltip: t`Unlink custom barcode from part`,
|
||||
disabled: !part?.barcode_hash
|
||||
}
|
||||
]}
|
||||
/>,
|
||||
<ActionDropdown
|
||||
tooltip={t`Stock Actions`}
|
||||
icon={<IconPackages />}
|
||||
actions={[
|
||||
{
|
||||
icon: <IconClipboardList color="blue" />,
|
||||
name: t`Count Stock`,
|
||||
tooltip: t`Count part stock`
|
||||
},
|
||||
{
|
||||
icon: <IconTransfer color="blue" />,
|
||||
name: t`Transfer Stock`,
|
||||
tooltip: t`Transfer part stock`
|
||||
}
|
||||
]}
|
||||
/>,
|
||||
<ActionDropdown
|
||||
tooltip={t`Part Actions`}
|
||||
icon={<IconDots />}
|
||||
actions={[
|
||||
{
|
||||
icon: <IconEdit color="blue" />,
|
||||
name: t`Edit`,
|
||||
tooltip: t`Edit part`,
|
||||
onClick: () => {
|
||||
part.pk &&
|
||||
editPart({
|
||||
part_id: part.pk,
|
||||
callback: refreshInstance
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: <IconCopy color="green" />,
|
||||
name: t`Duplicate`,
|
||||
tooltip: t`Duplicate part`
|
||||
},
|
||||
{
|
||||
icon: <IconTrash color="red" />,
|
||||
name: t`Delete`,
|
||||
tooltip: t`Delete part`
|
||||
}
|
||||
]}
|
||||
/>
|
||||
];
|
||||
}, [id, part, user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack spacing="xs">
|
||||
@ -219,21 +299,7 @@ export default function PartDetail() {
|
||||
<PageDetail
|
||||
detail={partDetail}
|
||||
breadcrumbs={breadcrumbs}
|
||||
actions={[
|
||||
<Button
|
||||
variant="outline"
|
||||
color="blue"
|
||||
onClick={() =>
|
||||
part.pk &&
|
||||
editPart({
|
||||
part_id: part.pk,
|
||||
callback: refreshInstance
|
||||
})
|
||||
}
|
||||
>
|
||||
Edit Part
|
||||
</Button>
|
||||
]}
|
||||
actions={partActions}
|
||||
/>
|
||||
<PanelGroup pageKey="part" panels={partPanels} />
|
||||
</Stack>
|
||||
|
@ -26,13 +26,23 @@ export default function PurchasingIndex() {
|
||||
name: 'suppliers',
|
||||
label: t`Suppliers`,
|
||||
icon: <IconBuildingStore />,
|
||||
content: <CompanyTable params={{ is_supplier: true }} />
|
||||
content: (
|
||||
<CompanyTable
|
||||
path="purchasing/supplier"
|
||||
params={{ is_supplier: true }}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'manufacturer',
|
||||
label: t`Manufacturers`,
|
||||
icon: <IconBuildingFactory2 />,
|
||||
content: <CompanyTable params={{ is_manufacturer: true }} />
|
||||
content: (
|
||||
<CompanyTable
|
||||
path="purchasing/manufacturer"
|
||||
params={{ is_manufacturer: true }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
@ -32,7 +32,9 @@ export default function PurchasingIndex() {
|
||||
name: 'suppliers',
|
||||
label: t`Customers`,
|
||||
icon: <IconBuildingStore />,
|
||||
content: <CompanyTable params={{ is_customer: true }} />
|
||||
content: (
|
||||
<CompanyTable path="sales/customer" params={{ is_customer: true }} />
|
||||
)
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
@ -12,6 +12,22 @@ export const Playground = Loadable(
|
||||
lazy(() => import('./pages/Index/Playground'))
|
||||
);
|
||||
|
||||
export const CompanyDetail = Loadable(
|
||||
lazy(() => import('./pages/company/CompanyDetail'))
|
||||
);
|
||||
|
||||
export const CustomerDetail = Loadable(
|
||||
lazy(() => import('./pages/company/CustomerDetail'))
|
||||
);
|
||||
|
||||
export const SupplierDetail = Loadable(
|
||||
lazy(() => import('./pages/company/SupplierDetail'))
|
||||
);
|
||||
|
||||
export const ManufacturerDetail = Loadable(
|
||||
lazy(() => import('./pages/company/ManufacturerDetail'))
|
||||
);
|
||||
|
||||
export const CategoryDetail = Loadable(
|
||||
lazy(() => import('./pages/part/CategoryDetail'))
|
||||
);
|
||||
@ -109,9 +125,13 @@ export const routes = (
|
||||
</Route>
|
||||
<Route path="purchasing/">
|
||||
<Route index element={<PurchasingIndex />} />
|
||||
<Route path="supplier/:id/" element={<SupplierDetail />} />
|
||||
<Route path="manufacturer/:id/" element={<ManufacturerDetail />} />
|
||||
</Route>
|
||||
<Route path="company/:id/" element={<CompanyDetail />} />
|
||||
<Route path="sales/">
|
||||
<Route index element={<SalesIndex />} />
|
||||
<Route path="customer/:id/" element={<CustomerDetail />} />
|
||||
</Route>
|
||||
<Route path="/profile/:tabValue" element={<Profile />} />
|
||||
</Route>
|
||||
|
@ -55,6 +55,7 @@ export enum ApiPaths {
|
||||
|
||||
// Company URLs
|
||||
company_list = 'api-company-list',
|
||||
company_attachment_list = 'api-company-attachment-list',
|
||||
supplier_part_list = 'api-supplier-part-list',
|
||||
|
||||
// Stock Item URLs
|
||||
@ -137,6 +138,8 @@ export function apiEndpoint(path: ApiPaths): string {
|
||||
return 'part/attachment/';
|
||||
case ApiPaths.company_list:
|
||||
return 'company/';
|
||||
case ApiPaths.company_attachment_list:
|
||||
return 'company/attachment/';
|
||||
case ApiPaths.supplier_part_list:
|
||||
return 'company/part/';
|
||||
case ApiPaths.stock_item_list:
|
||||
|
16
src/frontend/src/states/ModalState.tsx
Normal file
16
src/frontend/src/states/ModalState.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface ModalStateProps {
|
||||
loading: boolean;
|
||||
lock: () => void;
|
||||
unlock: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global state manager for modal forms.
|
||||
*/
|
||||
export const useModalState = create<ModalStateProps>((set) => ({
|
||||
loading: false,
|
||||
lock: () => set(() => ({ loading: true })),
|
||||
unlock: () => set(() => ({ loading: false }))
|
||||
}));
|
@ -10,6 +10,7 @@ interface UserStateProps {
|
||||
username: () => string;
|
||||
setUser: (newUser: UserProps) => void;
|
||||
fetchUserState: () => void;
|
||||
checkUserRole: (role: string, permission: string) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
Reference in New Issue
Block a user