@@ -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; | ||||