| @@ -692,16 +692,19 @@ export const fetchJobOrderLotsHierarchicalByPickOrderId = cache(async (pickOrder | |||||
| }, | }, | ||||
| ); | ); | ||||
| }); | }); | ||||
| export const fetchAllJoPickOrders = cache(async (isDrink?: boolean | null, floor?: string | null) => { | |||||
| // NOTE: Do NOT wrap in `cache()` because the list needs to reflect just-completed lines | |||||
| // immediately when navigating back from JobPickExecution. | |||||
| export const fetchAllJoPickOrders = async (isDrink?: boolean | null, floor?: string | null) => { | |||||
| const params = new URLSearchParams(); | const params = new URLSearchParams(); | ||||
| if (isDrink !== undefined && isDrink !== null) params.set("isDrink", String(isDrink)); | if (isDrink !== undefined && isDrink !== null) params.set("isDrink", String(isDrink)); | ||||
| if (floor) params.set("floor", floor); | if (floor) params.set("floor", floor); | ||||
| const query = params.toString() ? `?${params.toString()}` : ""; | const query = params.toString() ? `?${params.toString()}` : ""; | ||||
| return serverFetchJson<AllJoPickOrderResponse[]>( | return serverFetchJson<AllJoPickOrderResponse[]>( | ||||
| `${BASE_API_URL}/jo/AllJoPickOrder${query}`, | `${BASE_API_URL}/jo/AllJoPickOrder${query}`, | ||||
| { method: "GET" } | |||||
| // Force re-fetch. This page reflects real-time pick completion state. | |||||
| { method: "GET", cache: "no-store" } | |||||
| ); | ); | ||||
| }); | |||||
| }; | |||||
| export const fetchProductProcessLineDetail = cache(async (lineId: number) => { | export const fetchProductProcessLineDetail = cache(async (lineId: number) => { | ||||
| return serverFetchJson<JobOrderProcessLineDetailResponse>( | return serverFetchJson<JobOrderProcessLineDetailResponse>( | ||||
| `${BASE_API_URL}/product-process/Demo/ProcessLine/detail/${lineId}`, | `${BASE_API_URL}/product-process/Demo/ProcessLine/detail/${lineId}`, | ||||
| @@ -27,7 +27,7 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{ | |||||
| const [selectedJobOrderId, setSelectedJobOrderId] = useState<number | undefined>(undefined); | const [selectedJobOrderId, setSelectedJobOrderId] = useState<number | undefined>(undefined); | ||||
| type PickOrderFilter = "all" | "drink" | "other"; | type PickOrderFilter = "all" | "drink" | "other"; | ||||
| const [filter, setFilter] = useState<PickOrderFilter>("all"); | const [filter, setFilter] = useState<PickOrderFilter>("all"); | ||||
| type FloorFilter = "ALL" | "2F" | "3F" | "4F"; | |||||
| type FloorFilter = "ALL" | "2F" | "3F" | "4F" | "NO_LOT"; | |||||
| const [floorFilter, setFloorFilter] = useState<FloorFilter>("ALL"); | const [floorFilter, setFloorFilter] = useState<FloorFilter>("ALL"); | ||||
| const fetchPickOrders = useCallback(async () => { | const fetchPickOrders = useCallback(async () => { | ||||
| setLoading(true); | setLoading(true); | ||||
| @@ -60,10 +60,7 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{ | |||||
| <Box sx={{ mb: 2 }}> | <Box sx={{ mb: 2 }}> | ||||
| <Button | <Button | ||||
| variant="outlined" | variant="outlined" | ||||
| onClick={() => { | |||||
| setSelectedPickOrderId(undefined); | |||||
| setSelectedJobOrderId(undefined); | |||||
| }} | |||||
| onClick={handleBackToList} | |||||
| startIcon={<ArrowBackIcon />} | startIcon={<ArrowBackIcon />} | ||||
| > | > | ||||
| {t("Back to List")} | {t("Back to List")} | ||||
| @@ -140,6 +137,13 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{ | |||||
| > | > | ||||
| 4F | 4F | ||||
| </Button> | </Button> | ||||
| <Button | |||||
| variant={floorFilter === "NO_LOT" ? "contained" : "outlined"} | |||||
| size="small" | |||||
| onClick={() => setFloorFilter("NO_LOT")} | |||||
| > | |||||
| {t("No Lot")} | |||||
| </Button> | |||||
| </Box> | </Box> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | ||||
| {t("Total pick orders")}: {pickOrders.length} | {t("Total pick orders")}: {pickOrders.length} | ||||
| @@ -163,8 +167,8 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{ | |||||
| <Grid key={pickOrder.id} item xs={12} sm={6} md={4}> | <Grid key={pickOrder.id} item xs={12} sm={6} md={4}> | ||||
| <Card | <Card | ||||
| sx={{ | sx={{ | ||||
| minHeight: 160, | |||||
| maxHeight: 240, | |||||
| minHeight: 180, | |||||
| maxHeight: 280, | |||||
| display: "flex", | display: "flex", | ||||
| flexDirection: "column", | flexDirection: "column", | ||||
| }} | }} | ||||
| @@ -196,21 +200,57 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{ | |||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| {t("Required Qty")}: {pickOrder.reqQty} ({pickOrder.uomName}) | {t("Required Qty")}: {pickOrder.reqQty} ({pickOrder.uomName}) | ||||
| </Typography> | </Typography> | ||||
| {pickOrder.floorPickCounts?.map(({ floor, finishedCount, totalCount }) => ( | |||||
| <Typography key={floor} variant="body2" color="text.secondary" component="span" sx={{ mr: 1 }}> | |||||
| {floor}: {finishedCount}/{totalCount} | |||||
| </Typography> | |||||
| ))} | |||||
| {!!pickOrder.noLotPickCount && ( | |||||
| <Typography | |||||
| key="NO_LOT" | |||||
| variant="body2" | |||||
| color="text.secondary" | |||||
| component="span" | |||||
| sx={{ mr: 1 }} | |||||
| > | |||||
| {t("No Lot")}: {pickOrder.noLotPickCount.finishedCount}/{pickOrder.noLotPickCount.totalCount} | |||||
| </Typography> | |||||
| {floorFilter === "ALL" ? ( | |||||
| <> | |||||
| {pickOrder.floorPickCounts?.map(({ floor, finishedCount, totalCount }) => ( | |||||
| <Typography | |||||
| key={floor} | |||||
| variant="body2" | |||||
| color="text.secondary" | |||||
| component="span" | |||||
| sx={{ mr: 1 }} | |||||
| > | |||||
| {floor}: {finishedCount}/{totalCount} | |||||
| </Typography> | |||||
| ))} | |||||
| {!!pickOrder.noLotPickCount && ( | |||||
| <Typography | |||||
| key="NO_LOT" | |||||
| variant="body2" | |||||
| color="text.secondary" | |||||
| component="span" | |||||
| sx={{ mr: 1 }} | |||||
| > | |||||
| {t("No Lot")}: {pickOrder.noLotPickCount.finishedCount}/{pickOrder.noLotPickCount.totalCount} | |||||
| </Typography> | |||||
| )} | |||||
| </> | |||||
| ) : floorFilter === "NO_LOT" ? ( | |||||
| !!pickOrder.noLotPickCount && ( | |||||
| <Typography | |||||
| key="NO_LOT" | |||||
| variant="body2" | |||||
| color="text.secondary" | |||||
| component="span" | |||||
| sx={{ mr: 1 }} | |||||
| > | |||||
| {t("No Lot")}: {pickOrder.noLotPickCount.finishedCount}/{pickOrder.noLotPickCount.totalCount} | |||||
| </Typography> | |||||
| ) | |||||
| ) : ( | |||||
| pickOrder.floorPickCounts | |||||
| ?.filter((c) => c.floor === floorFilter) | |||||
| .map(({ floor, finishedCount, totalCount }) => ( | |||||
| <Typography | |||||
| key={floor} | |||||
| variant="body2" | |||||
| color="text.secondary" | |||||
| component="span" | |||||
| sx={{ mr: 1 }} | |||||
| > | |||||
| {floor}: {finishedCount}/{totalCount} | |||||
| </Typography> | |||||
| )) | |||||
| )} | )} | ||||
| {typeof pickOrder.suggestedFailCount === "number" && pickOrder.suggestedFailCount > 0 && ( | {typeof pickOrder.suggestedFailCount === "number" && pickOrder.suggestedFailCount > 0 && ( | ||||
| <Typography variant="body2" color="error" sx={{ mt: 0.5 }}> | <Typography variant="body2" color="error" sx={{ mt: 0.5 }}> | ||||
| @@ -19,9 +19,11 @@ import { | |||||
| } from '@mui/material'; | } from '@mui/material'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import dayjs from 'dayjs'; | import dayjs from 'dayjs'; | ||||
| import type { Dayjs } from 'dayjs'; | |||||
| import { fetchJobProcessStatus, JobProcessStatusResponse } from '@/app/api/jo/actions'; | import { fetchJobProcessStatus, JobProcessStatusResponse } from '@/app/api/jo/actions'; | ||||
| import { arrayToDayjs } from '@/app/utils/formatUtil'; | import { arrayToDayjs } from '@/app/utils/formatUtil'; | ||||
| import { FormControl, Select, MenuItem } from "@mui/material"; | |||||
| import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | |||||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||||
| const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 minutes | const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 minutes | ||||
| const JobProcessStatus: React.FC = () => { | const JobProcessStatus: React.FC = () => { | ||||
| @@ -30,8 +32,8 @@ const JobProcessStatus: React.FC = () => { | |||||
| const [loading, setLoading] = useState<boolean>(true); | const [loading, setLoading] = useState<boolean>(true); | ||||
| const refreshCountRef = useRef<number>(0); | const refreshCountRef = useRef<number>(0); | ||||
| const [currentTime, setCurrentTime] = useState(dayjs()); | const [currentTime, setCurrentTime] = useState(dayjs()); | ||||
| const [selectedDate, setSelectedDate] = useState(dayjs().format("YYYY-MM-DD")); | |||||
| const [lastDataRefreshTime, setLastDataRefreshTime] = useState<dayjs.Dayjs | null>(null); | |||||
| const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs()); | |||||
| const [lastDataRefreshTime, setLastDataRefreshTime] = useState<Dayjs | null>(null); | |||||
| // Update current time every second for countdown | // Update current time every second for countdown | ||||
| useEffect(() => { | useEffect(() => { | ||||
| @@ -44,7 +46,7 @@ const [lastDataRefreshTime, setLastDataRefreshTime] = useState<dayjs.Dayjs | nul | |||||
| const loadData = useCallback(async () => { | const loadData = useCallback(async () => { | ||||
| setLoading(true); | setLoading(true); | ||||
| try { | try { | ||||
| const result = await fetchJobProcessStatus(selectedDate); | |||||
| const result = await fetchJobProcessStatus(selectedDate.format("YYYY-MM-DD")); | |||||
| setData(result); | setData(result); | ||||
| refreshCountRef.current += 1; | refreshCountRef.current += 1; | ||||
| setLastDataRefreshTime(dayjs()); | setLastDataRefreshTime(dayjs()); | ||||
| @@ -181,16 +183,19 @@ const [lastDataRefreshTime, setLastDataRefreshTime] = useState<dayjs.Dayjs | nul | |||||
| {/* Filters */} | {/* Filters */} | ||||
| <Stack direction="row" spacing={2} sx={{ mb: 3 }}> | <Stack direction="row" spacing={2} sx={{ mb: 3 }}> | ||||
| <FormControl size="small" sx={{ minWidth: 160 }}> | |||||
| <Select | |||||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||||
| <DatePicker | |||||
| label={t("Date")} | |||||
| value={selectedDate} | value={selectedDate} | ||||
| onChange={(e) => setSelectedDate(e.target.value)} | |||||
| > | |||||
| <MenuItem value={dayjs().format("YYYY-MM-DD")}>今天</MenuItem> | |||||
| <MenuItem value={dayjs().subtract(1, "day").format("YYYY-MM-DD")}>昨天</MenuItem> | |||||
| <MenuItem value={dayjs().subtract(2, "day").format("YYYY-MM-DD")}>前天</MenuItem> | |||||
| </Select> | |||||
| </FormControl> | |||||
| onChange={(newValue) => { | |||||
| if (newValue) setSelectedDate(newValue); | |||||
| }} | |||||
| format="YYYY-MM-DD" | |||||
| slotProps={{ | |||||
| textField: { size: "small", sx: { minWidth: 160 } }, | |||||
| }} | |||||
| /> | |||||
| </LocalizationProvider> | |||||
| <Box sx={{ flexGrow: 1 }} /> | <Box sx={{ flexGrow: 1 }} /> | ||||
| <Stack direction="row" spacing={2} sx={{ alignSelf: 'center' }}> | <Stack direction="row" spacing={2} sx={{ alignSelf: 'center' }}> | ||||
| @@ -14,13 +14,13 @@ import { | |||||
| TableRow, | TableRow, | ||||
| Paper, | Paper, | ||||
| Typography, | Typography, | ||||
| FormControl, | |||||
| Select, | |||||
| MenuItem, | |||||
| Stack | Stack | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import type { Dayjs } from "dayjs"; | |||||
| import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | |||||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||||
| import { fetchOperatorKpi, OperatorKpiResponse, OperatorKpiProcessInfo } from "@/app/api/jo/actions"; | import { fetchOperatorKpi, OperatorKpiResponse, OperatorKpiProcessInfo } from "@/app/api/jo/actions"; | ||||
| import { arrayToDayjs } from "@/app/utils/formatUtil"; | import { arrayToDayjs } from "@/app/utils/formatUtil"; | ||||
| @@ -30,10 +30,10 @@ const OperatorKpiDashboard: React.FC = () => { | |||||
| const { t } = useTranslation(["common", "jo"]); | const { t } = useTranslation(["common", "jo"]); | ||||
| const [data, setData] = useState<OperatorKpiResponse[]>([]); | const [data, setData] = useState<OperatorKpiResponse[]>([]); | ||||
| const [loading, setLoading] = useState<boolean>(true); | const [loading, setLoading] = useState<boolean>(true); | ||||
| const [selectedDate, setSelectedDate] = useState(dayjs().format("YYYY-MM-DD")); | |||||
| const [selectedDate, setSelectedDate] = useState<Dayjs>(dayjs()); | |||||
| const refreshCountRef = useRef<number>(0); | const refreshCountRef = useRef<number>(0); | ||||
| const [now, setNow] = useState(dayjs()); | const [now, setNow] = useState(dayjs()); | ||||
| const [lastDataRefreshTime, setLastDataRefreshTime] = useState<dayjs.Dayjs | null>(null); | |||||
| const [lastDataRefreshTime, setLastDataRefreshTime] = useState<Dayjs | null>(null); | |||||
| const formatTime = (timeData: any): string => { | const formatTime = (timeData: any): string => { | ||||
| if (!timeData) return "-"; | if (!timeData) return "-"; | ||||
| @@ -69,7 +69,7 @@ const OperatorKpiDashboard: React.FC = () => { | |||||
| const loadData = useCallback(async () => { | const loadData = useCallback(async () => { | ||||
| setLoading(true); | setLoading(true); | ||||
| try { | try { | ||||
| const result = await fetchOperatorKpi(selectedDate); | |||||
| const result = await fetchOperatorKpi(selectedDate.format("YYYY-MM-DD")); | |||||
| setData(result); | setData(result); | ||||
| setLastDataRefreshTime(dayjs()); | setLastDataRefreshTime(dayjs()); | ||||
| refreshCountRef.current += 1; | refreshCountRef.current += 1; | ||||
| @@ -165,16 +165,19 @@ const OperatorKpiDashboard: React.FC = () => { | |||||
| {/* Filters */} | {/* Filters */} | ||||
| <Stack direction="row" spacing={2} sx={{ mb: 3 }}> | <Stack direction="row" spacing={2} sx={{ mb: 3 }}> | ||||
| <FormControl size="small" sx={{ minWidth: 160 }}> | |||||
| <Select | |||||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||||
| <DatePicker | |||||
| label={t("Date")} | |||||
| value={selectedDate} | value={selectedDate} | ||||
| onChange={(e) => setSelectedDate(e.target.value)} | |||||
| > | |||||
| <MenuItem value={dayjs().format("YYYY-MM-DD")}>{t("Today")}</MenuItem> | |||||
| <MenuItem value={dayjs().subtract(1, "day").format("YYYY-MM-DD")}>{t("Yesterday")}</MenuItem> | |||||
| <MenuItem value={dayjs().subtract(2, "day").format("YYYY-MM-DD")}>{t("Two Days Ago")}</MenuItem> | |||||
| </Select> | |||||
| </FormControl> | |||||
| onChange={(newValue) => { | |||||
| if (newValue) setSelectedDate(newValue); | |||||
| }} | |||||
| format="YYYY-MM-DD" | |||||
| slotProps={{ | |||||
| textField: { size: "small", sx: { minWidth: 160 } }, | |||||
| }} | |||||
| /> | |||||
| </LocalizationProvider> | |||||
| <Box sx={{ flexGrow: 1 }} /> | <Box sx={{ flexGrow: 1 }} /> | ||||
| <Stack direction="row" spacing={2} sx={{ alignSelf: 'center' }}> | <Stack direction="row" spacing={2} sx={{ alignSelf: 'center' }}> | ||||