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