| @@ -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(); | |||
| if (isDrink !== undefined && isDrink !== null) params.set("isDrink", String(isDrink)); | |||
| if (floor) params.set("floor", floor); | |||
| const query = params.toString() ? `?${params.toString()}` : ""; | |||
| return serverFetchJson<AllJoPickOrderResponse[]>( | |||
| `${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) => { | |||
| return serverFetchJson<JobOrderProcessLineDetailResponse>( | |||
| `${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); | |||
| type PickOrderFilter = "all" | "drink" | "other"; | |||
| 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 fetchPickOrders = useCallback(async () => { | |||
| setLoading(true); | |||
| @@ -60,10 +60,7 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{ | |||
| <Box sx={{ mb: 2 }}> | |||
| <Button | |||
| variant="outlined" | |||
| onClick={() => { | |||
| setSelectedPickOrderId(undefined); | |||
| setSelectedJobOrderId(undefined); | |||
| }} | |||
| onClick={handleBackToList} | |||
| startIcon={<ArrowBackIcon />} | |||
| > | |||
| {t("Back to List")} | |||
| @@ -140,6 +137,13 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{ | |||
| > | |||
| 4F | |||
| </Button> | |||
| <Button | |||
| variant={floorFilter === "NO_LOT" ? "contained" : "outlined"} | |||
| size="small" | |||
| onClick={() => setFloorFilter("NO_LOT")} | |||
| > | |||
| {t("No Lot")} | |||
| </Button> | |||
| </Box> | |||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | |||
| {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}> | |||
| <Card | |||
| sx={{ | |||
| minHeight: 160, | |||
| maxHeight: 240, | |||
| minHeight: 180, | |||
| maxHeight: 280, | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| }} | |||
| @@ -196,21 +200,57 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{ | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Required Qty")}: {pickOrder.reqQty} ({pickOrder.uomName}) | |||
| </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 && ( | |||
| <Typography variant="body2" color="error" sx={{ mt: 0.5 }}> | |||
| @@ -19,9 +19,11 @@ import { | |||
| } from '@mui/material'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import dayjs from 'dayjs'; | |||
| import type { Dayjs } from 'dayjs'; | |||
| import { fetchJobProcessStatus, JobProcessStatusResponse } from '@/app/api/jo/actions'; | |||
| 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 JobProcessStatus: React.FC = () => { | |||
| @@ -30,8 +32,8 @@ const JobProcessStatus: React.FC = () => { | |||
| const [loading, setLoading] = useState<boolean>(true); | |||
| const refreshCountRef = useRef<number>(0); | |||
| 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 | |||
| useEffect(() => { | |||
| @@ -44,7 +46,7 @@ const [lastDataRefreshTime, setLastDataRefreshTime] = useState<dayjs.Dayjs | nul | |||
| const loadData = useCallback(async () => { | |||
| setLoading(true); | |||
| try { | |||
| const result = await fetchJobProcessStatus(selectedDate); | |||
| const result = await fetchJobProcessStatus(selectedDate.format("YYYY-MM-DD")); | |||
| setData(result); | |||
| refreshCountRef.current += 1; | |||
| setLastDataRefreshTime(dayjs()); | |||
| @@ -181,16 +183,19 @@ const [lastDataRefreshTime, setLastDataRefreshTime] = useState<dayjs.Dayjs | nul | |||
| {/* Filters */} | |||
| <Stack direction="row" spacing={2} sx={{ mb: 3 }}> | |||
| <FormControl size="small" sx={{ minWidth: 160 }}> | |||
| <Select | |||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||
| <DatePicker | |||
| label={t("Date")} | |||
| 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 }} /> | |||
| <Stack direction="row" spacing={2} sx={{ alignSelf: 'center' }}> | |||
| @@ -14,13 +14,13 @@ import { | |||
| TableRow, | |||
| Paper, | |||
| Typography, | |||
| FormControl, | |||
| Select, | |||
| MenuItem, | |||
| Stack | |||
| } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| 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 { arrayToDayjs } from "@/app/utils/formatUtil"; | |||
| @@ -30,10 +30,10 @@ const OperatorKpiDashboard: React.FC = () => { | |||
| const { t } = useTranslation(["common", "jo"]); | |||
| const [data, setData] = useState<OperatorKpiResponse[]>([]); | |||
| 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 [now, setNow] = useState(dayjs()); | |||
| const [lastDataRefreshTime, setLastDataRefreshTime] = useState<dayjs.Dayjs | null>(null); | |||
| const [lastDataRefreshTime, setLastDataRefreshTime] = useState<Dayjs | null>(null); | |||
| const formatTime = (timeData: any): string => { | |||
| if (!timeData) return "-"; | |||
| @@ -69,7 +69,7 @@ const OperatorKpiDashboard: React.FC = () => { | |||
| const loadData = useCallback(async () => { | |||
| setLoading(true); | |||
| try { | |||
| const result = await fetchOperatorKpi(selectedDate); | |||
| const result = await fetchOperatorKpi(selectedDate.format("YYYY-MM-DD")); | |||
| setData(result); | |||
| setLastDataRefreshTime(dayjs()); | |||
| refreshCountRef.current += 1; | |||
| @@ -165,16 +165,19 @@ const OperatorKpiDashboard: React.FC = () => { | |||
| {/* Filters */} | |||
| <Stack direction="row" spacing={2} sx={{ mb: 3 }}> | |||
| <FormControl size="small" sx={{ minWidth: 160 }}> | |||
| <Select | |||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||
| <DatePicker | |||
| label={t("Date")} | |||
| 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 }} /> | |||
| <Stack direction="row" spacing={2} sx={{ alignSelf: 'center' }}> | |||