| @@ -26,7 +26,7 @@ const jo: React.FC = async () => { | |||
| {t("Job Order")} | |||
| </Typography> | |||
| </Stack> | |||
| <I18nProvider namespaces={["jo", "common"]}> | |||
| <I18nProvider namespaces={["jo", "common", "purchaseOrder", "dashboard"]}> {/* TODO: Improve */} | |||
| <Suspense fallback={<JoSearch.Loading />}> | |||
| <JoSearch /> | |||
| </Suspense> | |||
| @@ -2,7 +2,7 @@ | |||
| import { cache } from 'react'; | |||
| import { Pageable, serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; | |||
| import { JoStatus, Machine, Operator } from "."; | |||
| import { JobOrder, JoStatus, Machine, Operator } from "."; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { revalidateTag } from "next/cache"; | |||
| import { convertObjToURLSearchParams } from "@/app/utils/commonUtil"; | |||
| @@ -21,14 +21,15 @@ export interface SaveJoResponse { | |||
| export interface SearchJoResultRequest extends Pageable { | |||
| code: string; | |||
| name: string; | |||
| itemName?: string; | |||
| } | |||
| export interface SearchJoResultResponse { | |||
| records: SearchJoResult[]; | |||
| records: JobOrder[]; | |||
| total: number; | |||
| } | |||
| // DEPRECIATED | |||
| export interface SearchJoResult { | |||
| id: number; | |||
| code: string; | |||
| @@ -39,6 +40,11 @@ export interface SearchJoResult { | |||
| status: JoStatus; | |||
| } | |||
| export interface UpdateJoRequest { | |||
| id: number; | |||
| status: string; | |||
| } | |||
| // For Jo Button Actions | |||
| export interface CommonActionJoRequest { | |||
| id: number; | |||
| @@ -79,6 +85,7 @@ export interface JobOrderDetail { | |||
| pickLines: any[]; | |||
| status: string; | |||
| } | |||
| export interface UnassignedJobOrderPickOrder { | |||
| pickOrderId: number; | |||
| pickOrderCode: string; | |||
| @@ -223,6 +230,7 @@ export const fetchCompletedJobOrderPickOrderRecords = cache(async (userId: numbe | |||
| }, | |||
| ); | |||
| }); | |||
| export const fetchJobOrderDetailByCode = cache(async (code: string) => { | |||
| return serverFetchJson<JobOrderDetail>( | |||
| `${BASE_API_URL}/jo/detailByCode/${code}`, | |||
| @@ -275,6 +283,15 @@ export const fetchJos = cache(async (data?: SearchJoResultRequest) => { | |||
| return response | |||
| }) | |||
| export const updateJo = cache(async (data: UpdateJoRequest) => { | |||
| return serverFetchJson<SaveJoResponse>(`${BASE_API_URL}/jo/update`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }) | |||
| }) | |||
| export const releaseJo = cache(async (data: CommonActionJoRequest) => { | |||
| const response = serverFetchJson<CommonActionJoResponse>(`${BASE_API_URL}/jo/release`, | |||
| { | |||
| @@ -3,6 +3,8 @@ | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { cache } from "react"; | |||
| import { Item } from "../settings/item"; | |||
| import { Uom } from "../settings/uom"; | |||
| export type JoStatus = "planning" | "pending" | "processing" | "packaging" | "storing" | "completed" | |||
| export type JoBomMaterialStatus = "pending" | "completed" | |||
| @@ -13,6 +15,24 @@ export interface Operator { | |||
| username: string; | |||
| } | |||
| export interface JobOrder { | |||
| id: number; | |||
| code: string; | |||
| reqQty: number; | |||
| item: Item; | |||
| itemName: string; | |||
| // uom: Uom; | |||
| pickLines?: JoDetailPickLine[]; | |||
| status: JoStatus; | |||
| planStart?: number[]; | |||
| planEnd?: number[]; | |||
| type: string; | |||
| // TODO pack below into StockInLineInfo | |||
| stockInLineId?: number; | |||
| stockInLineStatus?: string; | |||
| silHandlerId?: number; | |||
| } | |||
| export interface Machine { | |||
| id: number; | |||
| name: string; | |||
| @@ -24,14 +44,17 @@ export interface JoDetail { | |||
| id: number; | |||
| code: string; | |||
| itemCode: string; | |||
| itemName?: string; | |||
| name: string; | |||
| reqQty: number; | |||
| // itemId: number; | |||
| uom: string; | |||
| pickLines: JoDetailPickLine[]; | |||
| status: JoStatus; | |||
| planStart: number[]; | |||
| planEnd: number[]; | |||
| type: string; | |||
| // item?: Item; | |||
| } | |||
| export interface JoDetailPickLine { | |||
| @@ -52,7 +75,7 @@ export interface JoDetailPickedLotNo { | |||
| } | |||
| export const fetchJoDetail = cache(async (id: number) => { | |||
| return serverFetchJson<JoDetail>(`${BASE_API_URL}/jo/detail/${id}`, | |||
| return serverFetchJson<JobOrder>(`${BASE_API_URL}/jo/detail/${id}`, | |||
| { | |||
| method: "GET", | |||
| headers: { "Content-Type": "application/json"}, | |||
| @@ -5,6 +5,7 @@ import "server-only"; | |||
| import { serverFetchJson } from "../../../utils/fetchUtil"; | |||
| import { BASE_API_URL } from "../../../../config/api"; | |||
| import { QcCategoryResult } from "../qcCategory"; | |||
| import { Uom } from "../uom"; | |||
| // import { TypeInputs, UomInputs, WeightUnitInputs } from "./actions"; | |||
| @@ -24,6 +25,19 @@ export type ItemsResultResponse = { | |||
| total: number; | |||
| }; | |||
| export type Item = { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| remarks?: string; | |||
| type?: string; | |||
| shelfLife?: number; | |||
| countryOfOrigin?: string; | |||
| qcCategory?: QcCategoryResult; | |||
| uom: Uom; | |||
| } | |||
| export type ItemsResult = { | |||
| id: string | number; | |||
| code: string; | |||
| @@ -30,6 +30,7 @@ export interface StockInLineEntry { | |||
| acceptedQty: number; | |||
| purchaseOrderId?: number; | |||
| purchaseOrderLineId?: number; | |||
| jobOrderId?: number; | |||
| status?: string; | |||
| expiryDate?: string; | |||
| productLotNo?: string; | |||
| @@ -101,6 +101,7 @@ export interface StockInLine { | |||
| stockInId?: number; | |||
| purchaseOrderId?: number; | |||
| purchaseOrderLineId: number; | |||
| jobOrderId: number; | |||
| itemId: number; | |||
| itemNo: string; | |||
| itemName: string; | |||
| @@ -40,7 +40,11 @@ const EscalationLogTable: React.FC<Props> = ({ | |||
| const [escalationLogs, setEscalationLogs] = useState<EscalationResult[]>([]); | |||
| const useCardFilter = useContext(CardFilterContext); | |||
| const showCompleted = useCardFilter.filter; | |||
| const showCompleted = useMemo(() => { | |||
| if (type === "dashboard") { | |||
| return useCardFilter.filter; | |||
| } else { return true} | |||
| }, [useCardFilter.filter]); | |||
| useEffect(() => { | |||
| if (showCompleted) { | |||
| @@ -11,6 +11,7 @@ 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"; | |||
| import { msg } from "../Swal/CustomAlerts"; | |||
| interface Props { | |||
| open: boolean; | |||
| @@ -54,6 +55,7 @@ const JoCreateFormModal: React.FC<Props> = ({ | |||
| const response = await manualCreateJo(data) | |||
| if (response) { | |||
| onSearch(); | |||
| msg(t("update success")); | |||
| onModalClose(); | |||
| } | |||
| }, []) | |||
| @@ -1,34 +1,41 @@ | |||
| "use client" | |||
| import { SearchJoResult, SearchJoResultRequest, fetchJos } from "@/app/api/jo/actions"; | |||
| import { SearchJoResultRequest, fetchJos, updateJo } from "@/app/api/jo/actions"; | |||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| 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 { arrayToDateString, integerFormatter } from "@/app/utils/formatUtil"; | |||
| 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 { StockInLineInput } from "@/app/api/stockIn"; | |||
| import { JobOrder, JoStatus } 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'; | |||
| import QcStockInModal from "../PoDetail/QcStockInModal"; | |||
| import { useSession } from "next-auth/react"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import { createStockInLine } from "@/app/api/stockIn/actions"; | |||
| import { msg } from "../Swal/CustomAlerts"; | |||
| import dayjs from "dayjs"; | |||
| interface Props { | |||
| defaultInputs: SearchJoResultRequest, | |||
| bomCombo: BomCombo[] | |||
| } | |||
| type SearchQuery = Partial<Omit<SearchJoResult, "id">>; | |||
| type SearchQuery = Partial<Omit<JobOrder, "id">>; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo }) => { | |||
| const { t } = useTranslation("jo"); | |||
| const router = useRouter() | |||
| const [filteredJos, setFilteredJos] = useState<SearchJoResult[]>([]); | |||
| const [filteredJos, setFilteredJos] = useState<JobOrder[]>([]); | |||
| const [inputs, setInputs] = useState(defaultInputs); | |||
| const [pagingController, setPagingController] = useState( | |||
| defaultPagingController | |||
| @@ -38,28 +45,28 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo }) => { | |||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo(() => [ | |||
| { label: t("Code"), paramName: "code", type: "text" }, | |||
| { label: t("Item Name"), paramName: "name", type: "text" }, | |||
| { label: t("Item Name"), paramName: "itemName", type: "text" }, | |||
| ], [t]) | |||
| const columns = useMemo<Column<SearchJoResult>[]>( | |||
| const columns = useMemo<Column<JobOrder>[]>( | |||
| () => [ | |||
| { | |||
| name: "id", | |||
| label: t("Details"), | |||
| onClick: (record) => onDetailClick(record), | |||
| buttonIcon: <EditNote />, | |||
| }, | |||
| { | |||
| name: "code", | |||
| label: t("Code") | |||
| }, | |||
| { | |||
| name: "itemCode", | |||
| label: t("Item Code") | |||
| name: "item", | |||
| label: t("Item Code"), | |||
| renderCell: (row) => { | |||
| return t(row.item.code) | |||
| } | |||
| }, | |||
| { | |||
| name: "name", | |||
| name: "itemName", | |||
| label: t("Item Name"), | |||
| renderCell: (row) => { | |||
| return t(row.item.name) | |||
| } | |||
| }, | |||
| { | |||
| name: "reqQty", | |||
| @@ -71,12 +78,12 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo }) => { | |||
| } | |||
| }, | |||
| { | |||
| name: "uom", | |||
| name: "item", | |||
| label: t("UoM"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| renderCell: (row) => { | |||
| return t(row.uom) | |||
| return t(row.item.uom.udfudesc) | |||
| } | |||
| }, | |||
| { | |||
| @@ -85,17 +92,69 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo }) => { | |||
| renderCell: (row) => { | |||
| return t(upperFirst(row.status)) | |||
| } | |||
| } | |||
| }, | |||
| { | |||
| // TODO put it inside Action Buttons | |||
| name: "id", | |||
| label: t("Actions"), | |||
| // onClick: (record) => onDetailClick(record), | |||
| // buttonIcon: <EditNote />, | |||
| renderCell: (row) => { | |||
| const btnSx = getButtonSx(row); | |||
| return ( | |||
| <Button | |||
| id="emailSupplier" | |||
| type="button" | |||
| variant="contained" | |||
| color="primary" | |||
| sx={{ width: "150px", backgroundColor: btnSx.color }} | |||
| // disabled={params.row.status != "rejected" && params.row.status != "partially_completed"} | |||
| onClick={() => onDetailClick(row)} | |||
| >{btnSx.label}</Button> | |||
| ) | |||
| } | |||
| }, | |||
| ], [] | |||
| ) | |||
| const handleUpdate = useCallback(async (jo: JobOrder) => { | |||
| console.log(jo); | |||
| try { | |||
| // setIsUploading(true) | |||
| if (jo.id) { | |||
| const response = await updateJo({ id: jo.id, status: "storing" }); | |||
| console.log(`%c Updated JO:`, "color:lime", response); | |||
| const postData = { | |||
| itemId: jo?.item?.id!!, | |||
| acceptedQty: jo?.reqQty ?? 1, | |||
| productLotNo: jo?.code, | |||
| productionDate: arrayToDateString(dayjs(), "input"), | |||
| jobOrderId: jo?.id, | |||
| // acceptedQty: secondReceiveQty || 0, | |||
| // acceptedQty: row.acceptedQty, | |||
| }; | |||
| const res = await createStockInLine(postData); | |||
| console.log(`%c Created Stock In Line`, "color:lime", res); | |||
| msg(t("update success")); | |||
| refetchData(defaultInputs, "search"); | |||
| } | |||
| } catch (e) { | |||
| // backend error | |||
| // setServerError(t("An error has occurred. Please try again later.")); | |||
| console.log(e); | |||
| } finally { | |||
| // setIsUploading(false) | |||
| } | |||
| }, []) | |||
| const refetchData = useCallback(async ( | |||
| query: Record<SearchParamNames, string> | SearchJoResultRequest, | |||
| actionType: "reset" | "search" | "paging", | |||
| ) => { | |||
| const params: SearchJoResultRequest = { | |||
| code: query.code, | |||
| name: query.name, | |||
| itemName: query.itemName, | |||
| pageNum: pagingController.pageNum - 1, | |||
| pageSize: pagingController.pageSize, | |||
| } | |||
| @@ -124,14 +183,69 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo }) => { | |||
| searchDataByPage(); | |||
| }, [pagingController]); | |||
| const onDetailClick = useCallback((record: SearchJoResult) => { | |||
| router.push(`/jo/edit?id=${record.id}`) | |||
| const getButtonSx = (jo : JobOrder) => { // TODO put it in ActionButtons.ts | |||
| const joStatus = jo.status?.toLowerCase(); | |||
| const silStatus = jo.stockInLineStatus?.toLowerCase(); | |||
| let btnSx = {label:"", color:""}; | |||
| switch (joStatus) { | |||
| case "planning": btnSx = {label: t("release jo"), color:"primary.main"}; break; | |||
| case "pending": btnSx = {label: t("scan picked material"), color:"error.main"}; break; | |||
| case "processing": btnSx = {label: t("complete jo"), color:"warning.main"}; break; | |||
| // case "packaging": | |||
| // case "storing": btnSx = {label: t("view putaway"), color:"secondary.main"}; break; | |||
| case "storing": | |||
| switch (silStatus) { | |||
| case "pending": btnSx = {label: t("process epqc"), color:"primary.main"}; break; | |||
| case "received": btnSx = {label: t("view putaway"), color:"secondary.main"}; break; | |||
| case "escalated": | |||
| if (sessionToken?.id == jo.silHandlerId) { | |||
| btnSx = {label: t("escalation processing"), color:"warning.main"}; | |||
| break; | |||
| } | |||
| default: btnSx = {label: t("view stockin"), color:"info.main"}; | |||
| } | |||
| break; | |||
| case "completed": btnSx = {label: t("view stockin"), color:"info.main"}; break; | |||
| default: btnSx = {label: t("scan picked material"), color:"success.main"}; | |||
| } | |||
| return btnSx | |||
| }; | |||
| const { data: session } = useSession(); | |||
| const sessionToken = session as SessionWithTokens | null; | |||
| const [openModal, setOpenModal] = useState<boolean>(false); | |||
| const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | |||
| const onDetailClick = useCallback((record: JobOrder) => { | |||
| if (record.status == "processing") { | |||
| handleUpdate(record) | |||
| } else if (record.status == "storing" || record.status == "completed") { | |||
| if (record.stockInLineId != null) { | |||
| const data = { | |||
| id: record.stockInLineId, | |||
| } | |||
| setModalInfo(data); | |||
| setOpenModal(true); | |||
| } else { alert('Invalid Stock In Line Id'); } | |||
| } else { | |||
| router.push(`/jo/edit?id=${record.id}`) | |||
| } | |||
| }, []) | |||
| const closeNewModal = useCallback(() => { | |||
| // const response = updateJo({ id: 1, status: "storing" }); | |||
| setOpenModal(false); // Close the modal first | |||
| // setTimeout(() => { | |||
| // }, 300); // Add a delay to avoid immediate re-trigger of useEffect | |||
| refetchData(defaultInputs, "search"); | |||
| }, []); | |||
| const onSearch = useCallback((query: Record<SearchParamNames, string>) => { | |||
| setInputs(() => ({ | |||
| code: query.code, | |||
| name: query.name | |||
| itemName: query.itemName | |||
| })) | |||
| refetchData(query, "search"); | |||
| }, []) | |||
| @@ -170,7 +284,7 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo }) => { | |||
| onSearch={onSearch} | |||
| onReset={onReset} | |||
| /> | |||
| <SearchResults<SearchJoResult> | |||
| <SearchResults<JobOrder> | |||
| items={filteredJos} | |||
| columns={columns} | |||
| setPagingController={setPagingController} | |||
| @@ -184,6 +298,15 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo }) => { | |||
| onClose={onCloseCreateJoModal} | |||
| onSearch={searchDataByPage} | |||
| /> | |||
| <QcStockInModal | |||
| session={sessionToken} | |||
| open={openModal} | |||
| onClose={closeNewModal} | |||
| inputDetail={modalInfo} | |||
| printerCombo={[]} | |||
| // skipQc={true} | |||
| /> | |||
| </> | |||
| } | |||
| @@ -392,9 +392,9 @@ const PutAwayForm: React.FC<Props> = ({ itemDetail, warehouse=[], disabled, setR | |||
| </Grid> | |||
| <Grid item xs={3}> | |||
| <TextField | |||
| label={t("acceptedQty")} | |||
| label={t("acceptedPutawayQty")} // TODO: fix it back to acceptedQty after db is fixed | |||
| fullWidth | |||
| value={itemDetail.acceptedQty} | |||
| value={itemDetail.demandQty ?? itemDetail.acceptedQty} | |||
| disabled | |||
| /> | |||
| </Grid> | |||
| @@ -104,6 +104,11 @@ const QcComponent: React.FC<Props> = ({ itemDetail, disabled = false }) => { | |||
| const [qcHistory, setQcHistory] = useState<PurchaseQcResult[]>([]); | |||
| const [qcResult, setQcResult] = useState<PurchaseQcResult[]>([]); | |||
| const detailMode = useMemo(() => { | |||
| const isDetailMode = itemDetail.status == "escalated" || isNaN(itemDetail.jobOrderId); | |||
| return isDetailMode; | |||
| }, [itemDetail]); | |||
| // const [qcAccept, setQcAccept] = useState(true); | |||
| // const [qcItems, setQcItems] = useState(dummyQCData) | |||
| @@ -610,7 +615,7 @@ const QcComponent: React.FC<Props> = ({ itemDetail, disabled = false }) => { | |||
| <FormControlLabel disabled={disabled} | |||
| value="1" control={<Radio />} label="接受來貨" /> | |||
| {(itemDetail.status == "escalated"|| (disabled && accQty != itemDetail.acceptedQty && qcDecision == 1)) && ( //TODO Improve | |||
| {(detailMode || (disabled && accQty != itemDetail.acceptedQty && qcDecision == 1)) && ( //TODO Improve | |||
| <Box sx={{mr:2}}> | |||
| <TextField | |||
| // type="number" | |||
| @@ -661,7 +666,7 @@ const QcComponent: React.FC<Props> = ({ itemDetail, disabled = false }) => { | |||
| label={t("rejectQty")} | |||
| sx={{ width: '150px' }} | |||
| value={ | |||
| (!Boolean(errors.acceptQty)) ? | |||
| (!Boolean(errors.acceptQty) && qcDecision !== undefined) ? | |||
| (qcDecision == 1 ? itemDetail.acceptedQty - accQty : itemDetail.acceptedQty) | |||
| : "" | |||
| } | |||
| @@ -673,7 +678,7 @@ const QcComponent: React.FC<Props> = ({ itemDetail, disabled = false }) => { | |||
| <FormControlLabel disabled={disabled} | |||
| value="2" control={<Radio />} | |||
| sx={{"& .Mui-checked": {color: "red"}}} | |||
| label= {itemDetail.status == "escalated" ? "全部拒絕並退貨" : "不接受並退貨"} /> | |||
| label= {detailMode ? "全部拒絕並退貨" : "不接受並退貨"} /> | |||
| {(itemDetail.status == "pending" || disabled) && (<> | |||
| <FormControlLabel disabled={disabled} | |||
| @@ -42,6 +42,7 @@ import { fetchStockInLineInfo } from "@/app/api/stockIn/actions"; | |||
| import { fetchQcResult } from "@/app/api/qc/actions"; | |||
| import { fetchEscalationLogsByStockInLines } from "@/app/api/escalation/actions"; | |||
| import LoadingComponent from "../General/LoadingComponent"; | |||
| import FgStockInForm from "../StockIn/FgStockInForm"; | |||
| const style = { | |||
| @@ -64,6 +65,7 @@ interface CommonProps extends Omit<ModalProps, "children"> { | |||
| warehouse?: any[]; | |||
| printerCombo: PrinterCombo[]; | |||
| onClose: () => void; | |||
| skipQc?: Boolean; | |||
| } | |||
| interface Props extends CommonProps { | |||
| // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | |||
| @@ -76,6 +78,7 @@ const PoQcStockInModalVer2: React.FC<Props> = ({ | |||
| session, | |||
| warehouse, | |||
| printerCombo, | |||
| skipQc = false, | |||
| }) => { | |||
| const { | |||
| t, | |||
| @@ -84,6 +87,7 @@ const PoQcStockInModalVer2: React.FC<Props> = ({ | |||
| const [stockInLineInfo, setStockInLineInfo] = useState<StockInLine>(); | |||
| const [isLoading, setIsLoading] = useState<Boolean>(false); | |||
| // const [skipQc, setSkipQc] = useState<Boolean>(false); | |||
| // const [viewOnly, setViewOnly] = useState(false); | |||
| // Select Printer | |||
| @@ -110,6 +114,8 @@ const PoQcStockInModalVer2: React.FC<Props> = ({ | |||
| } else throw("Result is undefined"); | |||
| } catch (e) { | |||
| console.log("%c Error when fetching Stock In Line: ", "color:red", e); | |||
| alert("Something went wrong, please retry"); | |||
| closeHandler({}, "backdropClick"); | |||
| } | |||
| },[fetchStockInLineInfo, inputDetail] | |||
| ); | |||
| @@ -129,6 +135,8 @@ const PoQcStockInModalVer2: React.FC<Props> = ({ | |||
| // } else throw("Result is undefined"); | |||
| } catch (e) { | |||
| console.log("%c Error when fetching Qc Result: ", "color:red", e); | |||
| // alert("Something went wrong, please retry"); | |||
| // closeHandler({}, "backdropClick"); | |||
| } | |||
| },[fetchQcResult] | |||
| ); | |||
| @@ -145,6 +153,8 @@ const PoQcStockInModalVer2: React.FC<Props> = ({ | |||
| } else throw("Result is undefined"); | |||
| } catch (e) { | |||
| console.log("%c Error when fetching EscalationLog: ", "color:red", e); | |||
| // alert("Something went wrong, please retry"); | |||
| // closeHandler({}, "backdropClick"); | |||
| } | |||
| },[fetchEscalationLogsByStockInLines] | |||
| ); | |||
| @@ -334,10 +344,10 @@ const PoQcStockInModalVer2: React.FC<Props> = ({ | |||
| } | |||
| } | |||
| // Check if dates are input | |||
| if (data.productionDate === undefined || data.productionDate == null) { | |||
| alert("請輸入生產日期!"); | |||
| return; | |||
| } | |||
| // if (data.productionDate === undefined || data.productionDate == null) { | |||
| // alert("請輸入生產日期!"); | |||
| // return; | |||
| // } | |||
| if (data.expiryDate === undefined || data.expiryDate == null) { | |||
| alert("請輸入到期日!"); | |||
| return; | |||
| @@ -357,7 +367,7 @@ const PoQcStockInModalVer2: React.FC<Props> = ({ | |||
| // validationErrors.push(`${t("QC items without result")}: ${itemsWithoutResult.map(item => item.code).join(', ')}`); | |||
| } | |||
| if (validationErrors.length > 0) { | |||
| if (validationErrors.length > 0 && !skipQc) { | |||
| console.error("QC Validation failed:", validationErrors); | |||
| alert(`未完成品檢: ${validationErrors}`); | |||
| return; | |||
| @@ -531,6 +541,25 @@ const PoQcStockInModalVer2: React.FC<Props> = ({ | |||
| // return isPassed | |||
| // }, [acceptQty, formProps]) | |||
| const printQrcode = useCallback( | |||
| async () => { | |||
| setIsPrinting(true); | |||
| try { | |||
| const postData = { stockInLineIds: [stockInLineInfo?.id] }; | |||
| const response = await fetchPoQrcode(postData); | |||
| if (response) { | |||
| console.log(response); | |||
| downloadFile(new Uint8Array(response.blobValue), response.filename!); | |||
| } | |||
| } catch (e) { | |||
| console.log("%c Error downloading QR Code", "color:red", e); | |||
| } finally { | |||
| setIsPrinting(false); | |||
| } | |||
| }, | |||
| [stockInLineInfo], | |||
| ); | |||
| return ( | |||
| <> | |||
| <FormProvider {...formProps}> | |||
| @@ -577,12 +606,17 @@ const PoQcStockInModalVer2: React.FC<Props> = ({ | |||
| {t("Delivery Detail")} | |||
| </Typography> | |||
| </Grid> | |||
| <StockInForm itemDetail={stockInLineInfo} disabled={viewOnly || showPutaway} /> | |||
| {stockInLineInfo.qcResult ? | |||
| {stockInLineInfo.jobOrderId ? ( | |||
| <FgStockInForm itemDetail={stockInLineInfo} disabled={viewOnly || showPutaway} /> | |||
| ) : ( | |||
| <StockInForm itemDetail={stockInLineInfo} disabled={viewOnly || showPutaway} /> | |||
| ) | |||
| } | |||
| {skipQc === false && (stockInLineInfo.qcResult ? | |||
| <QcComponent | |||
| itemDetail={stockInLineInfo} | |||
| disabled={viewOnly || showPutaway} | |||
| /> : <LoadingComponent/> | |||
| /> : <LoadingComponent/>) | |||
| } | |||
| <Stack direction="row" justifyContent="flex-end" gap={1} sx={{pt:2}}> | |||
| {(!viewOnly && !showPutaway) && (<Button | |||
| @@ -593,7 +627,7 @@ const PoQcStockInModalVer2: React.FC<Props> = ({ | |||
| sx={{ mt: 1 }} | |||
| onClick={formProps.handleSubmit(onSubmitQc, onSubmitErrorQc)} | |||
| > | |||
| {t("confirm qc result")} | |||
| {skipQc ? t("confirm") : t("confirm qc result")} | |||
| </Button>)} | |||
| </Stack> | |||
| </Box> | |||
| @@ -653,6 +687,17 @@ const PoQcStockInModalVer2: React.FC<Props> = ({ | |||
| > | |||
| {isPrinting ? t("Printing") : t("print")} | |||
| </Button> | |||
| <Button | |||
| id="demoPrint" | |||
| type="button" | |||
| variant="contained" | |||
| color="primary" | |||
| sx={{ mt: 1 }} | |||
| onClick={printQrcode} | |||
| disabled={isPrinting} | |||
| > | |||
| {isPrinting ? t("downloading") : t("download Qr Code")} | |||
| </Button> | |||
| </Stack> | |||
| )} | |||
| </>) : <LoadingComponent/>} | |||
| @@ -35,6 +35,7 @@ import { arrayToDateString, INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import { QrCodeScanner } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | |||
| import { msg } from "../Swal/CustomAlerts"; | |||
| import { PutAwayRecord } from "."; | |||
| import FgStockInForm from "../StockIn/FgStockInForm"; | |||
| interface Props extends Omit<ModalProps, "children"> { | |||
| @@ -337,7 +338,11 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||
| 處理上架 | |||
| </Typography> | |||
| <Grid item xs={12}> | |||
| <StockInForm itemDetail={itemDetail} disabled={true} putawayMode={true}/> | |||
| {itemDetail.jobOrderId ? ( | |||
| <FgStockInForm itemDetail={itemDetail} disabled={true} putawayMode={true}/> | |||
| ) : ( | |||
| <StockInForm itemDetail={itemDetail} disabled={true} putawayMode={true}/> | |||
| )} | |||
| </Grid> | |||
| <Paper sx={{ mt: 2, padding: 2, width: "100%", backgroundColor: verified ? '#bceb19' : '#FCD34D' }}> | |||
| <Grid | |||
| @@ -0,0 +1,442 @@ | |||
| "use client"; | |||
| import { | |||
| PurchaseQcResult, | |||
| PurchaseQCInput, | |||
| StockInInput, | |||
| } from "@/app/api/stockIn/actions"; | |||
| import { | |||
| Box, | |||
| Card, | |||
| CardContent, | |||
| Grid, | |||
| Stack, | |||
| TextField, | |||
| Tooltip, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { Controller, useFormContext } from "react-hook-form"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import StyledDataGrid from "../StyledDataGrid"; | |||
| import { useCallback, useEffect, useMemo } from "react"; | |||
| import { | |||
| GridColDef, | |||
| GridRowIdGetter, | |||
| GridRowModel, | |||
| useGridApiContext, | |||
| GridRenderCellParams, | |||
| GridRenderEditCellParams, | |||
| useGridApiRef, | |||
| } from "@mui/x-data-grid"; | |||
| import InputDataGrid from "../InputDataGrid"; | |||
| import { TableRow } from "../InputDataGrid/InputDataGrid"; | |||
| import { QcItemWithChecks } from "@/app/api/qc"; | |||
| import { GridEditInputCell } from "@mui/x-data-grid"; | |||
| import { StockInLine } from "@/app/api/stockIn"; | |||
| import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | |||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import dayjs from "dayjs"; | |||
| // change PurchaseQcResult to stock in entry props | |||
| interface Props { | |||
| itemDetail: StockInLine; | |||
| // qc: QcItemWithChecks[]; | |||
| disabled: boolean; | |||
| putawayMode?: boolean; | |||
| } | |||
| type EntryError = | |||
| | { | |||
| [field in keyof StockInInput]?: string; | |||
| } | |||
| | undefined; | |||
| // type PoQcRow = TableRow<Partial<PurchaseQcResult>, EntryError>; | |||
| const textfieldSx = { | |||
| width: "100%", | |||
| "& .MuiInputBase-root": { | |||
| // height: "120", // Scales with root font size | |||
| height: "5rem", // Scales with root font size | |||
| }, | |||
| "& .MuiInputBase-input": { | |||
| height: "100%", | |||
| boxSizing: "border-box", | |||
| padding: "0.75rem", | |||
| fontSize: 24, | |||
| }, | |||
| "& .MuiInputLabel-root": { | |||
| fontSize: 24, | |||
| transform: "translate(14px, 1.5rem) scale(1)", | |||
| "&.MuiInputLabel-shrink": { | |||
| fontSize: 18, | |||
| transform: "translate(14px, -9px) scale(1)", | |||
| }, | |||
| // [theme.breakpoints.down("sm")]: { | |||
| // fontSize: "1rem", | |||
| // transform: "translate(14px, 1.5rem) scale(1)", | |||
| // "&.MuiInputLabel-shrink": { | |||
| // fontSize: "0.875rem", | |||
| // }, | |||
| // }, | |||
| }, | |||
| }; | |||
| const FgStockInForm: React.FC<Props> = ({ | |||
| // qc, | |||
| itemDetail, | |||
| disabled, | |||
| putawayMode = false, | |||
| }) => { | |||
| const { | |||
| t, | |||
| i18n: { language }, | |||
| } = useTranslation("purchaseOrder"); | |||
| const apiRef = useGridApiRef(); | |||
| const { | |||
| register, | |||
| formState: { errors, defaultValues, touchedFields }, | |||
| watch, | |||
| control, | |||
| setValue, | |||
| getValues, | |||
| reset, | |||
| resetField, | |||
| setError, | |||
| clearErrors, | |||
| } = useFormContext<StockInInput>(); | |||
| // console.log(itemDetail); | |||
| useEffect(() => { | |||
| // console.log("triggered"); | |||
| // // receiptDate default tdy | |||
| // setValue("receiptDate", dayjs().add(0, "month").format(INPUT_DATE_FORMAT)); | |||
| // setValue("status", "received"); | |||
| }, [setValue]); | |||
| useEffect(() => { | |||
| console.log(errors); | |||
| }, [errors]); | |||
| const productionDate = watch("productionDate"); | |||
| const expiryDate = watch("expiryDate"); | |||
| const uom = watch("uom"); | |||
| //// TODO : Add Checking //// | |||
| // Check if dates are input | |||
| // if (data.productionDate === undefined || data.productionDate == null) { | |||
| // validationErrors.push("請輸入生產日期!"); | |||
| // } | |||
| // if (data.expiryDate === undefined || data.expiryDate == null) { | |||
| // validationErrors.push("請輸入到期日!"); | |||
| // } | |||
| useEffect(() => { | |||
| // console.log(uom); | |||
| // console.log(productionDate); | |||
| // console.log(expiryDate); | |||
| if (expiryDate) clearErrors(); | |||
| if (productionDate) clearErrors(); | |||
| }, [productionDate, expiryDate, clearErrors]); | |||
| useEffect(() => { | |||
| console.log("%c StockInForm itemDetail update: ", "color: brown", itemDetail); | |||
| }, [itemDetail]); | |||
| return ( | |||
| <Grid container justifyContent="flex-start" alignItems="flex-start"> | |||
| {/* <Grid item xs={12}> | |||
| <Typography variant="h6" display="block" marginBlockEnd={1}> | |||
| {t("Stock In Detail")} | |||
| </Typography> | |||
| </Grid> */} | |||
| <Grid | |||
| container | |||
| justifyContent="flex-start" | |||
| alignItems="flex-start" | |||
| spacing={2} | |||
| sx={{ mt: 0.5 }} | |||
| > | |||
| {putawayMode && ( | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("joCode")} | |||
| fullWidth | |||
| {...register("productLotNo", { | |||
| // required: "productLotNo required!", | |||
| })} | |||
| sx={textfieldSx} | |||
| disabled={true} | |||
| /> | |||
| </Grid> | |||
| ) | |||
| } | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("itemName")} | |||
| fullWidth | |||
| {...register("itemName", { | |||
| // required: "productLotNo required!", | |||
| })} | |||
| sx={textfieldSx} | |||
| disabled={true} | |||
| // error={Boolean(errors.productLotNo)} | |||
| // helperText={errors.productLotNo?.message} | |||
| /> | |||
| </Grid> | |||
| {putawayMode || ( | |||
| <> | |||
| <Grid item xs={6}> | |||
| <Controller | |||
| control={control} | |||
| name="receiptDate" | |||
| rules={{ required: true }} | |||
| render={({ field }) => { | |||
| return ( | |||
| <LocalizationProvider | |||
| dateAdapter={AdapterDayjs} | |||
| adapterLocale={`${language}-hk`} | |||
| > | |||
| <DatePicker | |||
| {...field} | |||
| sx={textfieldSx} | |||
| label={t("receiptDate")} | |||
| value={dayjs(watch("receiptDate"))} | |||
| disabled={true} | |||
| onChange={(date) => { | |||
| if (!date) return; | |||
| // setValue("receiptDate", date.format(INPUT_DATE_FORMAT)); | |||
| field.onChange(date); | |||
| }} | |||
| inputRef={field.ref} | |||
| slotProps={{ | |||
| textField: { | |||
| // required: true, | |||
| error: Boolean(errors.receiptDate?.message), | |||
| helperText: errors.receiptDate?.message, | |||
| }, | |||
| }} | |||
| /> | |||
| </LocalizationProvider> | |||
| ); | |||
| }} | |||
| /> | |||
| </Grid> | |||
| </> | |||
| )} | |||
| <Grid item xs={6}> | |||
| {putawayMode ? ( | |||
| <TextField | |||
| label={t("stockLotNo")} | |||
| fullWidth | |||
| {...register("lotNo", { | |||
| // required: "productLotNo required!", | |||
| })} | |||
| sx={textfieldSx} | |||
| disabled={true} | |||
| error={Boolean(errors.productLotNo)} | |||
| helperText={errors.productLotNo?.message} | |||
| />) : ( | |||
| <TextField | |||
| label={t("stockLotNo")} | |||
| fullWidth | |||
| {...register("productLotNo", { | |||
| // required: "productLotNo required!", | |||
| })} | |||
| sx={textfieldSx} | |||
| disabled={true} | |||
| error={Boolean(errors.productLotNo)} | |||
| helperText={errors.productLotNo?.message} | |||
| />) | |||
| } | |||
| </Grid> | |||
| {/* {putawayMode || (<> | |||
| <Grid item xs={6}> | |||
| <Controller | |||
| control={control} | |||
| name="productionDate" | |||
| // rules={{ required: !Boolean(expiryDate) }} | |||
| render={({ field }) => { | |||
| return ( | |||
| <LocalizationProvider | |||
| dateAdapter={AdapterDayjs} | |||
| adapterLocale={`${language}-hk`} | |||
| > | |||
| <DatePicker | |||
| {...field} | |||
| sx={textfieldSx} | |||
| label={t("productionDate")} | |||
| value={productionDate ? dayjs(productionDate) : undefined} | |||
| disabled={disabled} | |||
| onChange={(date) => { | |||
| if (!date) return; | |||
| setValue( | |||
| "productionDate", | |||
| date.format(INPUT_DATE_FORMAT), | |||
| ); | |||
| // field.onChange(date); | |||
| }} | |||
| inputRef={field.ref} | |||
| slotProps={{ | |||
| textField: { | |||
| // required: true, | |||
| error: Boolean(errors.productionDate?.message), | |||
| helperText: errors.productionDate?.message, | |||
| }, | |||
| }} | |||
| /> | |||
| </LocalizationProvider> | |||
| ); | |||
| }} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("qty")} | |||
| fullWidth | |||
| {...register("qty", { | |||
| required: "qty required!", | |||
| })} | |||
| sx={textfieldSx} | |||
| disabled={true} | |||
| /> | |||
| </Grid></> | |||
| )} */} | |||
| <Grid item xs={6}> | |||
| <Controller | |||
| control={control} | |||
| name="expiryDate" | |||
| // rules={{ required: !Boolean(productionDate) }} | |||
| render={({ field }) => { | |||
| return ( | |||
| <LocalizationProvider | |||
| dateAdapter={AdapterDayjs} | |||
| adapterLocale={`${language}-hk`} | |||
| > | |||
| <DatePicker | |||
| {...field} | |||
| sx={textfieldSx} | |||
| label={t("expiryDate")} | |||
| value={expiryDate ? dayjs(expiryDate) : undefined} | |||
| disabled={disabled} | |||
| onChange={(date) => { | |||
| if (!date) return; | |||
| console.log(date.format(INPUT_DATE_FORMAT)); | |||
| setValue("expiryDate", date.format(INPUT_DATE_FORMAT)); | |||
| // field.onChange(date); | |||
| }} | |||
| inputRef={field.ref} | |||
| slotProps={{ | |||
| textField: { | |||
| // required: true, | |||
| error: Boolean(errors.expiryDate?.message), | |||
| helperText: errors.expiryDate?.message, | |||
| }, | |||
| }} | |||
| /> | |||
| </LocalizationProvider> | |||
| ); | |||
| }} | |||
| /> | |||
| </Grid> | |||
| {/* <Grid item xs={6}> | |||
| {putawayMode ? ( | |||
| <TextField | |||
| label={t("acceptedQty")} | |||
| fullWidth | |||
| sx={textfieldSx} | |||
| disabled={true} | |||
| value={itemDetail.acceptedQty} | |||
| // disabled={true} | |||
| // disabled={disabled} | |||
| // error={Boolean(errors.acceptedQty)} | |||
| // helperText={errors.acceptedQty?.message} | |||
| /> | |||
| ) : ( | |||
| <TextField | |||
| label={t("receivedQty")} | |||
| fullWidth | |||
| {...register("receivedQty", { | |||
| required: "receivedQty required!", | |||
| })} | |||
| sx={textfieldSx} | |||
| disabled={true} | |||
| /> | |||
| )} | |||
| </Grid> */} | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("salesUnit")} | |||
| fullWidth | |||
| {...register("uom.udfudesc", { | |||
| required: "uom required!", | |||
| })} | |||
| // value={uom?.code} | |||
| sx={textfieldSx} | |||
| disabled={true} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| {putawayMode ? ( | |||
| <TextField | |||
| label={t("processedQty")} | |||
| fullWidth | |||
| sx={textfieldSx} | |||
| disabled={true} | |||
| value={itemDetail.putAwayLines?.reduce((sum, p) => sum + p.qty, 0) ?? 0} | |||
| // disabled={true} | |||
| // disabled={disabled} | |||
| // error={Boolean(errors.acceptedQty)} | |||
| // helperText={errors.acceptedQty?.message} | |||
| /> | |||
| ) : ( | |||
| <TextField | |||
| label={t("acceptedQty")} | |||
| fullWidth | |||
| sx={textfieldSx} | |||
| disabled={true} | |||
| {...register("acceptedQty", { | |||
| required: "acceptedQty required!", | |||
| })} | |||
| // disabled={true} | |||
| // disabled={disabled} | |||
| // error={Boolean(errors.acceptedQty)} | |||
| // helperText={errors.acceptedQty?.message} | |||
| /> | |||
| )} | |||
| </Grid> | |||
| {/* <Grid item xs={4}> | |||
| <TextField | |||
| label={t("acceptedWeight")} | |||
| fullWidth | |||
| // {...register("acceptedWeight", { | |||
| // required: "acceptedWeight required!", | |||
| // })} | |||
| disabled={disabled} | |||
| error={Boolean(errors.acceptedWeight)} | |||
| helperText={errors.acceptedWeight?.message} | |||
| /> | |||
| </Grid> */} | |||
| </Grid> | |||
| <Grid | |||
| container | |||
| justifyContent="flex-start" | |||
| alignItems="flex-start" | |||
| spacing={2} | |||
| sx={{ mt: 0.5 }} | |||
| > | |||
| {/* <Grid item xs={12}> | |||
| <InputDataGrid<PurchaseQCInput, PurchaseQcResult, EntryError> | |||
| apiRef={apiRef} | |||
| checkboxSelection={false} | |||
| _formKey={"qcCheck"} | |||
| columns={columns} | |||
| validateRow={validationTest} | |||
| /> | |||
| </Grid> */} | |||
| </Grid> | |||
| </Grid> | |||
| ); | |||
| }; | |||
| export default FgStockInForm; | |||
| @@ -3,25 +3,37 @@ | |||
| "Create Job Order": "創建工單", | |||
| "Edit Job Order Detail": "編輯工單", | |||
| "Details": "細節", | |||
| "Actions": "操作", | |||
| "Code": "工單編號", | |||
| "Name": "成品/半成品名稱", | |||
| "Picked Qty": "已提料數量", | |||
| "Req. Qty": "需求數量", | |||
| "UoM": "銷售單位", | |||
| "Status": "來貨狀態", | |||
| "Status": "工單狀態", | |||
| "Lot No.": "批號", | |||
| "Bom": "物料清單", | |||
| "Release": "放單", | |||
| "Pending": "待掃碼", | |||
| "Pending for pick": "待提料", | |||
| "Planning": "計劃中", | |||
| "Scanned": "已掃碼", | |||
| "Processing": "已開始工序", | |||
| "Storing": "入倉中", | |||
| "Storing": "成品入倉中", | |||
| "completed": "已完成", | |||
| "Completed": "已完成", | |||
| "Cancel": "取消", | |||
| "Create": "創建", | |||
| "view stockin": "查看入倉詳情", | |||
| "view putaway": "查看上架詳情", | |||
| "process epqc": "進行成品檢驗", | |||
| "scan picked material": "掃碼確認提料", | |||
| "escalation processing": "處理上報記錄", | |||
| "process stockIn": "進行收貨程序", | |||
| "release jo": "確認發佈工單", | |||
| "complete jo": "完成工單", | |||
| "update success": "成功更新資料", | |||
| "Scanned": "已掃碼", | |||
| "Scan Status": "掃碼狀態", | |||
| "Start Job Order": "開始工單", | |||
| "Target Production Date" : "預計生產日期", | |||
| @@ -80,7 +92,7 @@ | |||
| "Scanning...": "掃碼中", | |||
| "Unassigned Job Orders": "未分配工單", | |||
| "Please scan the item qr code": "請掃描物料二維碼", | |||
| "Please make sure the qty is enough": "請確保物料數量是足夠", | |||
| "Please make sure the qty is enough": "物料數量不足", | |||
| "Please make sure all required items are picked": "請確保所有物料已被提取", | |||
| "Do you want to start job order": "是否開始工單", | |||
| "Submit": "提交", | |||
| @@ -45,6 +45,7 @@ | |||
| "processedQty": "已上架數量", | |||
| "expiryDate": "到期日", | |||
| "acceptedQty": "本批收貨數量", | |||
| "acceptedPutawayQty": "本批上架數量", | |||
| "putawayQty": "上架數量", | |||
| "acceptQty": "揀收數量", | |||
| "printQty": "列印數量", | |||
| @@ -151,5 +152,9 @@ | |||
| "Qc Decision": "品檢詳情", | |||
| "Print Qty": "列印數量", | |||
| "putawayDatetime": "上架時間", | |||
| "putawayUser": "上架同事" | |||
| "putawayUser": "上架同事", | |||
| "joCode": "工單編號", | |||
| "salesUnit": "銷售單位", | |||
| "download Qr Code": "下載QR碼", | |||
| "downloading": "下載中" | |||
| } | |||