440 lines
15 KiB
TypeScript
440 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|