| @@ -1,3 +1,4 @@ | |||
| import { preloadBomCombo } from "@/app/api/bom"; | |||
| import JoSearch from "@/components/JoSearch"; | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import { Stack, Typography } from "@mui/material"; | |||
| @@ -11,6 +12,8 @@ export const metadata: Metadata = { | |||
| const jo: React.FC = async () => { | |||
| const { t } = await getServerI18n("jo"); | |||
| preloadBomCombo() | |||
| return ( | |||
| <> | |||
| <Stack | |||
| @@ -0,0 +1,21 @@ | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { cache } from "react"; | |||
| export interface BomCombo { | |||
| id: number; | |||
| value: number; | |||
| label: string; | |||
| } | |||
| export const preloadBomCombo = (() => { | |||
| fetchBomCombo() | |||
| }) | |||
| export const fetchBomCombo = cache(async () => { | |||
| return serverFetchJson<BomCombo[]>(`${BASE_API_URL}/bom/combo`, { | |||
| next: { tags: ["bomCombo"] }, | |||
| }) | |||
| }) | |||
| @@ -7,6 +7,18 @@ import { BASE_API_URL } from "@/config/api"; | |||
| import { revalidateTag } from "next/cache"; | |||
| import { convertObjToURLSearchParams } from "@/app/utils/commonUtil"; | |||
| export interface SaveJo { | |||
| bomId: number; | |||
| planStart: string; | |||
| planEnd: string; | |||
| reqQty: number; | |||
| type: string; | |||
| } | |||
| export interface SaveJoResponse { | |||
| id: number; | |||
| } | |||
| export interface SearchJoResultRequest extends Pageable { | |||
| code: string; | |||
| name: string; | |||
| @@ -105,4 +117,13 @@ export const releaseJo = cache(async (data: ReleaseJoRequest) => { | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }) | |||
| }) | |||
| export const manualCreateJo = cache(async (data: SaveJo) => { | |||
| console.log(data) | |||
| return serverFetchJson<SaveJoResponse>(`${BASE_API_URL}/jo/manualCreate`, { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" } | |||
| }) | |||
| }) | |||
| @@ -22,9 +22,12 @@ export interface JoDetail { | |||
| code: string; | |||
| name: string; | |||
| reqQty: number; | |||
| outputQtyUom: string; | |||
| uom: string; | |||
| pickLines: JoDetailPickLine[]; | |||
| status: string; | |||
| planStart: number[]; | |||
| planEnd: number[]; | |||
| type: string; | |||
| } | |||
| export interface JoDetailPickLine { | |||
| @@ -68,6 +68,14 @@ export const dayjsToDateString = (date: Dayjs) => { | |||
| return date.format(OUTPUT_DATE_FORMAT); | |||
| }; | |||
| export const dayjsToInputDateString = (date: Dayjs) => { | |||
| return date.format(INPUT_DATE_FORMAT); | |||
| }; | |||
| export const dayjsToInputDateTimeString = (date: Dayjs) => { | |||
| return date.format(`${INPUT_DATE_FORMAT}T${OUTPUT_TIME_FORMAT}`); | |||
| }; | |||
| export const stockInLineStatusMap: { [status: string]: number } = { | |||
| draft: 0, | |||
| pending: 1, | |||
| @@ -67,7 +67,7 @@ const InfoCard: React.FC<Props> = ({ | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| { | |||
| ...register("outputQtyUom") | |||
| ...register("uom") | |||
| } | |||
| label={t("UoM")} | |||
| fullWidth | |||
| @@ -0,0 +1,243 @@ | |||
| import { BomCombo } from "@/app/api/bom"; | |||
| import { JoDetail } from "@/app/api/jo"; | |||
| import { SaveJo, manualCreateJo } from "@/app/api/jo/actions"; | |||
| import { OUTPUT_DATE_FORMAT, OUTPUT_TIME_FORMAT, dateStringToDayjs, dayjsToDateString, dayjsToInputDateString, dayjsToInputDateTimeString } from "@/app/utils/formatUtil"; | |||
| import { Check } from "@mui/icons-material"; | |||
| import { Autocomplete, Box, Button, Card, Grid, Modal, Stack, TextField, Typography } from "@mui/material"; | |||
| import { DatePicker, DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; | |||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import dayjs, { Dayjs } from "dayjs"; | |||
| import { isFinite } from "lodash"; | |||
| import React, { SetStateAction, SyntheticEvent, useCallback, useEffect } from "react"; | |||
| import { Controller, FormProvider, SubmitErrorHandler, SubmitHandler, useForm, useFormContext } from "react-hook-form"; | |||
| import { useTranslation } from "react-i18next"; | |||
| interface Props { | |||
| open: boolean; | |||
| bomCombo: BomCombo[]; | |||
| onClose: () => void; | |||
| onSearch: () => void; | |||
| } | |||
| const JoCreateFormModal: React.FC<Props> = ({ | |||
| open, | |||
| bomCombo, | |||
| onClose, | |||
| onSearch, | |||
| }) => { | |||
| const { t } = useTranslation("jo"); | |||
| const formProps = useForm<SaveJo>({ | |||
| mode: "onChange", | |||
| }); | |||
| const { reset, trigger, watch, control, register, formState: { errors } } = formProps | |||
| const onModalClose = useCallback(() => { | |||
| reset() | |||
| onClose() | |||
| }, []) | |||
| const handleAutoCompleteChange = useCallback((event: SyntheticEvent<Element, Event>, value: BomCombo, onChange: (...event: any[]) => void) => { | |||
| onChange(value.id) | |||
| }, []) | |||
| const handleDateTimePickerChange = useCallback((value: Dayjs | null, onChange: (...event: any[]) => void) => { | |||
| if (value != null) { | |||
| const updatedValue = dayjsToInputDateTimeString(value) | |||
| onChange(updatedValue) | |||
| } else { | |||
| onChange(value) | |||
| } | |||
| }, []) | |||
| const onSubmit = useCallback<SubmitHandler<SaveJo>>(async (data) => { | |||
| data.type = "manual" | |||
| const response = await manualCreateJo(data) | |||
| if (response) { | |||
| onSearch(); | |||
| onModalClose(); | |||
| } | |||
| }, []) | |||
| const onSubmitError = useCallback<SubmitErrorHandler<SaveJo>>((error) => { | |||
| console.log(error) | |||
| }, []) | |||
| const planStart = watch("planStart") | |||
| const planEnd = watch("planEnd") | |||
| useEffect(() => { | |||
| trigger(['planStart', 'planEnd']); | |||
| }, [trigger, planStart, planEnd]) | |||
| return ( | |||
| <Modal | |||
| open={open} | |||
| onClose={onModalClose} | |||
| > | |||
| <Card | |||
| style={{ | |||
| flex: 10, | |||
| marginBottom: "20px", | |||
| width: "90%", | |||
| // height: "80%", | |||
| position: "fixed", | |||
| top: "50%", | |||
| left: "50%", | |||
| transform: "translate(-50%, -50%)", | |||
| }} | |||
| > | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| "flex-direction": "column", | |||
| padding: "20px", | |||
| height: "100%", //'30rem', | |||
| width: "100%", | |||
| "& .actions": { | |||
| color: "text.secondary", | |||
| }, | |||
| "& .header": { | |||
| // border: 1, | |||
| // 'border-width': '1px', | |||
| // 'border-color': 'grey', | |||
| }, | |||
| "& .textPrimary": { | |||
| color: "text.primary", | |||
| }, | |||
| }} | |||
| > | |||
| <FormProvider {...formProps}> | |||
| <Stack | |||
| // spacing={2} | |||
| component="form" | |||
| onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} | |||
| > | |||
| <LocalizationProvider | |||
| dateAdapter={AdapterDayjs} | |||
| // TODO: Should maybe use a custom adapterLocale here to support YYYY-MM-DD | |||
| adapterLocale="zh-hk" | |||
| > | |||
| <Grid container spacing={2}> | |||
| <Grid item xs={12} sm={12} md={12}> | |||
| <Typography variant="h6">{t("Create Job Order")}</Typography> | |||
| </Grid> | |||
| <Grid item xs={12} sm={12} md={6}> | |||
| <Controller | |||
| control={control} | |||
| name="bomId" | |||
| rules={{ | |||
| required: "Bom required!", | |||
| validate: (value) => isFinite(value) | |||
| }} | |||
| render={({ field, fieldState: { error } }) => ( | |||
| <Autocomplete | |||
| disableClearable | |||
| options={bomCombo} | |||
| onChange={(event, value) => { | |||
| handleAutoCompleteChange(event, value, field.onChange) | |||
| }} | |||
| onBlur={field.onBlur} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| error={Boolean(error)} | |||
| variant="outlined" | |||
| label={t("Bom")} | |||
| /> | |||
| )} | |||
| /> | |||
| )} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12} sm={12} md={6}> | |||
| <TextField | |||
| {...register("reqQty", { | |||
| required: "Req. Qty. required!", | |||
| validate: (value) => value > 0 | |||
| })} | |||
| label={t("Req. Qty")} | |||
| fullWidth | |||
| error={Boolean(errors.reqQty)} | |||
| variant="outlined" | |||
| type="number" | |||
| /> | |||
| </Grid> | |||
| {/* <Grid item xs={12} sm={12} md={6}> | |||
| <Controller | |||
| control={control} | |||
| name="planStart" | |||
| rules={{ | |||
| required: "Plan start required!", | |||
| validate: { | |||
| isValid: (value) => dateStringToDayjs(value).isValid(), | |||
| isBeforePlanEnd: (value) => { | |||
| const planStartDayjs = dateStringToDayjs(value) | |||
| const planEndDayjs = dateStringToDayjs(planEnd) | |||
| return planStartDayjs.isBefore(planEndDayjs) || planStartDayjs.isSame(planEndDayjs) | |||
| } | |||
| } | |||
| }} | |||
| render={({ field, fieldState: { error } }) => ( | |||
| <DateTimePicker | |||
| label={t("Plan Start")} | |||
| format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} | |||
| onChange={(newValue: Dayjs | null) => { | |||
| handleDateTimePickerChange(newValue, field.onChange) | |||
| }} | |||
| slotProps={{ textField: { fullWidth: true, error: Boolean(error) } }} | |||
| /> | |||
| )} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12} sm={12} md={6}> | |||
| <Controller | |||
| control={control} | |||
| name="planEnd" | |||
| rules={{ | |||
| required: "Plan end required!", | |||
| validate: { | |||
| isValid: (value) => dateStringToDayjs(value).isValid(), | |||
| isBeforePlanEnd: (value) => { | |||
| const planStartDayjs = dateStringToDayjs(planStart) | |||
| const planEndDayjs = dateStringToDayjs(value) | |||
| return planEndDayjs.isAfter(planStartDayjs) || planEndDayjs.isSame(planStartDayjs) | |||
| } | |||
| } | |||
| }} | |||
| render={({ field, fieldState: { error } }) => ( | |||
| <DateTimePicker | |||
| label={t("Plan End")} | |||
| format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} | |||
| onChange={(newValue: Dayjs | null) => { | |||
| handleDateTimePickerChange(newValue, field.onChange) | |||
| }} | |||
| slotProps={{ textField: { fullWidth: true } }} | |||
| /> | |||
| )} | |||
| /> | |||
| </Grid> */} | |||
| </Grid> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="flex-end" | |||
| spacing={2} | |||
| sx={{ mt: 2 }} | |||
| > | |||
| <Button | |||
| name="submit" | |||
| variant="contained" | |||
| startIcon={<Check />} | |||
| type="submit" | |||
| > | |||
| {t("Create")} | |||
| </Button> | |||
| </Stack> | |||
| </LocalizationProvider> | |||
| </Stack> | |||
| </FormProvider> | |||
| </Box> | |||
| </Card> | |||
| </Modal> | |||
| ) | |||
| } | |||
| export default JoCreateFormModal; | |||
| @@ -6,19 +6,26 @@ import { Criterion } from "../SearchBox"; | |||
| import SearchResults, { Column, defaultPagingController } from "../SearchResults/SearchResults"; | |||
| import { EditNote } from "@mui/icons-material"; | |||
| import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil"; | |||
| import { uniqBy, upperFirst } from "lodash"; | |||
| import { orderBy, uniqBy, upperFirst } from "lodash"; | |||
| import SearchBox from "../SearchBox/SearchBox"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form"; | |||
| import { JoDetail } from "@/app/api/jo"; | |||
| import { Button, Stack } from "@mui/material"; | |||
| import { BomCombo } from "@/app/api/bom"; | |||
| import JoCreateFormModal from "./JoCreateFormModal"; | |||
| import AddIcon from '@mui/icons-material/Add'; | |||
| interface Props { | |||
| defaultInputs: SearchJoResultRequest | |||
| defaultInputs: SearchJoResultRequest, | |||
| bomCombo: BomCombo[] | |||
| } | |||
| type SearchQuery = Partial<Omit<SearchJoResult, "id">>; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const JoSearch: React.FC<Props> = ({ defaultInputs }) => { | |||
| const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo }) => { | |||
| const { t } = useTranslation("jo"); | |||
| const router = useRouter() | |||
| const [filteredJos, setFilteredJos] = useState<SearchJoResult[]>([]); | |||
| @@ -27,6 +34,7 @@ const JoSearch: React.FC<Props> = ({ defaultInputs }) => { | |||
| defaultPagingController | |||
| ) | |||
| const [totalCount, setTotalCount] = useState(0) | |||
| const [isCreateJoModalOpen, setIsCreateJoModalOpen] = useState(false) | |||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo(() => [ | |||
| { label: t("Code"), paramName: "code", type: "text" }, | |||
| @@ -94,19 +102,22 @@ const JoSearch: React.FC<Props> = ({ defaultInputs }) => { | |||
| switch (actionType) { | |||
| case "reset": | |||
| case "search": | |||
| setFilteredJos(() => response.records); | |||
| setFilteredJos(() => orderBy(response.records, ["id"], ["desc"])); | |||
| break; | |||
| case "paging": | |||
| setFilteredJos((fs) => | |||
| uniqBy([...fs, ...response.records], "id"), | |||
| orderBy(uniqBy([...fs, ...response.records], "id"), ["id"], ["desc"]), | |||
| ); | |||
| break; | |||
| } | |||
| } | |||
| }, [pagingController, setPagingController]) | |||
| useEffect(() => { | |||
| const searchDataByPage = useCallback(() => { | |||
| refetchData(inputs, "paging"); | |||
| }, [inputs]) | |||
| useEffect(() => { | |||
| searchDataByPage(); | |||
| }, [pagingController]); | |||
| const onDetailClick = useCallback((record: SearchJoResult) => { | |||
| @@ -125,7 +136,31 @@ const JoSearch: React.FC<Props> = ({ defaultInputs }) => { | |||
| refetchData(defaultInputs, "paging"); | |||
| }, []) | |||
| // Manual Create Jo Related | |||
| const onOpenCreateJoModal = useCallback(() => { | |||
| setIsCreateJoModalOpen(() => true) | |||
| }, []) | |||
| const onCloseCreateJoModal = useCallback(() => { | |||
| setIsCreateJoModalOpen(() => false) | |||
| }, []) | |||
| return <> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="flex-end" | |||
| spacing={2} | |||
| sx={{ mt: 2 }} | |||
| > | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<AddIcon />} | |||
| onClick={onOpenCreateJoModal} | |||
| > | |||
| {t("Create Job Order")} | |||
| </Button> | |||
| </Stack> | |||
| <SearchBox | |||
| criteria={searchCriteria} | |||
| onSearch={onSearch} | |||
| @@ -139,6 +174,12 @@ const JoSearch: React.FC<Props> = ({ defaultInputs }) => { | |||
| totalCount={totalCount} | |||
| // isAutoPaging={false} | |||
| /> | |||
| <JoCreateFormModal | |||
| open={isCreateJoModalOpen} | |||
| bomCombo={bomCombo} | |||
| onClose={onCloseCreateJoModal} | |||
| onSearch={searchDataByPage} | |||
| /> | |||
| </> | |||
| } | |||
| @@ -2,6 +2,7 @@ import React from "react"; | |||
| import GeneralLoading from "../General/GeneralLoading"; | |||
| import JoSearch from "./JoSearch"; | |||
| import { SearchJoResultRequest } from "@/app/api/jo/actions"; | |||
| import { fetchBomCombo } from "@/app/api/bom"; | |||
| interface SubComponents { | |||
| Loading: typeof GeneralLoading; | |||
| @@ -12,8 +13,14 @@ const JoSearchWrapper: React.FC & SubComponents = async () => { | |||
| code: "", | |||
| name: "", | |||
| } | |||
| const [ | |||
| bomCombo | |||
| ] = await Promise.all([ | |||
| fetchBomCombo() | |||
| ]) | |||
| return <JoSearch defaultInputs={defaultInputs}/> | |||
| return <JoSearch defaultInputs={defaultInputs} bomCombo={bomCombo}/> | |||
| } | |||
| JoSearchWrapper.Loading = GeneralLoading; | |||