| @@ -1,3 +1,4 @@ | |||||
| import { preloadBomCombo } from "@/app/api/bom"; | |||||
| import JoSearch from "@/components/JoSearch"; | import JoSearch from "@/components/JoSearch"; | ||||
| import { I18nProvider, getServerI18n } from "@/i18n"; | import { I18nProvider, getServerI18n } from "@/i18n"; | ||||
| import { Stack, Typography } from "@mui/material"; | import { Stack, Typography } from "@mui/material"; | ||||
| @@ -11,6 +12,8 @@ export const metadata: Metadata = { | |||||
| const jo: React.FC = async () => { | const jo: React.FC = async () => { | ||||
| const { t } = await getServerI18n("jo"); | const { t } = await getServerI18n("jo"); | ||||
| preloadBomCombo() | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Stack | <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 { revalidateTag } from "next/cache"; | ||||
| import { convertObjToURLSearchParams } from "@/app/utils/commonUtil"; | 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 { | export interface SearchJoResultRequest extends Pageable { | ||||
| code: string; | code: string; | ||||
| name: string; | name: string; | ||||
| @@ -105,4 +117,13 @@ export const releaseJo = cache(async (data: ReleaseJoRequest) => { | |||||
| body: JSON.stringify(data), | body: JSON.stringify(data), | ||||
| headers: { "Content-Type": "application/json" }, | 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; | code: string; | ||||
| name: string; | name: string; | ||||
| reqQty: number; | reqQty: number; | ||||
| outputQtyUom: string; | |||||
| uom: string; | |||||
| pickLines: JoDetailPickLine[]; | pickLines: JoDetailPickLine[]; | ||||
| status: string; | status: string; | ||||
| planStart: number[]; | |||||
| planEnd: number[]; | |||||
| type: string; | |||||
| } | } | ||||
| export interface JoDetailPickLine { | export interface JoDetailPickLine { | ||||
| @@ -68,6 +68,14 @@ export const dayjsToDateString = (date: Dayjs) => { | |||||
| return date.format(OUTPUT_DATE_FORMAT); | 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 } = { | export const stockInLineStatusMap: { [status: string]: number } = { | ||||
| draft: 0, | draft: 0, | ||||
| pending: 1, | pending: 1, | ||||
| @@ -67,7 +67,7 @@ const InfoCard: React.FC<Props> = ({ | |||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <TextField | <TextField | ||||
| { | { | ||||
| ...register("outputQtyUom") | |||||
| ...register("uom") | |||||
| } | } | ||||
| label={t("UoM")} | label={t("UoM")} | ||||
| fullWidth | 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 SearchResults, { Column, defaultPagingController } from "../SearchResults/SearchResults"; | ||||
| import { EditNote } from "@mui/icons-material"; | import { EditNote } from "@mui/icons-material"; | ||||
| import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil"; | import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil"; | ||||
| import { uniqBy, upperFirst } from "lodash"; | |||||
| import { orderBy, uniqBy, upperFirst } from "lodash"; | |||||
| import SearchBox from "../SearchBox/SearchBox"; | import SearchBox from "../SearchBox/SearchBox"; | ||||
| import { useRouter } from "next/navigation"; | 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 { | interface Props { | ||||
| defaultInputs: SearchJoResultRequest | |||||
| defaultInputs: SearchJoResultRequest, | |||||
| bomCombo: BomCombo[] | |||||
| } | } | ||||
| type SearchQuery = Partial<Omit<SearchJoResult, "id">>; | type SearchQuery = Partial<Omit<SearchJoResult, "id">>; | ||||
| type SearchParamNames = keyof SearchQuery; | type SearchParamNames = keyof SearchQuery; | ||||
| const JoSearch: React.FC<Props> = ({ defaultInputs }) => { | |||||
| const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo }) => { | |||||
| const { t } = useTranslation("jo"); | const { t } = useTranslation("jo"); | ||||
| const router = useRouter() | const router = useRouter() | ||||
| const [filteredJos, setFilteredJos] = useState<SearchJoResult[]>([]); | const [filteredJos, setFilteredJos] = useState<SearchJoResult[]>([]); | ||||
| @@ -27,6 +34,7 @@ const JoSearch: React.FC<Props> = ({ defaultInputs }) => { | |||||
| defaultPagingController | defaultPagingController | ||||
| ) | ) | ||||
| const [totalCount, setTotalCount] = useState(0) | const [totalCount, setTotalCount] = useState(0) | ||||
| const [isCreateJoModalOpen, setIsCreateJoModalOpen] = useState(false) | |||||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo(() => [ | const searchCriteria: Criterion<SearchParamNames>[] = useMemo(() => [ | ||||
| { label: t("Code"), paramName: "code", type: "text" }, | { label: t("Code"), paramName: "code", type: "text" }, | ||||
| @@ -94,19 +102,22 @@ const JoSearch: React.FC<Props> = ({ defaultInputs }) => { | |||||
| switch (actionType) { | switch (actionType) { | ||||
| case "reset": | case "reset": | ||||
| case "search": | case "search": | ||||
| setFilteredJos(() => response.records); | |||||
| setFilteredJos(() => orderBy(response.records, ["id"], ["desc"])); | |||||
| break; | break; | ||||
| case "paging": | case "paging": | ||||
| setFilteredJos((fs) => | setFilteredJos((fs) => | ||||
| uniqBy([...fs, ...response.records], "id"), | |||||
| orderBy(uniqBy([...fs, ...response.records], "id"), ["id"], ["desc"]), | |||||
| ); | ); | ||||
| break; | break; | ||||
| } | } | ||||
| } | } | ||||
| }, [pagingController, setPagingController]) | }, [pagingController, setPagingController]) | ||||
| useEffect(() => { | |||||
| const searchDataByPage = useCallback(() => { | |||||
| refetchData(inputs, "paging"); | refetchData(inputs, "paging"); | ||||
| }, [inputs]) | |||||
| useEffect(() => { | |||||
| searchDataByPage(); | |||||
| }, [pagingController]); | }, [pagingController]); | ||||
| const onDetailClick = useCallback((record: SearchJoResult) => { | const onDetailClick = useCallback((record: SearchJoResult) => { | ||||
| @@ -125,7 +136,31 @@ const JoSearch: React.FC<Props> = ({ defaultInputs }) => { | |||||
| refetchData(defaultInputs, "paging"); | refetchData(defaultInputs, "paging"); | ||||
| }, []) | }, []) | ||||
| // Manual Create Jo Related | |||||
| const onOpenCreateJoModal = useCallback(() => { | |||||
| setIsCreateJoModalOpen(() => true) | |||||
| }, []) | |||||
| const onCloseCreateJoModal = useCallback(() => { | |||||
| setIsCreateJoModalOpen(() => false) | |||||
| }, []) | |||||
| return <> | 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 | <SearchBox | ||||
| criteria={searchCriteria} | criteria={searchCriteria} | ||||
| onSearch={onSearch} | onSearch={onSearch} | ||||
| @@ -139,6 +174,12 @@ const JoSearch: React.FC<Props> = ({ defaultInputs }) => { | |||||
| totalCount={totalCount} | totalCount={totalCount} | ||||
| // isAutoPaging={false} | // 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 GeneralLoading from "../General/GeneralLoading"; | ||||
| import JoSearch from "./JoSearch"; | import JoSearch from "./JoSearch"; | ||||
| import { SearchJoResultRequest } from "@/app/api/jo/actions"; | import { SearchJoResultRequest } from "@/app/api/jo/actions"; | ||||
| import { fetchBomCombo } from "@/app/api/bom"; | |||||
| interface SubComponents { | interface SubComponents { | ||||
| Loading: typeof GeneralLoading; | Loading: typeof GeneralLoading; | ||||
| @@ -12,8 +13,14 @@ const JoSearchWrapper: React.FC & SubComponents = async () => { | |||||
| code: "", | code: "", | ||||
| name: "", | name: "", | ||||
| } | } | ||||
| const [ | |||||
| bomCombo | |||||
| ] = await Promise.all([ | |||||
| fetchBomCombo() | |||||
| ]) | |||||
| return <JoSearch defaultInputs={defaultInputs}/> | |||||
| return <JoSearch defaultInputs={defaultInputs} bomCombo={bomCombo}/> | |||||
| } | } | ||||
| JoSearchWrapper.Loading = GeneralLoading; | JoSearchWrapper.Loading = GeneralLoading; | ||||