From 8f1cabcf5e338db4c19f3c56227f52f15a94e0be Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Ren=C3=A9=20Schr=C3=B6der?=
<131770181+ReneSchroederLJ@users.noreply.github.com>
Date: Wed, 17 Apr 2024 10:29:47 +0200
Subject: [PATCH 1/5] feat: implementation of planned-production in the
dashboard
---
frontend/.env | 2 +
frontend/nginx.conf | 1 +
.../src/components/TableWithRowHeader.tsx | 3 +-
frontend/src/components/layout/SideBar.tsx | 10 +-
frontend/src/components/ui/DateTime.tsx | 99 +++++
.../dashboard/components/Dashboard.tsx | 250 ++++++++----
.../dashboard/components/DashboardFilters.tsx | 2 +-
.../components/PlannedProductionModal.tsx | 367 ++++++++++++++++++
.../dashboard/components/ProductionTable.tsx | 111 ++++--
.../features/dashboard/hooks/useProduction.ts | 33 ++
.../dashboard/hooks/useReportedProduction.ts | 32 ++
.../src/features/dashboard/util/helpers.tsx | 28 +-
.../components/StockDetailsView.tsx | 7 +-
frontend/src/index.css | 33 +-
frontend/src/models/constants/config.ts | 2 +
.../src/models/types/data/notification.ts | 24 ++
.../src/models/types/data/order-reference.ts | 24 ++
frontend/src/models/types/data/production.ts | 35 ++
frontend/src/models/types/data/stock.ts | 8 +-
frontend/src/router.tsx | 8 +-
frontend/src/services/productions-service.ts | 51 +++
frontend/src/util/helpers.ts | 6 +-
...ierDashboardView.tsx => DashboardView.tsx} | 2 +-
23 files changed, 998 insertions(+), 140 deletions(-)
create mode 100644 frontend/src/components/ui/DateTime.tsx
create mode 100644 frontend/src/features/dashboard/components/PlannedProductionModal.tsx
create mode 100644 frontend/src/features/dashboard/hooks/useProduction.ts
create mode 100644 frontend/src/features/dashboard/hooks/useReportedProduction.ts
create mode 100644 frontend/src/models/types/data/notification.ts
create mode 100644 frontend/src/models/types/data/order-reference.ts
create mode 100644 frontend/src/models/types/data/production.ts
create mode 100644 frontend/src/services/productions-service.ts
rename frontend/src/views/{SupplierDashboardView.tsx => DashboardView.tsx} (97%)
diff --git a/frontend/.env b/frontend/.env
index 9d9c5d7d..6d4c9128 100644
--- a/frontend/.env
+++ b/frontend/.env
@@ -13,6 +13,8 @@ VITE_ENDPOINT_REPORTED_PRODUCT_STOCKS=stockView/reported-product-stocks?ownMater
VITE_ENDPOINT_UPDATE_REPORTED_MATERIAL_STOCKS=stockView/update-reported-material-stocks?ownMaterialNumber=
VITE_ENDPOINT_UPDATE_REPORTED_PRODUCT_STOCKS=stockView/update-reported-product-stocks?ownMaterialNumber=
VITE_ENDPOINT_PARTNER_OWNSITES=partners/ownSites
+VITE_ENDPOINT_PRODUCTION=production
+VITE_ENDPOINT_PRODUCTION_RANGE=production/range
VITE_IDP_DISABLE=true
VITE_IDP_URL=http://localhost:10081/
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index 586abca8..38c7f681 100755
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -41,6 +41,7 @@ http {
limit_req zone=zoneLimit burst=${NGINX_BURST} nodelay;
root /usr/share/nginx/html;
index index.html index.htm;
+ try_files $uri $uri/ /index.html;
}
}
}
diff --git a/frontend/src/components/TableWithRowHeader.tsx b/frontend/src/components/TableWithRowHeader.tsx
index e18e2c58..edb3e8bd 100644
--- a/frontend/src/components/TableWithRowHeader.tsx
+++ b/frontend/src/components/TableWithRowHeader.tsx
@@ -31,6 +31,7 @@ export const TableWithRowHeader = ({ rows, ...tableProps }: TableWithRowHeaderPr
title=''
columns={[{ field: 'name', headerName: '', width: 180 }]}
rows={rows}
+ density='standard'
rowSelection={false}
hideFooter={true}
disableColumnFilter
@@ -38,7 +39,7 @@ export const TableWithRowHeader = ({ rows, ...tableProps }: TableWithRowHeaderPr
sortingMode={'server'}
/>
-
+
diff --git a/frontend/src/components/layout/SideBar.tsx b/frontend/src/components/layout/SideBar.tsx
index 24b4e045..36607905 100644
--- a/frontend/src/components/layout/SideBar.tsx
+++ b/frontend/src/components/layout/SideBar.tsx
@@ -45,6 +45,11 @@ type SideBarItemProps = (
};
const sideBarItems: SideBarItemProps[] = [
+ {
+ name: 'Dashboard',
+ icon: HomeIcon,
+ path: '/dashboard',
+ },
{
name: 'Stocks',
icon: StockIcon,
@@ -68,11 +73,6 @@ const sideBarItems: SideBarItemProps[] = [
path: '/transfers',
requiredRoles: ['PURIS_ADMIN'],
},
- {
- name: 'Supplier Dashboard',
- icon: HomeIcon,
- path: '/supplierDashboard',
- },
{
name: 'Logout',
icon: TrashIcon,
diff --git a/frontend/src/components/ui/DateTime.tsx b/frontend/src/components/ui/DateTime.tsx
new file mode 100644
index 00000000..4c917f2c
--- /dev/null
+++ b/frontend/src/components/ui/DateTime.tsx
@@ -0,0 +1,99 @@
+/*
+Copyright (c) 2024 Volkswagen AG
+Copyright (c) 2024 Contributors to the Eclipse Foundation
+
+See the NOTICE file(s) distributed with this work for additional
+information regarding copyright ownership.
+
+This program and the accompanying materials are made available under the
+terms of the Apache License, Version 2.0 which is available at
+https://www.apache.org/licenses/LICENSE-2.0.
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+License for the specific language governing permissions and limitations
+under the License.
+
+SPDX-License-Identifier: Apache-2.0
+*/
+import { DateType, Datepicker } from '@catena-x/portal-shared-components';
+import Box from '@mui/material/Box';
+import { useEffect, useRef, useState } from 'react';
+
+const isValidTime = (time?: string) => {
+ if (!time) {
+ return false;
+ }
+ const splits = time.split(':');
+ if (splits.length !== 2) {
+ return false;
+ }
+ const [hours, minutes] = splits;
+ return parseInt(hours) >= 0 && parseInt(hours) <= 23 && parseInt(minutes) >= 0 && parseInt(minutes) <= 59;
+};
+
+type DateTimeProps = {
+ label: string;
+ placeholder: string;
+ locale: 'en' | 'de';
+ error: boolean;
+ value: Date | null;
+ onValueChange: (date: Date | null) => void;
+};
+
+export const DateTime = ({ error, value, onValueChange, ...props }: DateTimeProps) => {
+ const [date, setDate] = useState(value ? new Date(value) : null);
+ const timeRef = useRef(null);
+ const handleTimeChange = () => {
+ const time = timeRef.current?.value;
+ if (time && date) {
+ const [hours, minutes] = time.split(':');
+ const newDate = new Date(date);
+ newDate.setHours(parseInt(hours));
+ newDate.setMinutes(parseInt(minutes));
+ onValueChange(newDate);
+ } else {
+ onValueChange(null);
+ }
+ };
+ const handleDateChange = (newDate: DateType) => {
+ setDate(newDate);
+ if (newDate && timeRef.current) {
+ onValueChange(new Date(newDate));
+ } else {
+ onValueChange(null);
+ }
+ };
+
+ useEffect(() => {
+ if (value) {
+ const d = new Date(value);
+ setDate(d);
+ const hours = d.getHours().toString().padStart(2, '0');
+ const minutes = d.getMinutes().toString().padStart(2, '0');
+ timeRef.current!.value = `${hours}:${minutes}`;
+ }
+ }, [value]);
+ return (
+
+
+ handleDateChange(event)}
+ />
+
+
+
+ );
+};
diff --git a/frontend/src/features/dashboard/components/Dashboard.tsx b/frontend/src/features/dashboard/components/Dashboard.tsx
index 9967634b..787a3feb 100644
--- a/frontend/src/features/dashboard/components/Dashboard.tsx
+++ b/frontend/src/features/dashboard/components/Dashboard.tsx
@@ -17,103 +17,215 @@ under the License.
SPDX-License-Identifier: Apache-2.0
*/
-
import { usePartnerStocks } from '@features/stock-view/hooks/usePartnerStocks';
import { useStocks } from '@features/stock-view/hooks/useStocks';
import { MaterialDescriptor } from '@models/types/data/material-descriptor';
import { Site } from '@models/types/edc/site';
-import { useState } from 'react';
+import { useCallback, useReducer } from 'react';
import { DashboardFilters } from './DashboardFilters';
import { DemandTable } from './DemandTable';
import { ProductionTable } from './ProductionTable';
-import { Stack, Typography, capitalize } from '@mui/material';
+import { Box, Button, Stack, Typography, capitalize } from '@mui/material';
import { Delivery } from '@models/types/data/delivery';
import { DeliveryInformationModal } from './DeliveryInformationModal';
import { getPartnerType } from '../util/helpers';
+import { Production } from '@models/types/data/production';
+import { PlannedProductionModal } from './PlannedProductionModal';
+import { useProduction } from '../hooks/useProduction';
+import { useReportedProduction } from '../hooks/useReportedProduction';
+import { LoadingButton } from '@catena-x/portal-shared-components';
+import { Refresh } from '@mui/icons-material';
+import { refreshPartnerStocks } from '@services/stocks-service';
+
+const NUMBER_OF_DAYS = 28;
-const NUMBER_OF_DAYS = 42;
+type DashboardState = {
+ selectedMaterial: MaterialDescriptor | null;
+ selectedSite: Site | null;
+ selectedPartnerSites: Site[] | null;
+ deliveryDialogOptions: { open: boolean; mode: 'create' | 'edit' };
+ productionDialogOptions: { open: boolean; mode: 'create' | 'edit' };
+ delivery: Delivery | null;
+ production: Partial | null;
+ isRefreshing: boolean;
+};
+
+type DashboardAction = {
+ type: keyof DashboardState;
+ payload: DashboardState[keyof DashboardState];
+};
+
+const reducer = (state: DashboardState, action: DashboardAction): DashboardState => {
+ return { ...state, [action.type]: action.payload };
+};
+
+const initialState: DashboardState = {
+ selectedMaterial: null,
+ selectedSite: null,
+ selectedPartnerSites: null,
+ deliveryDialogOptions: { open: false, mode: 'create' },
+ productionDialogOptions: { open: false, mode: 'edit' },
+ delivery: null,
+ production: null,
+ isRefreshing: false,
+};
export const Dashboard = ({ type }: { type: 'customer' | 'supplier' }) => {
- const [selectedMaterial, setSelectedMaterial] = useState(null);
- const [selectedSite, setSelectedSite] = useState(null);
- const [selectedPartnerSites, setSelectedPartnerSites] = useState(null);
+ const [state, dispatch] = useReducer(reducer, initialState);
const { stocks } = useStocks(type === 'customer' ? 'material' : 'product');
- const { partnerStocks } = usePartnerStocks(type === 'customer' ? 'material' : 'product', selectedMaterial?.ownMaterialNumber ?? null);
- const [open, setOpen] = useState(false);
- const [delivery, setDelivery] = useState(null);
- const openDeliveryDialog = (d: Delivery) => {
- setDelivery(d);
- setOpen(true);
+ const { partnerStocks, refreshPartnerStocks: refresh } = usePartnerStocks(
+ type === 'customer' ? 'material' : 'product',
+ state.selectedMaterial?.ownMaterialNumber ?? null
+ );
+ const { productions, refreshProduction } = useProduction(
+ state.selectedMaterial?.ownMaterialNumber ?? null,
+ state.selectedSite?.bpns ?? null
+ );
+ const { reportedProductions } = useReportedProduction(state.selectedMaterial?.ownMaterialNumber ?? null);
+
+ const handleRefresh = () => {
+ dispatch({ type: 'isRefreshing', payload: true });
+ refreshPartnerStocks( type === 'customer' ? 'material' : 'product', state.selectedMaterial?.ownMaterialNumber ?? null )
+ .then(refresh)
+ .finally(() => dispatch({ type: 'isRefreshing', payload: false }));
+ };
+ const openDeliveryDialog = (d: Partial) => {
+ dispatch({ type: 'delivery', payload: d });
+ dispatch({ type: 'deliveryDialogOptions', payload: { open: true, mode: 'edit' } });
};
- const handleMaterialSelect = (material: MaterialDescriptor | null) => {
- setSelectedMaterial(material);
- setSelectedSite(null);
- setSelectedPartnerSites(null);
+ const openProductionDialog = (p: Partial, mode: 'create' | 'edit') => {
+ p.material ??= {
+ materialFlag: true,
+ productFlag: false,
+ materialNumberSupplier: state.selectedMaterial?.ownMaterialNumber ?? '',
+ materialNumberCustomer: null,
+ materialNumberCx: null,
+ name: state.selectedMaterial?.description ?? '',
+ };
+ p.measurementUnit ??= 'unit:piece';
+ dispatch({ type: 'production', payload: p });
+ dispatch({ type: 'productionDialogOptions', payload: { open: true, mode } });
};
+ const handleMaterialSelect = useCallback((material: MaterialDescriptor | null) => {
+ dispatch({ type: 'selectedMaterial', payload: material });
+ dispatch({ type: 'selectedSite', payload: null });
+ dispatch({ type: 'selectedPartnerSites', payload: null });
+ }, []);
return (
<>
-
+
dispatch({ type: 'selectedSite', payload: site })}
+ onPartnerSitesChange={(sites) => dispatch({ type: 'selectedPartnerSites', payload: sites })}
/>
-
- Our Stock Information {selectedMaterial && selectedSite && <>for {selectedMaterial.description}>}
-
- {selectedSite ? (
- type === 'supplier' ? (
-
+
+
+ Production Information
+ {state.selectedMaterial && state.selectedSite && <> for {state.selectedMaterial.description} ({state.selectedMaterial.ownMaterialNumber})>}
+
+ {state.selectedSite && state.selectedMaterial ? (
+ type === 'supplier' ? (
+
+ ) : (
+
+ )
) : (
-
- )
- ) : (
- Select a Site to show production data
- )}
- {selectedSite && (
- <>
-
- {`${capitalize(getPartnerType(type))} Stocks ${selectedMaterial ? `for ${selectedMaterial?.description}` : ''}`}
-
- {selectedPartnerSites ? (
- selectedPartnerSites.map((ps) =>
- type === 'supplier' ? (
- Select a Site to show production data
+ )}
+
+ {state.selectedSite && (
+
+
+
+ {`${capitalize(getPartnerType(type))} Information ${
+ state.selectedMaterial ? `for ${state.selectedMaterial.description} (${state.selectedMaterial.ownMaterialNumber})` : ''
+ }`}
+
+ {state.selectedPartnerSites?.length &&
+ (state.isRefreshing ? (
+
) : (
-
+
+ ))}
+
+
+ {state.selectedPartnerSites ? (
+ state.selectedPartnerSites.map((ps) =>
+ type === 'supplier' ? (
+
+ ) : (
+ p.productionSiteBpns === ps.bpns) ?? []}
+ readOnly
+ />
+ )
)
- )
- ) : (
- {`Select a ${getPartnerType(type)} site to show their stock information`}
- )}
- >
+ ) : (
+ {`Select a ${getPartnerType(
+ type
+ )} site to show their stock information`}
+ )}
+
+
)}
- setOpen(false)} delivery={delivery} />
+
+ dispatch({ type: 'deliveryDialogOptions', payload: { open: false, mode: state.deliveryDialogOptions.mode } })
+ }
+ delivery={state.delivery}
+ />
+ dispatch({ type: 'productionDialogOptions', payload: { open: false, mode: state.productionDialogOptions.mode } })}
+ onSave={refreshProduction}
+ production={state.production}
+ productions={state.productionDialogOptions.mode === 'edit' ? productions ?? [] : []}
+ />
>
);
};
diff --git a/frontend/src/features/dashboard/components/DashboardFilters.tsx b/frontend/src/features/dashboard/components/DashboardFilters.tsx
index d639694b..a72d5698 100644
--- a/frontend/src/features/dashboard/components/DashboardFilters.tsx
+++ b/frontend/src/features/dashboard/components/DashboardFilters.tsx
@@ -56,7 +56,7 @@ export const DashboardFilters = ({
id="material"
value={material}
options={materials ?? []}
- getOptionLabel={(option) => option.ownMaterialNumber}
+ getOptionLabel={(option) => `${option.description} (${option.ownMaterialNumber})`}
renderInput={(params) => }
onChange={(_, newValue) => onMaterialChange(newValue || null)}
/>
diff --git a/frontend/src/features/dashboard/components/PlannedProductionModal.tsx b/frontend/src/features/dashboard/components/PlannedProductionModal.tsx
new file mode 100644
index 00000000..7d55438d
--- /dev/null
+++ b/frontend/src/features/dashboard/components/PlannedProductionModal.tsx
@@ -0,0 +1,367 @@
+/*
+Copyright (c) 2024 Volkswagen AG
+Copyright (c) 2024 Contributors to the Eclipse Foundation
+
+See the NOTICE file(s) distributed with this work for additional
+information regarding copyright ownership.
+
+This program and the accompanying materials are made available under the
+terms of the Apache License, Version 2.0 which is available at
+https://www.apache.org/licenses/LICENSE-2.0.
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+License for the specific language governing permissions and limitations
+under the License.
+
+SPDX-License-Identifier: Apache-2.0
+*/
+import { useEffect, useMemo, useState } from 'react';
+import { Input, PageSnackbar, PageSnackbarStack, Table } from '@catena-x/portal-shared-components';
+import { UNITS_OF_MEASUREMENT } from '@models/constants/uom';
+import { Production } from '@models/types/data/production';
+import { Autocomplete, Box, Button, Dialog, DialogTitle, Grid, Stack, Typography, capitalize } from '@mui/material';
+import { getUnitOfMeasurement, isValidOrderReference } from '@util/helpers';
+import { usePartners } from '@features/stock-view/hooks/usePartners';
+import { deleteProduction, postProductionRange } from '@services/productions-service';
+import { Notification } from '@models/types/data/notification';
+import { Close, Delete, Save } from '@mui/icons-material';
+import { DateTime } from '@components/ui/DateTime';
+
+const GridItem = ({ label, value }: { label: string; value: string }) => (
+
+
+
+ {label}:
+
+
+ {value}
+
+
+
+);
+
+const createProductionColumns = (handleDelete: (row: Production) => void) =>
+ [
+ {
+ field: 'estimatedTimeOfCompletion',
+ headerName: 'Completion Time',
+ headerAlign: 'center',
+ width: 150,
+ renderCell: (data: { row: Production }) => (
+
+ {new Date(data.row.estimatedTimeOfCompletion).toLocaleTimeString('de-DE')}
+
+ ),
+ },
+ {
+ field: 'quantity',
+ headerName: 'Quantity',
+ headerAlign: 'center',
+ width: 120,
+ renderCell: (data: { row: Production }) => (
+
+ {`${data.row.quantity} ${getUnitOfMeasurement(data.row.measurementUnit)}`}
+
+ ),
+ },
+ {
+ field: 'partner',
+ headerName: 'Partner',
+ headerAlign: 'center',
+ width: 200,
+ renderCell: (data: { row: Production }) => (
+
+ {data.row.partner?.name}
+
+ ),
+ },
+ {
+ field: 'customerOrderNumber',
+ headerName: 'Order Reference',
+ headerAlign: 'center',
+ width: 200,
+ renderCell: (data: { row: Production }) => (
+
+ {data.row.customerOrderNumber ? (
+
+ {`${data.row.customerOrderNumber} / ${data.row.customerOrderPositionNumber}`}
+ {data.row.supplierOrderNumber}
+
+ ) : (
+ '-'
+ )}
+
+ ),
+ },
+ {
+ field: 'delete',
+ headerName: '',
+ sortable: false,
+ filterable: false,
+ hideable: false,
+ headerAlign: 'center',
+ width: 30,
+ renderCell: (data: { row: Production }) => (
+
+
+
+ ),
+ },
+ ] as const;
+
+type PlannedProductionModalProps = {
+ open: boolean;
+ mode: 'create' | 'edit';
+ onClose: () => void;
+ onSave: () => void;
+ production: Partial | null;
+ productions: Production[];
+};
+const isValidProduction = (production: Partial) =>
+ production &&
+ production.estimatedTimeOfCompletion &&
+ production.quantity &&
+ production.measurementUnit &&
+ production.partner &&
+ isValidOrderReference(production);
+
+export const PlannedProductionModal = ({ open, mode, onClose, onSave, production, productions }: PlannedProductionModalProps) => {
+ const [temporaryProduction, setTemporaryProduction] = useState>(production ?? {});
+ const { partners } = usePartners('product', temporaryProduction?.material?.materialNumberSupplier ?? null);
+ const [notifications, setNotifications] = useState([]);
+ const [formError, setFormError] = useState(false);
+ const dailyProductions = useMemo(
+ () =>
+ productions.filter(
+ (p) =>
+ new Date(p.estimatedTimeOfCompletion).toLocaleDateString() ===
+ new Date(production?.estimatedTimeOfCompletion ?? Date.now()).toLocaleDateString()
+ ),
+ [productions, production?.estimatedTimeOfCompletion]
+ );
+
+ const handleSaveClick = () => {
+ temporaryProduction.customerOrderNumber ||= undefined;
+ temporaryProduction.customerOrderPositionNumber ||= undefined;
+ temporaryProduction.supplierOrderNumber ||= undefined;
+ if (!isValidProduction(temporaryProduction)) {
+ setFormError(true);
+ return;
+ }
+ setFormError(false);
+ postProductionRange([temporaryProduction])
+ .then(() => {
+ onSave();
+ setNotifications((ns) => [
+ ...ns,
+ {
+ title: 'Production Created',
+ description: 'The Production has been saved successfully',
+ severity: 'success',
+ },
+ ]);
+ })
+ .catch((error) => {
+ setNotifications((ns) => [
+ ...ns,
+ {
+ title: error.status === 409 ? 'Conflict' : 'Error requesting update',
+ description: error.status === 409 ? 'Date conflicting with another Production' : error.error,
+ severity: 'error',
+ },
+ ]);
+ })
+ .finally(onClose);
+ };
+ const handleDelete = (row: Production) => {
+ if (row.uuid) deleteProduction(row.uuid).then(onSave);
+ };
+ useEffect(() => {
+ if (production) setTemporaryProduction(production);
+ }, [production]);
+ return (
+ <>
+