-
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Paper,
|
||||
Typography,
|
||||
Grid,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
Alert,
|
||||
Chip,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Tabs,
|
||||
Tab,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
import { DataGrid } from "@mui/x-data-grid";
|
||||
import {
|
||||
Save as SaveIcon,
|
||||
ArrowBack as BackIcon,
|
||||
Check as CheckIcon,
|
||||
PlayArrow as StartIcon,
|
||||
Done as CompleteIcon,
|
||||
Visibility as ViewIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { productionService } from "../services/productionService";
|
||||
import { articleService } from "../../warehouse/services/warehouseService";
|
||||
import { CreateProductionOrderDto, UpdateProductionOrderDto, ProductionOrderStatus } from "../types";
|
||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||
import dayjs from "dayjs";
|
||||
import ProductionOrderPhases from "../components/ProductionOrderPhases";
|
||||
|
||||
export default function ProductionOrderFormPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const isEdit = Boolean(id);
|
||||
const queryClient = useQueryClient();
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
|
||||
const { control, handleSubmit, reset, watch, setValue, formState: { errors } } = useForm<CreateProductionOrderDto>({
|
||||
defaultValues: {
|
||||
articleId: 0,
|
||||
quantity: 1,
|
||||
startDate: new Date().toISOString(),
|
||||
dueDate: new Date().toISOString(),
|
||||
notes: "",
|
||||
billOfMaterialsId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: order, isLoading } = useQuery({
|
||||
queryKey: ["production-order", id],
|
||||
queryFn: () => productionService.getProductionOrderById(Number(id)),
|
||||
enabled: isEdit,
|
||||
});
|
||||
|
||||
const { data: articles = [] } = useQuery({
|
||||
queryKey: ["articles"],
|
||||
queryFn: () => articleService.getAll(),
|
||||
});
|
||||
|
||||
const { data: boms = [] } = useQuery({
|
||||
queryKey: ["boms"],
|
||||
queryFn: () => productionService.getBillOfMaterials(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (order) {
|
||||
reset({
|
||||
articleId: order.articleId,
|
||||
quantity: order.quantity,
|
||||
startDate: order.startDate,
|
||||
dueDate: order.dueDate,
|
||||
notes: order.notes,
|
||||
});
|
||||
}
|
||||
}, [order, reset]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateProductionOrderDto) => productionService.createProductionOrder(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["production-orders"] });
|
||||
navigate("/production/orders");
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: UpdateProductionOrderDto) => productionService.updateProductionOrder(Number(id), data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["production-orders"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["production-order", id] });
|
||||
navigate("/production/orders");
|
||||
},
|
||||
});
|
||||
|
||||
const changeStatusMutation = useMutation({
|
||||
mutationFn: (status: ProductionOrderStatus) => productionService.changeProductionOrderStatus(Number(id), status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["production-orders"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["production-order", id] });
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: CreateProductionOrderDto) => {
|
||||
if (isEdit) {
|
||||
// For update we need to pass status as well, but form doesn't have it.
|
||||
// We assume status doesn't change via form save, only via actions.
|
||||
const updateData: UpdateProductionOrderDto = {
|
||||
quantity: data.quantity,
|
||||
startDate: data.startDate,
|
||||
dueDate: data.dueDate,
|
||||
notes: data.notes,
|
||||
status: order!.status // Keep existing status
|
||||
};
|
||||
updateMutation.mutate(updateData);
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArticleChange = (articleId: number | null) => {
|
||||
if (!articleId) return;
|
||||
setValue('articleId', articleId);
|
||||
|
||||
// Try to find a BOM for this article to auto-select
|
||||
const bom = boms.find(b => b.articleId === articleId);
|
||||
if (bom) {
|
||||
setValue('billOfMaterialsId', bom.id);
|
||||
}
|
||||
};
|
||||
|
||||
const isReadOnly = isEdit && order?.status === ProductionOrderStatus.Completed;
|
||||
|
||||
if (isEdit && isLoading) return <Typography>Loading...</Typography>;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 800, mx: "auto" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 3 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Button
|
||||
startIcon={<BackIcon />}
|
||||
onClick={() => navigate("/production/orders")}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
<Box>
|
||||
<Typography variant="h4">
|
||||
{isEdit ? `${t("production.order.editTitle")} ${order?.code}` : t("production.order.createTitle")}
|
||||
</Typography>
|
||||
{isEdit && order && (
|
||||
<Chip
|
||||
label={t(`production.order.status.${ProductionOrderStatus[order.status]}`)}
|
||||
color={order.status === ProductionOrderStatus.Completed ? "success" : "default"}
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
{isEdit && order?.status === ProductionOrderStatus.Draft && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="info"
|
||||
startIcon={<CheckIcon />}
|
||||
onClick={() => changeStatusMutation.mutate(ProductionOrderStatus.Planned)}
|
||||
>
|
||||
{t("production.order.actions.plan")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isEdit && order?.status === ProductionOrderStatus.Planned && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<StartIcon />}
|
||||
onClick={() => changeStatusMutation.mutate(ProductionOrderStatus.Released)}
|
||||
>
|
||||
{t("production.order.actions.release")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isEdit && order?.status === ProductionOrderStatus.Released && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
startIcon={<StartIcon />}
|
||||
onClick={() => changeStatusMutation.mutate(ProductionOrderStatus.InProgress)}
|
||||
>
|
||||
{t("production.order.actions.start")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isEdit && order?.status === ProductionOrderStatus.InProgress && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={<CompleteIcon />}
|
||||
onClick={() => changeStatusMutation.mutate(ProductionOrderStatus.Completed)}
|
||||
>
|
||||
{t("production.order.actions.complete")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{(createMutation.isError || updateMutation.isError || changeStatusMutation.isError) && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{t("common.error")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Controller
|
||||
name="articleId"
|
||||
control={control}
|
||||
rules={{ required: t("common.required") }}
|
||||
render={({ field }: { field: any }) => (
|
||||
<Autocomplete
|
||||
options={articles}
|
||||
getOptionLabel={(option) => `${option.code} - ${option.description}`}
|
||||
value={articles.find(a => a.id === field.value) || null}
|
||||
onChange={(_, newValue) => handleArticleChange(newValue?.id || null)}
|
||||
disabled={isEdit || isReadOnly} // Cannot change article after creation
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t("production.order.fields.article")}
|
||||
error={!!errors.articleId}
|
||||
helperText={errors.articleId?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{!isEdit && (
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Controller
|
||||
name="billOfMaterialsId"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<Autocomplete
|
||||
options={boms.filter(b => b.articleId === watch('articleId'))}
|
||||
getOptionLabel={(option) => option.name}
|
||||
value={boms.find(b => b.id === field.value) || null}
|
||||
onChange={(_, newValue) => field.onChange(newValue?.id)}
|
||||
disabled={isReadOnly}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t("production.order.fields.bom")}
|
||||
helperText={t("production.order.fields.bomHelp")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{!isEdit && (
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Controller
|
||||
name="createChildOrders"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t("production.order.fields.createChildOrders")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Controller
|
||||
name="quantity"
|
||||
control={control}
|
||||
rules={{ required: t("common.required"), min: 0.0001 }}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("production.order.fields.quantity")}
|
||||
type="number"
|
||||
fullWidth
|
||||
disabled={isReadOnly}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
error={!!errors.quantity}
|
||||
helperText={errors.quantity?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Controller
|
||||
name="startDate"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<DatePicker
|
||||
label={t("production.order.fields.startDate")}
|
||||
value={field.value ? dayjs(field.value) : null}
|
||||
onChange={(date) => field.onChange(date?.toISOString())}
|
||||
disabled={isReadOnly}
|
||||
slotProps={{ textField: { fullWidth: true } }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Controller
|
||||
name="dueDate"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<DatePicker
|
||||
label={t("production.order.fields.dueDate")}
|
||||
value={field.value ? dayjs(field.value) : null}
|
||||
onChange={(date) => field.onChange(date?.toISOString())}
|
||||
disabled={isReadOnly}
|
||||
slotProps={{ textField: { fullWidth: true } }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Controller
|
||||
name="notes"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("production.order.fields.notes")}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{isEdit && order && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tabValue} onChange={(_, val) => setTabValue(val)}>
|
||||
<Tab label={t("production.order.phases.title")} />
|
||||
<Tab label={t("production.order.subOrders")} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<ProductionOrderPhases order={order} isReadOnly={isReadOnly} />
|
||||
</TabPanel>
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
{order.childOrders && order.childOrders.length > 0 ? (
|
||||
<Paper sx={{ width: '100%' }}>
|
||||
<DataGrid
|
||||
rows={order.childOrders}
|
||||
columns={[
|
||||
{ field: "code", headerName: t("production.order.columns.code"), width: 150 },
|
||||
{ field: "articleName", headerName: t("production.order.columns.article"), flex: 1 },
|
||||
{ field: "quantity", headerName: t("production.order.columns.quantity"), width: 100 },
|
||||
{ field: "status", headerName: t("production.order.columns.status"), width: 150, valueGetter: (_value, row: any) => t(`production.order.status.${ProductionOrderStatus[row.status]}`) },
|
||||
{
|
||||
field: "actions",
|
||||
headerName: t("common.actions"),
|
||||
width: 100,
|
||||
renderCell: (params: any) => (
|
||||
<IconButton size="small" onClick={() => navigate(`/production/orders/${params.row.id}`)}>
|
||||
<ViewIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
]}
|
||||
hideFooter
|
||||
autoHeight
|
||||
/>
|
||||
</Paper>
|
||||
) : (
|
||||
<Typography sx={{ p: 2 }}>{t("production.order.noSubOrders")}</Typography>
|
||||
)}
|
||||
</TabPanel>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TabPanel(props: { children?: React.ReactNode; index: number; value: number }) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`simple-tabpanel-${index}`}
|
||||
aria-labelledby={`simple-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user