# Conflicts: # src/i18n/zh/inventory.jsonmaster
| @@ -0,0 +1,316 @@ | |||
| "use client"; | |||
| import React, { useState, useEffect, useMemo } from "react"; | |||
| import { | |||
| Box, Paper, Typography, Button, Dialog, DialogTitle, | |||
| DialogContent, DialogActions, TextField, Stack, Table, | |||
| TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton, | |||
| CircularProgress, Tooltip | |||
| } from "@mui/material"; | |||
| import { | |||
| Search, Visibility, ListAlt, CalendarMonth, | |||
| OnlinePrediction, FileDownload, SettingsEthernet | |||
| } from "@mui/icons-material"; | |||
| import dayjs from "dayjs"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| export default function ProductionSchedulePage() { | |||
| // --- 1. States --- | |||
| const [searchDate, setSearchDate] = useState(dayjs().format('YYYY-MM-DD')); | |||
| const [schedules, setSchedules] = useState<any[]>([]); | |||
| const [selectedLines, setSelectedLines] = useState([]); | |||
| const [isDetailOpen, setIsDetailOpen] = useState(false); | |||
| const [selectedPs, setSelectedPs] = useState<any>(null); | |||
| const [loading, setLoading] = useState(false); | |||
| const [isGenerating, setIsGenerating] = useState(false); | |||
| // --- 2. Auto-search on page entry --- | |||
| useEffect(() => { | |||
| handleSearch(); | |||
| }, []); | |||
| // --- 3. Formatters & Helpers --- | |||
| // Handles [YYYY, MM, DD] format from Kotlin/Java LocalDate | |||
| const formatBackendDate = (dateVal: any) => { | |||
| if (Array.isArray(dateVal)) { | |||
| const [year, month, day] = dateVal; | |||
| return dayjs(new Date(year, month - 1, day)).format('DD MMM (dddd)'); | |||
| } | |||
| return dayjs(dateVal).format('DD MMM (dddd)'); | |||
| }; | |||
| // Adds commas as thousands separators | |||
| const formatNum = (num: any) => { | |||
| return new Intl.NumberFormat('en-US').format(Number(num) || 0); | |||
| }; | |||
| // Logic to determine if the selected row's produceAt is TODAY | |||
| const isDateToday = useMemo(() => { | |||
| if (!selectedPs?.produceAt) return false; | |||
| const todayStr = dayjs().format('YYYY-MM-DD'); | |||
| let scheduleDateStr = ""; | |||
| if (Array.isArray(selectedPs.produceAt)) { | |||
| const [y, m, d] = selectedPs.produceAt; | |||
| scheduleDateStr = dayjs(new Date(y, m - 1, d)).format('YYYY-MM-DD'); | |||
| } else { | |||
| scheduleDateStr = dayjs(selectedPs.produceAt).format('YYYY-MM-DD'); | |||
| } | |||
| return todayStr === scheduleDateStr; | |||
| }, [selectedPs]); | |||
| // --- 4. API Actions --- | |||
| // Main Grid Query | |||
| const handleSearch = async () => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| setLoading(true); | |||
| try { | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/ps/search-ps?produceAt=${searchDate}`, { | |||
| method: 'GET', | |||
| headers: { 'Authorization': `Bearer ${token}` } | |||
| }); | |||
| const data = await response.json(); | |||
| setSchedules(Array.isArray(data) ? data : []); | |||
| } catch (e) { | |||
| console.error("Search Error:", e); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }; | |||
| // Forecast API | |||
| const handleForecast = async () => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| setLoading(true); | |||
| try { | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/productionSchedule/testDetailedSchedule`, { | |||
| method: 'POST', | |||
| headers: { 'Authorization': `Bearer ${token}` } | |||
| }); | |||
| if (response.ok) { | |||
| await handleSearch(); // Refresh grid after successful forecast | |||
| } | |||
| } catch (e) { | |||
| console.error("Forecast Error:", e); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }; | |||
| // Export Excel API | |||
| const handleExport = async () => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| try { | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/ps/export-prod-schedule`, { | |||
| method: 'POST', | |||
| headers: { 'Authorization': `Bearer ${token}` } | |||
| }); | |||
| if (!response.ok) throw new Error("Export failed"); | |||
| const blob = await response.blob(); | |||
| const url = window.URL.createObjectURL(blob); | |||
| const a = document.createElement('a'); | |||
| a.href = url; | |||
| a.download = `production_schedule_${dayjs().format('YYYYMMDD')}.xlsx`; | |||
| document.body.appendChild(a); | |||
| a.click(); | |||
| window.URL.revokeObjectURL(url); | |||
| document.body.removeChild(a); | |||
| } catch (e) { | |||
| console.error("Export Error:", e); | |||
| } | |||
| }; | |||
| // Get Detail Lines | |||
| const handleViewDetail = async (ps: any) => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| setSelectedPs(ps); | |||
| try { | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/ps/search-ps-line?psId=${ps.id}`, { | |||
| method: 'GET', | |||
| headers: { 'Authorization': `Bearer ${token}` } | |||
| }); | |||
| const data = await response.json(); | |||
| setSelectedLines(data || []); | |||
| setIsDetailOpen(true); | |||
| } catch (e) { | |||
| console.error("Detail Error:", e); | |||
| } | |||
| }; | |||
| // Auto Gen Job API (Only allowed for Today's date) | |||
| const handleAutoGenJob = async () => { | |||
| if (!isDateToday) return; | |||
| const token = localStorage.getItem("accessToken"); | |||
| setIsGenerating(true); | |||
| try { | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/productionSchedule/detail/detailed/release`, { | |||
| method: 'POST', | |||
| headers: { | |||
| 'Authorization': `Bearer ${token}`, | |||
| 'Content-Type': 'application/json' | |||
| }, | |||
| body: JSON.stringify({ id: selectedPs.id }) | |||
| }); | |||
| if (response.ok) { | |||
| alert("Job Orders generated successfully!"); | |||
| setIsDetailOpen(false); | |||
| } else { | |||
| alert("Failed to generate jobs."); | |||
| } | |||
| } catch (e) { | |||
| console.error("Release Error:", e); | |||
| } finally { | |||
| setIsGenerating(false); | |||
| } | |||
| }; | |||
| return ( | |||
| <Box sx={{ p: 4, bgcolor: '#fbfbfb', minHeight: '100vh' }}> | |||
| {/* Top Header Buttons */} | |||
| <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 3 }}> | |||
| <Stack direction="row" spacing={2} alignItems="center"> | |||
| <CalendarMonth color="primary" sx={{ fontSize: 32 }} /> | |||
| <Typography variant="h4" sx={{ fontWeight: 'bold' }}>Production Planning</Typography> | |||
| </Stack> | |||
| <Stack direction="row" spacing={2}> | |||
| <Button variant="outlined" color="success" startIcon={<FileDownload />} onClick={handleExport} sx={{ fontWeight: 'bold' }}> | |||
| Export Excel | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| color="secondary" | |||
| startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <OnlinePrediction />} | |||
| onClick={handleForecast} | |||
| disabled={loading} | |||
| sx={{ fontWeight: 'bold' }} | |||
| > | |||
| Forecast | |||
| </Button> | |||
| </Stack> | |||
| </Stack> | |||
| {/* Query Bar */} | |||
| <Paper sx={{ p: 2, mb: 3, display: 'flex', alignItems: 'center', gap: 2, borderLeft: '6px solid #1976d2' }}> | |||
| <TextField | |||
| label="Produce Date" | |||
| type="date" | |||
| size="small" | |||
| InputLabelProps={{ shrink: true }} | |||
| value={searchDate} | |||
| onChange={(e) => setSearchDate(e.target.value)} | |||
| /> | |||
| <Button variant="contained" startIcon={<Search />} onClick={handleSearch}>Query</Button> | |||
| </Paper> | |||
| {/* Main Grid Table */} | |||
| <TableContainer component={Paper}> | |||
| <Table stickyHeader size="small"> | |||
| <TableHead> | |||
| <TableRow sx={{ bgcolor: '#f5f5f5' }}> | |||
| <TableCell align="center" sx={{ fontWeight: 'bold', width: 100 }}>Action</TableCell> | |||
| <TableCell sx={{ fontWeight: 'bold' }}>ID</TableCell> | |||
| <TableCell sx={{ fontWeight: 'bold' }}>Production Date</TableCell> | |||
| <TableCell align="right" sx={{ fontWeight: 'bold' }}>Est. Prod Count</TableCell> | |||
| <TableCell align="right" sx={{ fontWeight: 'bold' }}>Total FG Types</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {schedules.map((ps) => ( | |||
| <TableRow key={ps.id} hover> | |||
| <TableCell align="center"> | |||
| <IconButton color="primary" size="small" onClick={() => handleViewDetail(ps)}> | |||
| <Visibility fontSize="small" /> | |||
| </IconButton> | |||
| </TableCell> | |||
| <TableCell>#{ps.id}</TableCell> | |||
| <TableCell>{formatBackendDate(ps.produceAt)}</TableCell> | |||
| <TableCell align="right">{formatNum(ps.totalEstProdCount)}</TableCell> | |||
| <TableCell align="right">{formatNum(ps.totalFGType)}</TableCell> | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| {/* Detailed Lines Dialog */} | |||
| <Dialog open={isDetailOpen} onClose={() => setIsDetailOpen(false)} maxWidth="lg" fullWidth> | |||
| <DialogTitle sx={{ bgcolor: '#1976d2', color: 'white' }}> | |||
| <Stack direction="row" alignItems="center" spacing={1}> | |||
| <ListAlt /> | |||
| <Typography variant="h6">Schedule Details: {selectedPs?.id} ({formatBackendDate(selectedPs?.produceAt)})</Typography> | |||
| </Stack> | |||
| </DialogTitle> | |||
| <DialogContent sx={{ p: 0 }}> | |||
| <TableContainer sx={{ maxHeight: '65vh' }}> | |||
| <Table size="small" stickyHeader> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Job Order</TableCell> | |||
| <TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Item Code</TableCell> | |||
| <TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Item Name</TableCell> | |||
| <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Avg Last Month</TableCell> | |||
| <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Stock</TableCell> | |||
| <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Days Left</TableCell> | |||
| <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Batch Need</TableCell> | |||
| <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Prod Qty</TableCell> | |||
| <TableCell align="center" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Priority</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {selectedLines.map((line: any) => ( | |||
| <TableRow key={line.id} hover> | |||
| <TableCell sx={{ color: 'primary.main', fontWeight: 'bold' }}>{line.joCode || '-'}</TableCell> | |||
| <TableCell sx={{ fontWeight: 'bold' }}>{line.itemCode}</TableCell> | |||
| <TableCell>{line.itemName}</TableCell> | |||
| <TableCell align="right">{formatNum(line.avgQtyLastMonth)}</TableCell> | |||
| <TableCell align="right">{formatNum(line.stockQty)}</TableCell> | |||
| <TableCell align="right" sx={{ color: line.daysLeft < 5 ? 'error.main' : 'inherit', fontWeight: line.daysLeft < 5 ? 'bold' : 'normal' }}> | |||
| {line.daysLeft} | |||
| </TableCell> | |||
| <TableCell align="right">{formatNum(line.batchNeed)}</TableCell> | |||
| <TableCell align="right"><strong>{formatNum(line.prodQty)}</strong></TableCell> | |||
| <TableCell align="center">{line.itemPriority}</TableCell> | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| </DialogContent> | |||
| {/* Footer Actions */} | |||
| <DialogActions sx={{ p: 2, bgcolor: '#f9f9f9' }}> | |||
| <Stack direction="row" spacing={2}> | |||
| <Tooltip title={!isDateToday ? "Job Orders can only be generated for the current day's schedule." : ""}> | |||
| <span> | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| startIcon={isGenerating ? <CircularProgress size={20} color="inherit" /> : <SettingsEthernet />} | |||
| onClick={handleAutoGenJob} | |||
| disabled={isGenerating || !isDateToday} | |||
| > | |||
| Auto Gen Job | |||
| </Button> | |||
| </span> | |||
| </Tooltip> | |||
| <Button | |||
| onClick={() => setIsDetailOpen(false)} | |||
| variant="outlined" | |||
| color="inherit" | |||
| disabled={isGenerating} | |||
| > | |||
| Close | |||
| </Button> | |||
| </Stack> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -12,6 +12,7 @@ import { Suspense } from "react"; | |||
| import { fetchAllEquipments } from "@/app/api/settings/equipment"; | |||
| import { I18nProvider } from "@/i18n"; | |||
| import EquipmentSearchWrapper from "@/components/EquipmentSearch/EquipmentSearchWrapper"; | |||
| import EquipmentSearchLoading from "@/components/EquipmentSearch/EquipmentSearchLoading"; | |||
| export const metadata: Metadata = { | |||
| title: "Equipment Type", | |||
| @@ -33,7 +34,7 @@ const productSetting: React.FC = async () => { | |||
| {t("Equipment")} | |||
| </Typography> | |||
| </Stack> | |||
| <Suspense fallback={<EquipmentSearchWrapper.Loading />}> | |||
| <Suspense fallback={<EquipmentSearchLoading />}> | |||
| <I18nProvider namespaces={["common", "project"]}> | |||
| <EquipmentSearchWrapper /> | |||
| </I18nProvider> | |||
| @@ -0,0 +1,21 @@ | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import React, { Suspense } from "react"; | |||
| import { Typography } from "@mui/material"; | |||
| import CreateWarehouse from "@/components/CreateWarehouse"; | |||
| const CreateWarehousePage: React.FC = async () => { | |||
| const { t } = await getServerI18n("warehouse"); | |||
| return ( | |||
| <> | |||
| <Typography variant="h4">{t("Create Warehouse")}</Typography> | |||
| <I18nProvider namespaces={["warehouse", "common"]}> | |||
| <Suspense fallback={<CreateWarehouse.Loading />}> | |||
| <CreateWarehouse /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default CreateWarehousePage; | |||
| @@ -0,0 +1,45 @@ | |||
| import { Metadata } from "next"; | |||
| import { getServerI18n, I18nProvider } from "@/i18n"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { Suspense } from "react"; | |||
| import { Stack } from "@mui/material"; | |||
| import { Button } from "@mui/material"; | |||
| import Link from "next/link"; | |||
| import WarehouseHandle from "@/components/WarehouseHandle"; | |||
| import Add from "@mui/icons-material/Add"; | |||
| export const metadata: Metadata = { | |||
| title: "Warehouse Management", | |||
| }; | |||
| const Warehouse: React.FC = async () => { | |||
| const { t } = await getServerI18n("warehouse"); | |||
| return ( | |||
| <> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="space-between" | |||
| flexWrap="wrap" | |||
| rowGap={2} | |||
| > | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Warehouse")} | |||
| </Typography> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<Add />} | |||
| LinkComponent={Link} | |||
| href="/settings/warehouse/create" | |||
| > | |||
| {t("Create Warehouse")} | |||
| </Button> | |||
| </Stack> | |||
| <I18nProvider namespaces={["warehouse", "common", "dashboard"]}> | |||
| <Suspense fallback={<WarehouseHandle.Loading />}> | |||
| <WarehouseHandle /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default Warehouse; | |||
| @@ -0,0 +1,306 @@ | |||
| "use client"; | |||
| import React, { useState } from "react"; | |||
| import { | |||
| Box, Grid, Paper, Typography, Button, Dialog, DialogTitle, | |||
| DialogContent, DialogActions, TextField, Stack, Table, | |||
| TableBody, TableCell, TableContainer, TableHead, TableRow | |||
| } from "@mui/material"; | |||
| import { FileDownload, Print, SettingsEthernet, Lan, Router } from "@mui/icons-material"; | |||
| import dayjs from "dayjs"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| export default function TestingPage() { | |||
| // --- 1. TSC Section States --- | |||
| const [tscConfig, setTscConfig] = useState({ ip: '192.168.1.100', port: '9100' }); | |||
| const [tscItems, setTscItems] = useState([ | |||
| { id: 1, itemCode: 'FG-001', itemName: 'Yellow Curry Sauce', lotNo: 'LOT-TSC-01', expiryDate: '2025-12-01' }, | |||
| { id: 2, itemCode: 'FG-002', itemName: 'Red Curry Paste', lotNo: 'LOT-TSC-02', expiryDate: '2025-12-05' }, | |||
| ]); | |||
| // --- 2. DataFlex Section States --- | |||
| const [dfConfig, setDfConfig] = useState({ ip: '192.168.1.101', port: '9100' }); | |||
| const [dfItems, setDfItems] = useState([ | |||
| { id: 1, itemCode: 'DF-101', itemName: 'Instant Noodle A', lotNo: 'LOT-DF-01', expiryDate: '2026-01-10' }, | |||
| { id: 2, itemCode: 'DF-102', itemName: 'Instant Noodle B', lotNo: 'LOT-DF-02', expiryDate: '2026-01-15' }, | |||
| ]); | |||
| // --- 3. OnPack Section States --- | |||
| const [isPrinterModalOpen, setIsPrinterModalOpen] = useState(false); | |||
| const [printerFormData, setPrinterFormData] = useState({ | |||
| itemCode: '', | |||
| lotNo: '', | |||
| expiryDate: dayjs().format('YYYY-MM-DD'), | |||
| productName: '' | |||
| }); | |||
| // --- 4. Laser Section States --- | |||
| const [laserConfig, setLaserConfig] = useState({ ip: '192.168.1.102', port: '8080' }); | |||
| const [laserItems, setLaserItems] = useState([ | |||
| { id: 1, templateId: 'JOB_001', lotNo: 'L-LASER-01', expiryDate: '2025-12-31', power: '50' }, | |||
| ]); | |||
| // Generic handler for inline table edits | |||
| const handleItemChange = (setter: any, id: number, field: string, value: string) => { | |||
| setter((prev: any[]) => prev.map(item => | |||
| item.id === id ? { ...item, [field]: value } : item | |||
| )); | |||
| }; | |||
| // --- API CALLS --- | |||
| // TSC Print (Section 1) | |||
| const handleTscPrint = async (row: any) => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| const payload = { ...row, printerIp: tscConfig.ip, printerPort: tscConfig.port }; | |||
| try { | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/print-tsc`, { | |||
| method: 'POST', | |||
| headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify(payload) | |||
| }); | |||
| if (response.ok) alert(`TSC Print Command Sent for ${row.itemCode}!`); | |||
| else alert("TSC Print Failed"); | |||
| } catch (e) { console.error("TSC Error:", e); } | |||
| }; | |||
| // DataFlex Print (Section 2) | |||
| const handleDfPrint = async (row: any) => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| const payload = { ...row, printerIp: dfConfig.ip, printerPort: dfConfig.port }; | |||
| try { | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/print-dataflex`, { | |||
| method: 'POST', | |||
| headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify(payload) | |||
| }); | |||
| if (response.ok) alert(`DataFlex Print Command Sent for ${row.itemCode}!`); | |||
| else alert("DataFlex Print Failed"); | |||
| } catch (e) { console.error("DataFlex Error:", e); } | |||
| }; | |||
| // OnPack Zip Download (Section 3) | |||
| const handleDownloadPrintJob = async () => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| const params = new URLSearchParams(printerFormData); | |||
| try { | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/get-printer6?${params.toString()}`, { | |||
| method: 'GET', | |||
| headers: { 'Authorization': `Bearer ${token}` } | |||
| }); | |||
| if (!response.ok) throw new Error('Download failed'); | |||
| const blob = await response.blob(); | |||
| const url = window.URL.createObjectURL(blob); | |||
| const link = document.createElement('a'); | |||
| link.href = url; | |||
| link.setAttribute('download', `${printerFormData.lotNo || 'OnPack'}.zip`); | |||
| document.body.appendChild(link); | |||
| link.click(); | |||
| link.remove(); | |||
| window.URL.revokeObjectURL(url); | |||
| setIsPrinterModalOpen(false); | |||
| } catch (e) { console.error("OnPack Error:", e); } | |||
| }; | |||
| const handleLaserPrint = async (row: any) => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| const payload = { ...row, printerIp: laserConfig.ip, printerPort: laserConfig.port }; | |||
| try { | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/print-laser`, { | |||
| method: 'POST', | |||
| headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify(payload) | |||
| }); | |||
| if (response.ok) alert(`Laser Command Sent: ${row.templateId}`); | |||
| } catch (e) { console.error(e); } | |||
| }; | |||
| const handleLaserPreview = async (row: any) => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| const payload = { ...row, printerIp: laserConfig.ip, printerPort: parseInt(laserConfig.port) }; | |||
| try { | |||
| // We'll create this endpoint in the backend next | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/preview-laser`, { | |||
| method: 'POST', | |||
| headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify(payload) | |||
| }); | |||
| if (response.ok) alert("Red light preview active!"); | |||
| } catch (e) { console.error("Preview Error:", e); } | |||
| }; | |||
| // Layout Helper | |||
| const Section = ({ title, children }: { title: string, children?: React.ReactNode }) => ( | |||
| <Grid item xs={12} md={6}> | |||
| <Paper sx={{ p: 3, minHeight: '450px', display: 'flex', flexDirection: 'column' }}> | |||
| <Typography variant="h5" gutterBottom color="primary" sx={{ borderBottom: '2px solid #f0f0f0', pb: 1, mb: 2 }}> | |||
| {title} | |||
| </Typography> | |||
| {children || <Typography color="textSecondary" sx={{ m: 'auto' }}>Waiting for implementation...</Typography>} | |||
| </Paper> | |||
| </Grid> | |||
| ); | |||
| return ( | |||
| <Box sx={{ p: 4 }}> | |||
| <Typography variant="h4" sx={{ mb: 4, fontWeight: 'bold' }}>Printer Testing Dashboard</Typography> | |||
| <Grid container spacing={3}> | |||
| {/* 1. TSC Section */} | |||
| <Section title="1. TSC"> | |||
| <Stack direction="row" spacing={2} sx={{ mb: 2 }}> | |||
| <TextField size="small" label="Printer IP" value={tscConfig.ip} onChange={e => setTscConfig({...tscConfig, ip: e.target.value})} /> | |||
| <TextField size="small" label="Port" value={tscConfig.port} onChange={e => setTscConfig({...tscConfig, port: e.target.value})} /> | |||
| <SettingsEthernet color="action" /> | |||
| </Stack> | |||
| <TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 300 }}> | |||
| <Table size="small" stickyHeader> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>Code</TableCell> | |||
| <TableCell>Name</TableCell> | |||
| <TableCell>Lot</TableCell> | |||
| <TableCell>Expiry</TableCell> | |||
| <TableCell align="center">Action</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {tscItems.map(row => ( | |||
| <TableRow key={row.id}> | |||
| <TableCell><TextField variant="standard" value={row.itemCode} onChange={e => handleItemChange(setTscItems, row.id, 'itemCode', e.target.value)} /></TableCell> | |||
| <TableCell><TextField variant="standard" value={row.itemName} onChange={e => handleItemChange(setTscItems, row.id, 'itemName', e.target.value)} /></TableCell> | |||
| <TableCell><TextField variant="standard" value={row.lotNo} onChange={e => handleItemChange(setTscItems, row.id, 'lotNo', e.target.value)} /></TableCell> | |||
| <TableCell><TextField variant="standard" type="date" value={row.expiryDate} onChange={e => handleItemChange(setTscItems, row.id, 'expiryDate', e.target.value)} /></TableCell> | |||
| <TableCell align="center"><Button variant="contained" size="small" startIcon={<Print />} onClick={() => handleTscPrint(row)}>Print</Button></TableCell> | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| </Section> | |||
| {/* 2. DataFlex Section */} | |||
| <Section title="2. DataFlex"> | |||
| <Stack direction="row" spacing={2} sx={{ mb: 2 }}> | |||
| <TextField size="small" label="Printer IP" value={dfConfig.ip} onChange={e => setDfConfig({...dfConfig, ip: e.target.value})} /> | |||
| <TextField size="small" label="Port" value={dfConfig.port} onChange={e => setDfConfig({...dfConfig, port: e.target.value})} /> | |||
| <Lan color="action" /> | |||
| </Stack> | |||
| <TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 300 }}> | |||
| <Table size="small" stickyHeader> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>Code</TableCell> | |||
| <TableCell>Name</TableCell> | |||
| <TableCell>Lot</TableCell> | |||
| <TableCell>Expiry</TableCell> | |||
| <TableCell align="center">Action</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {dfItems.map(row => ( | |||
| <TableRow key={row.id}> | |||
| <TableCell><TextField variant="standard" value={row.itemCode} onChange={e => handleItemChange(setDfItems, row.id, 'itemCode', e.target.value)} /></TableCell> | |||
| <TableCell><TextField variant="standard" value={row.itemName} onChange={e => handleItemChange(setDfItems, row.id, 'itemName', e.target.value)} /></TableCell> | |||
| <TableCell><TextField variant="standard" value={row.lotNo} onChange={e => handleItemChange(setDfItems, row.id, 'lotNo', e.target.value)} /></TableCell> | |||
| <TableCell><TextField variant="standard" type="date" value={row.expiryDate} onChange={e => handleItemChange(setDfItems, row.id, 'expiryDate', e.target.value)} /></TableCell> | |||
| <TableCell align="center"><Button variant="contained" color="secondary" size="small" startIcon={<Print />} onClick={() => handleDfPrint(row)}>Print</Button></TableCell> | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| </Section> | |||
| {/* 3. OnPack Section */} | |||
| <Section title="3. OnPack"> | |||
| <Box sx={{ m: 'auto', textAlign: 'center' }}> | |||
| <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}> | |||
| Calls /plastic/get-printer6 to generate CoLOS .job bundle. | |||
| </Typography> | |||
| <Button variant="contained" color="success" size="large" startIcon={<FileDownload />} onClick={() => setIsPrinterModalOpen(true)}> | |||
| Generate CoLOS Files | |||
| </Button> | |||
| </Box> | |||
| </Section> | |||
| {/* 4. Laser Section (HANS600S-M) */} | |||
| <Section title="4. Laser"> | |||
| <Stack direction="row" spacing={2} sx={{ mb: 2 }}> | |||
| <TextField size="small" label="Laser IP" value={laserConfig.ip} onChange={e => setLaserConfig({...laserConfig, ip: e.target.value})} /> | |||
| <TextField size="small" label="Port" value={laserConfig.port} onChange={e => setLaserConfig({...laserConfig, port: e.target.value})} /> | |||
| <Router color="action" /> | |||
| </Stack> | |||
| <TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 300 }}> | |||
| <Table size="small" stickyHeader> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>Template</TableCell> | |||
| <TableCell>Lot</TableCell> | |||
| <TableCell>Exp</TableCell> | |||
| <TableCell>Pwr%</TableCell> | |||
| <TableCell align="center">Action</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {laserItems.map(row => ( | |||
| <TableRow key={row.id}> | |||
| <TableCell><TextField variant="standard" value={row.templateId} onChange={e => handleItemChange(setLaserItems, row.id, 'templateId', e.target.value)} /></TableCell> | |||
| <TableCell><TextField variant="standard" value={row.lotNo} onChange={e => handleItemChange(setLaserItems, row.id, 'lotNo', e.target.value)} /></TableCell> | |||
| <TableCell><TextField variant="standard" type="date" value={row.expiryDate} onChange={e => handleItemChange(setLaserItems, row.id, 'expiryDate', e.target.value)} /></TableCell> | |||
| <TableCell><TextField variant="standard" value={row.power} sx={{ width: 40 }} onChange={e => handleItemChange(setLaserItems, row.id, 'power', e.target.value)} /></TableCell> | |||
| <TableCell align="center"> | |||
| <Stack direction="row" spacing={1} justifyContent="center"> | |||
| <Button | |||
| variant="outlined" | |||
| color="info" | |||
| size="small" | |||
| onClick={() => handleLaserPreview(row)} | |||
| > | |||
| Preview | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| color="warning" | |||
| size="small" | |||
| startIcon={<Print />} | |||
| onClick={() => handleLaserPrint(row)} | |||
| > | |||
| Mark | |||
| </Button> | |||
| </Stack> | |||
| </TableCell> | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| <Typography variant="caption" sx={{ mt: 2, display: 'block', color: 'text.secondary' }}> | |||
| Note: HANS Laser requires pre-saved templates on the controller. | |||
| </Typography> | |||
| </Section> | |||
| </Grid> | |||
| {/* Dialog for OnPack */} | |||
| <Dialog open={isPrinterModalOpen} onClose={() => setIsPrinterModalOpen(false)} fullWidth maxWidth="sm"> | |||
| <DialogTitle sx={{ bgcolor: 'success.main', color: 'white' }}>OnPack Printer Job Details</DialogTitle> | |||
| <DialogContent sx={{ mt: 2 }}> | |||
| <Stack spacing={3}> | |||
| <TextField label="Item Code" fullWidth value={printerFormData.itemCode} onChange={(e) => setPrinterFormData({ ...printerFormData, itemCode: e.target.value })} /> | |||
| <TextField label="Lot Number" fullWidth value={printerFormData.lotNo} onChange={(e) => setPrinterFormData({ ...printerFormData, lotNo: e.target.value })} /> | |||
| <TextField label="Product Name" fullWidth value={printerFormData.productName} onChange={(e) => setPrinterFormData({ ...printerFormData, productName: e.target.value })} /> | |||
| <TextField label="Expiry Date" type="date" fullWidth InputLabelProps={{ shrink: true }} value={printerFormData.expiryDate} onChange={(e) => setPrinterFormData({ ...printerFormData, expiryDate: e.target.value })} /> | |||
| </Stack> | |||
| </DialogContent> | |||
| <DialogActions sx={{ p: 3 }}> | |||
| <Button onClick={() => setIsPrinterModalOpen(false)} variant="outlined" color="inherit">Cancel</Button> | |||
| <Button variant="contained" color="success" onClick={handleDownloadPrintJob}>Generate & Download</Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -43,6 +43,13 @@ export interface ReleaseProdScheduleReq { | |||
| id: number; | |||
| } | |||
| export interface print6FilesReq { | |||
| itemCode: 'string', | |||
| lotNo: 'string', | |||
| expiryDate: 'string', | |||
| productName: 'string' | |||
| } | |||
| export interface ReleaseProdScheduleResponse { | |||
| id: number; | |||
| code: string; | |||
| @@ -145,6 +152,23 @@ export const testDetailedSchedule = cache(async (date?: string) => { | |||
| ); | |||
| }); | |||
| export const getFile6 = cache(async ( | |||
| token: string | "", | |||
| data: print6FilesReq | |||
| ) => { | |||
| const queryStr = convertObjToURLSearchParams(data) | |||
| return serverFetchJson( | |||
| `${BASE_API_URL}/plastic/get-printer6?${queryStr}`, | |||
| { | |||
| method: "GET", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| "Authorization": `Bearer ${token}` | |||
| }, | |||
| }, | |||
| ); | |||
| }); | |||
| export const releaseProdScheduleLine = cache(async (data: ReleaseProdScheduleInputs) => { | |||
| const response = serverFetchJson<ReleaseProdScheduleResponse>( | |||
| `${BASE_API_URL}/productionSchedule/detail/detailed/releaseLine`, | |||
| @@ -9,6 +9,7 @@ export type ScheduleType = "all" | "rough" | "detailed" | "manual"; | |||
| export interface RoughProdScheduleResult { | |||
| id: number; | |||
| scheduleAt: number[]; | |||
| produceAt: number[]; | |||
| schedulePeriod: number[]; | |||
| schedulePeriodTo: number[]; | |||
| totalEstProdCount: number; | |||
| @@ -80,6 +81,7 @@ export interface RoughProdScheduleLineResultByBomByDate { | |||
| // Detailed | |||
| export interface DetailedProdScheduleResult { | |||
| id: number; | |||
| produceAt: number[]; | |||
| scheduleAt: number[]; | |||
| totalEstProdCount: number; | |||
| totalFGType: number; | |||
| @@ -0,0 +1,33 @@ | |||
| "use client"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import { EquipmentResult } from "./index"; | |||
| export const exportEquipmentQrCode = async (equipmentIds: number[]): Promise<{ blobValue: Uint8Array; filename: string }> => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/Equipment/export-qrcode`, { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| ...(token && { Authorization: `Bearer ${token}` }), | |||
| }, | |||
| body: JSON.stringify({ equipmentIds }), | |||
| }); | |||
| if (!response.ok) { | |||
| if (response.status === 401) { | |||
| throw new Error("Unauthorized: Please log in again"); | |||
| } | |||
| throw new Error(`Failed to export QR code: ${response.status} ${response.statusText}`); | |||
| } | |||
| const filename = response.headers.get("Content-Disposition")?.split("filename=")[1]?.replace(/"/g, "") || "equipment_qrcode.pdf"; | |||
| const blob = await response.blob(); | |||
| const arrayBuffer = await blob.arrayBuffer(); | |||
| const blobValue = new Uint8Array(arrayBuffer); | |||
| return { blobValue, filename }; | |||
| }; | |||
| @@ -0,0 +1,33 @@ | |||
| "use client"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import { EquipmentDetailResult } from "./index"; | |||
| export const exportEquipmentQrCode = async (equipmentDetailIds: number[]): Promise<{ blobValue: Uint8Array; filename: string }> => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/Equipment/export-qrcode`, { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| ...(token && { Authorization: `Bearer ${token}` }), | |||
| }, | |||
| body: JSON.stringify({ equipmentDetailIds }), | |||
| }); | |||
| if (!response.ok) { | |||
| if (response.status === 401) { | |||
| throw new Error("Unauthorized: Please log in again"); | |||
| } | |||
| throw new Error(`Failed to export QR code: ${response.status} ${response.statusText}`); | |||
| } | |||
| const filename = response.headers.get("Content-Disposition")?.split("filename=")[1]?.replace(/"/g, "") || "equipment_qrcode.pdf"; | |||
| const blob = await response.blob(); | |||
| const arrayBuffer = await blob.arrayBuffer(); | |||
| const blobValue = new Uint8Array(arrayBuffer); | |||
| return { blobValue, filename }; | |||
| }; | |||
| @@ -0,0 +1,32 @@ | |||
| import { cache } from "react"; | |||
| import "server-only"; | |||
| import { serverFetchJson } from "../../../utils/fetchUtil"; | |||
| import { BASE_API_URL } from "../../../../config/api"; | |||
| export type EquipmentDetailResult = { | |||
| id: string | number; | |||
| code: string; | |||
| name: string; | |||
| description: string | undefined; | |||
| equipmentCode?: string; | |||
| equipmentTypeId?: string | number | undefined; | |||
| repairAndMaintenanceStatus?: boolean | number; | |||
| latestRepairAndMaintenanceDate?: string | Date; | |||
| lastRepairAndMaintenanceDate?: string | Date; | |||
| repairAndMaintenanceRemarks?: string; | |||
| }; | |||
| export const fetchAllEquipmentDetails = cache(async () => { | |||
| return serverFetchJson<EquipmentDetailResult[]>(`${BASE_API_URL}/EquipmentDetail`, { | |||
| next: { tags: ["equipmentDetails"] }, | |||
| }); | |||
| }); | |||
| export const fetchEquipmentDetail = cache(async (id: number) => { | |||
| return serverFetchJson<EquipmentDetailResult>( | |||
| `${BASE_API_URL}/EquipmentDetail/details/${id}`, | |||
| { | |||
| next: { tags: ["equipmentDetails"] }, | |||
| }, | |||
| ); | |||
| }); | |||
| @@ -46,6 +46,8 @@ export interface Truck{ | |||
| districtReference: Number; | |||
| storeId: Number | String; | |||
| remark?: String | null; | |||
| shopName?: String | null; | |||
| shopCode?: String | null; | |||
| } | |||
| export interface SaveTruckLane { | |||
| @@ -62,9 +64,13 @@ export interface DeleteTruckLane { | |||
| id: number; | |||
| } | |||
| export interface UpdateLoadingSequenceRequest { | |||
| export interface UpdateTruckShopDetailsRequest { | |||
| id: number; | |||
| shopId?: number | null; | |||
| shopName: string | null; | |||
| shopCode: string | null; | |||
| loadingSequence: number; | |||
| remark?: string | null; | |||
| } | |||
| export interface SaveTruckRequest { | |||
| @@ -80,6 +86,15 @@ export interface SaveTruckRequest { | |||
| remark?: string | null; | |||
| } | |||
| export interface CreateTruckWithoutShopRequest { | |||
| store_id: string; | |||
| truckLanceCode: string; | |||
| departureTime: string; | |||
| loadingSequence?: number; | |||
| districtReference?: number | null; | |||
| remark?: string | null; | |||
| } | |||
| export interface MessageResponse { | |||
| id: number | null; | |||
| name: string | null; | |||
| @@ -137,7 +152,7 @@ export const deleteTruckLaneAction = async (data: DeleteTruckLane) => { | |||
| }; | |||
| export const createTruckAction = async (data: SaveTruckRequest) => { | |||
| const endpoint = `${BASE_API_URL}/truck/create`; | |||
| const endpoint = `${BASE_API_URL}/truck/createTruckInShop`; | |||
| return serverFetchJson<MessageResponse>(endpoint, { | |||
| method: "POST", | |||
| @@ -175,12 +190,68 @@ export const findAllShopsByTruckLanceCodeAction = cache(async (truckLanceCode: s | |||
| }); | |||
| }); | |||
| export const updateLoadingSequenceAction = async (data: UpdateLoadingSequenceRequest) => { | |||
| const endpoint = `${BASE_API_URL}/truck/updateLoadingSequence`; | |||
| export const findAllByTruckLanceCodeAndDeletedFalseAction = cache(async (truckLanceCode: string) => { | |||
| const endpoint = `${BASE_API_URL}/truck/findAllByTruckLanceCodeAndDeletedFalse`; | |||
| const url = `${endpoint}?truckLanceCode=${encodeURIComponent(truckLanceCode)}`; | |||
| return serverFetchJson<Truck[]>(url, { | |||
| method: "GET", | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| }); | |||
| export const updateTruckShopDetailsAction = async (data: UpdateTruckShopDetailsRequest) => { | |||
| const endpoint = `${BASE_API_URL}/truck/updateTruckShopDetails`; | |||
| return serverFetchJson<MessageResponse>(endpoint, { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| }; | |||
| export const createTruckWithoutShopAction = async (data: CreateTruckWithoutShopRequest) => { | |||
| const endpoint = `${BASE_API_URL}/truck/createTruckWithoutShop`; | |||
| return serverFetchJson<MessageResponse>(endpoint, { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| }; | |||
| }; | |||
| export const findAllUniqueShopNamesAndCodesFromTrucksAction = cache(async () => { | |||
| const endpoint = `${BASE_API_URL}/truck/findAllUniqueShopNamesAndCodesFromTrucks`; | |||
| return serverFetchJson<Array<{ name: string; code: string }>>(endpoint, { | |||
| method: "GET", | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| }); | |||
| export const findAllUniqueRemarksFromTrucksAction = cache(async () => { | |||
| const endpoint = `${BASE_API_URL}/truck/findAllUniqueRemarksFromTrucks`; | |||
| return serverFetchJson<string[]>(endpoint, { | |||
| method: "GET", | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| }); | |||
| export const findAllUniqueShopCodesFromTrucksAction = cache(async () => { | |||
| const endpoint = `${BASE_API_URL}/truck/findAllUniqueShopCodesFromTrucks`; | |||
| return serverFetchJson<string[]>(endpoint, { | |||
| method: "GET", | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| }); | |||
| export const findAllUniqueShopNamesFromTrucksAction = cache(async () => { | |||
| const endpoint = `${BASE_API_URL}/truck/findAllUniqueShopNamesFromTrucks`; | |||
| return serverFetchJson<string[]>(endpoint, { | |||
| method: "GET", | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| }); | |||
| @@ -9,11 +9,18 @@ import { | |||
| findAllUniqueTruckLaneCombinationsAction, | |||
| findAllShopsByTruckLanceCodeAndRemarkAction, | |||
| findAllShopsByTruckLanceCodeAction, | |||
| updateLoadingSequenceAction, | |||
| createTruckWithoutShopAction, | |||
| updateTruckShopDetailsAction, | |||
| findAllUniqueShopNamesAndCodesFromTrucksAction, | |||
| findAllUniqueRemarksFromTrucksAction, | |||
| findAllUniqueShopCodesFromTrucksAction, | |||
| findAllUniqueShopNamesFromTrucksAction, | |||
| findAllByTruckLanceCodeAndDeletedFalseAction, | |||
| type SaveTruckLane, | |||
| type DeleteTruckLane, | |||
| type SaveTruckRequest, | |||
| type UpdateLoadingSequenceRequest, | |||
| type UpdateTruckShopDetailsRequest, | |||
| type CreateTruckWithoutShopRequest, | |||
| type MessageResponse | |||
| } from "./actions"; | |||
| @@ -49,8 +56,32 @@ export const findAllShopsByTruckLanceCodeClient = async (truckLanceCode: string) | |||
| return await findAllShopsByTruckLanceCodeAction(truckLanceCode); | |||
| }; | |||
| export const updateLoadingSequenceClient = async (data: UpdateLoadingSequenceRequest): Promise<MessageResponse> => { | |||
| return await updateLoadingSequenceAction(data); | |||
| export const findAllByTruckLanceCodeAndDeletedFalseClient = async (truckLanceCode: string) => { | |||
| return await findAllByTruckLanceCodeAndDeletedFalseAction(truckLanceCode); | |||
| }; | |||
| export const updateTruckShopDetailsClient = async (data: UpdateTruckShopDetailsRequest): Promise<MessageResponse> => { | |||
| return await updateTruckShopDetailsAction(data); | |||
| }; | |||
| export const createTruckWithoutShopClient = async (data: CreateTruckWithoutShopRequest): Promise<MessageResponse> => { | |||
| return await createTruckWithoutShopAction(data); | |||
| }; | |||
| export const findAllUniqueShopNamesAndCodesFromTrucksClient = async () => { | |||
| return await findAllUniqueShopNamesAndCodesFromTrucksAction(); | |||
| }; | |||
| export const findAllUniqueRemarksFromTrucksClient = async () => { | |||
| return await findAllUniqueRemarksFromTrucksAction(); | |||
| }; | |||
| export const findAllUniqueShopCodesFromTrucksClient = async () => { | |||
| return await findAllUniqueShopCodesFromTrucksAction(); | |||
| }; | |||
| export const findAllUniqueShopNamesFromTrucksClient = async () => { | |||
| return await findAllUniqueShopNamesFromTrucksAction(); | |||
| }; | |||
| export default fetchAllShopsClient; | |||
| @@ -1,7 +1,63 @@ | |||
| "use server"; | |||
| import { serverFetchString } from "@/app/utils/fetchUtil"; | |||
| import { serverFetchString, serverFetchWithNoContent, serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { revalidateTag } from "next/cache"; | |||
| import { WarehouseResult } from "./index"; | |||
| import { cache } from "react"; | |||
| export interface WarehouseInputs { | |||
| code?: string; | |||
| name?: string; | |||
| description?: string; | |||
| capacity?: number; | |||
| store_id?: string; | |||
| warehouse?: string; | |||
| area?: string; | |||
| slot?: string; | |||
| stockTakeSection?: string; | |||
| } | |||
| export const fetchWarehouseDetail = cache(async (id: number) => { | |||
| return serverFetchJson<WarehouseResult>(`${BASE_API_URL}/warehouse/${id}`, { | |||
| next: { tags: ["warehouse"] }, | |||
| }); | |||
| }); | |||
| export const createWarehouse = async (data: WarehouseInputs) => { | |||
| const newWarehouse = await serverFetchWithNoContent(`${BASE_API_URL}/warehouse/save`, { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| revalidateTag("warehouse"); | |||
| return newWarehouse; | |||
| }; | |||
| export const editWarehouse = async (id: number, data: WarehouseInputs) => { | |||
| const updatedWarehouse = await serverFetchWithNoContent(`${BASE_API_URL}/warehouse/${id}`, { | |||
| method: "PUT", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| revalidateTag("warehouse"); | |||
| return updatedWarehouse; | |||
| }; | |||
| export const deleteWarehouse = async (id: number) => { | |||
| try { | |||
| const result = await serverFetchJson<WarehouseResult[]>(`${BASE_API_URL}/warehouse/${id}`, { | |||
| method: "DELETE", | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| revalidateTag("warehouse"); | |||
| return result; | |||
| } catch (error) { | |||
| console.error("Error deleting warehouse:", error); | |||
| revalidateTag("warehouse"); | |||
| throw error; | |||
| } | |||
| }; | |||
| export const importWarehouse = async (data: FormData) => { | |||
| const importWarehouse = await serverFetchString<string>( | |||
| @@ -4,10 +4,17 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| export interface WarehouseResult { | |||
| action: any; | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| description: string; | |||
| store_id?: string; | |||
| warehouse?: string; | |||
| area?: string; | |||
| slot?: string; | |||
| order?: number; | |||
| stockTakeSection?: string; | |||
| } | |||
| export interface WarehouseCombo { | |||
| @@ -151,3 +151,45 @@ export const calculateWeight = (qty: number, uom: Uom) => { | |||
| export const returnWeightUnit = (uom: Uom) => { | |||
| return uom.unit4 || uom.unit3 || uom.unit2 || uom.unit1; | |||
| }; | |||
| /** | |||
| * Formats departure time to HH:mm format | |||
| * Handles array format [hours, minutes] from API and string formats | |||
| */ | |||
| export const formatDepartureTime = (time: string | number[] | String | Number | null | undefined): string => { | |||
| if (!time) return "-"; | |||
| // Handle array format [hours, minutes] from API | |||
| if (Array.isArray(time) && time.length >= 2) { | |||
| const hours = time[0]; | |||
| const minutes = time[1]; | |||
| if (typeof hours === 'number' && typeof minutes === 'number' && | |||
| hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) { | |||
| return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; | |||
| } | |||
| } | |||
| const timeStr = String(time).trim(); | |||
| if (!timeStr || timeStr === "-") return "-"; | |||
| // If already in HH:mm format, return as is | |||
| if (/^\d{1,2}:\d{2}$/.test(timeStr)) { | |||
| const [hours, minutes] = timeStr.split(":"); | |||
| return `${hours.padStart(2, "0")}:${minutes.padStart(2, "0")}`; | |||
| } | |||
| return timeStr; | |||
| }; | |||
| /** | |||
| * Normalizes store ID to display format (2F or 4F) | |||
| */ | |||
| export const normalizeStoreId = (storeId: string | number | String | Number | null | undefined): string => { | |||
| if (!storeId) return "-"; | |||
| const storeIdStr = typeof storeId === 'string' || storeId instanceof String | |||
| ? String(storeId) | |||
| : String(storeId); | |||
| if (storeIdStr === "2" || storeIdStr === "2F") return "2F"; | |||
| if (storeIdStr === "4" || storeIdStr === "4F") return "4F"; | |||
| return storeIdStr; | |||
| }; | |||
| @@ -0,0 +1,14 @@ | |||
| export const [VIEW_USER,MAINTAIN_USER, VIEW_GROUP, MAINTAIN_GROUP, | |||
| TESTING, PROD, PACK, ADMIN, STOCK, Driver] = [ | |||
| "VIEW_USER", | |||
| "MAINTAIN_USER", | |||
| "VIEW_GROUP", | |||
| "MAINTAIN_GROUP", | |||
| //below auth act as role | |||
| "TESTING", | |||
| "PROD", | |||
| "PACK", | |||
| "ADMIN", | |||
| "STOCK", | |||
| "Driver", | |||
| ]; | |||
| @@ -1,6 +0,0 @@ | |||
| export const [VIEW_USER, MAINTAIN_USER, VIEW_GROUP, MAINTAIN_GROUP] = [ | |||
| "VIEW_USER", | |||
| "MAINTAIN_USER", | |||
| "VIEW_GROUP", | |||
| "MAINTAIN_GROUP", | |||
| ]; | |||
| @@ -17,6 +17,7 @@ const pathToLabelMap: { [path: string]: string } = { | |||
| "/settings/qrCodeHandle": "QR Code Handle", | |||
| "/settings/rss": "Demand Forecast Setting", | |||
| "/settings/equipment": "Equipment", | |||
| "/settings/equipment/MaintenanceEdit": "MaintenanceEdit", | |||
| "/settings/shop": "ShopAndTruck", | |||
| "/settings/shop/detail": "Shop Detail", | |||
| "/settings/shop/truckdetail": "Truck Lane Detail", | |||
| @@ -0,0 +1,148 @@ | |||
| "use client"; | |||
| import { useRouter } from "next/navigation"; | |||
| import React, { | |||
| useCallback, | |||
| useEffect, | |||
| useState, | |||
| } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { | |||
| Button, | |||
| Stack, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { | |||
| FormProvider, | |||
| SubmitErrorHandler, | |||
| SubmitHandler, | |||
| useForm, | |||
| } from "react-hook-form"; | |||
| import { Check, Close, RestartAlt } from "@mui/icons-material"; | |||
| import { | |||
| WarehouseInputs, | |||
| createWarehouse, | |||
| } from "@/app/api/warehouse/actions"; | |||
| import WarehouseDetail from "./WarehouseDetail"; | |||
| const CreateWarehouse: React.FC = () => { | |||
| const { t } = useTranslation(["warehouse", "common"]); | |||
| const formProps = useForm<WarehouseInputs>(); | |||
| const router = useRouter(); | |||
| const [serverError, setServerError] = useState(""); | |||
| const resetForm = React.useCallback((e?: React.MouseEvent<HTMLButtonElement>) => { | |||
| e?.preventDefault(); | |||
| e?.stopPropagation(); | |||
| try { | |||
| formProps.reset({ | |||
| store_id: "", | |||
| warehouse: "", | |||
| area: "", | |||
| slot: "", | |||
| stockTakeSection: "", | |||
| }); | |||
| } catch (error) { | |||
| console.log(error); | |||
| setServerError(t("An error has occurred. Please try again later.")); | |||
| } | |||
| }, [formProps, t]); | |||
| useEffect(() => { | |||
| resetForm(); | |||
| }, []); | |||
| const handleCancel = () => { | |||
| router.back(); | |||
| }; | |||
| const onSubmit = useCallback<SubmitHandler<WarehouseInputs>>( | |||
| async (data) => { | |||
| try { | |||
| // Automatically append "F" to store_id if not already present | |||
| // Remove any existing "F" to avoid duplication, then append it | |||
| const cleanStoreId = (data.store_id || "").replace(/F$/i, "").trim(); | |||
| const storeIdWithF = cleanStoreId ? `${cleanStoreId}F` : ""; | |||
| // Generate code, name, description from the input fields | |||
| // Format: store_idF-warehouse-area-slot (F is automatically appended) | |||
| const code = storeIdWithF | |||
| ? `${storeIdWithF}-${data.warehouse || ""}-${data.area || ""}-${data.slot || ""}` | |||
| : `${data.warehouse || ""}-${data.area || ""}-${data.slot || ""}`; | |||
| const name = storeIdWithF | |||
| ? `${storeIdWithF}-${data.warehouse || ""}` | |||
| : `${data.warehouse || ""}`; | |||
| const description = storeIdWithF | |||
| ? `${storeIdWithF}-${data.warehouse || ""}` | |||
| : `${data.warehouse || ""}`; | |||
| const warehouseData: WarehouseInputs = { | |||
| ...data, | |||
| store_id: storeIdWithF, // Save with F (F is automatically appended) | |||
| code: code.trim(), | |||
| name: name.trim(), | |||
| description: description.trim(), | |||
| capacity: 10000, // Default capacity | |||
| }; | |||
| await createWarehouse(warehouseData); | |||
| router.replace("/settings/warehouse"); | |||
| } catch (e) { | |||
| console.log(e); | |||
| setServerError(t("An error has occurred. Please try again later.")); | |||
| } | |||
| }, | |||
| [router, t], | |||
| ); | |||
| const onSubmitError = useCallback<SubmitErrorHandler<WarehouseInputs>>( | |||
| (errors) => { | |||
| console.log(errors); | |||
| }, | |||
| [], | |||
| ); | |||
| return ( | |||
| <> | |||
| {serverError && ( | |||
| <Typography variant="body2" color="error" alignSelf="flex-end"> | |||
| {serverError} | |||
| </Typography> | |||
| )} | |||
| <FormProvider {...formProps}> | |||
| <Stack | |||
| spacing={2} | |||
| component="form" | |||
| onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} | |||
| > | |||
| <WarehouseDetail /> | |||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | |||
| <Button | |||
| variant="text" | |||
| startIcon={<RestartAlt />} | |||
| onClick={(e) => { | |||
| e.preventDefault(); | |||
| e.stopPropagation(); | |||
| resetForm(e); | |||
| }} | |||
| type="button" | |||
| > | |||
| {t("Reset")} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<Close />} | |||
| onClick={handleCancel} | |||
| type="button" | |||
| > | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button variant="contained" startIcon={<Check />} type="submit"> | |||
| {t("Confirm")} | |||
| </Button> | |||
| </Stack> | |||
| </Stack> | |||
| </FormProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default CreateWarehouse; | |||
| @@ -0,0 +1,29 @@ | |||
| import Card from "@mui/material/Card"; | |||
| import CardContent from "@mui/material/CardContent"; | |||
| import Skeleton from "@mui/material/Skeleton"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import React from "react"; | |||
| export const CreateWarehouseLoading: React.FC = () => { | |||
| return ( | |||
| <> | |||
| <Card> | |||
| <CardContent> | |||
| <Stack spacing={2}> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton | |||
| variant="rounded" | |||
| height={50} | |||
| width={100} | |||
| sx={{ alignSelf: "flex-end" }} | |||
| /> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| </> | |||
| ); | |||
| }; | |||
| export default CreateWarehouseLoading; | |||
| @@ -0,0 +1,15 @@ | |||
| import React from "react"; | |||
| import CreateWarehouse from "./CreateWarehouse"; | |||
| import CreateWarehouseLoading from "./CreateWarehouseLoading"; | |||
| interface SubComponents { | |||
| Loading: typeof CreateWarehouseLoading; | |||
| } | |||
| const CreateWarehouseWrapper: React.FC & SubComponents = async () => { | |||
| return <CreateWarehouse />; | |||
| }; | |||
| CreateWarehouseWrapper.Loading = CreateWarehouseLoading; | |||
| export default CreateWarehouseWrapper; | |||
| @@ -0,0 +1,139 @@ | |||
| "use client"; | |||
| import { | |||
| Card, | |||
| CardContent, | |||
| Stack, | |||
| TextField, | |||
| Typography, | |||
| Box, | |||
| InputAdornment, | |||
| } from "@mui/material"; | |||
| import { useFormContext, Controller } from "react-hook-form"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { WarehouseInputs } from "@/app/api/warehouse/actions"; | |||
| const WarehouseDetail: React.FC = () => { | |||
| const { t } = useTranslation("warehouse"); | |||
| const { | |||
| register, | |||
| control, | |||
| formState: { errors }, | |||
| } = useFormContext<WarehouseInputs>(); | |||
| return ( | |||
| <Card> | |||
| <CardContent component={Stack} spacing={4}> | |||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||
| {t("Warehouse Detail")} | |||
| </Typography> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| alignItems: "flex-start", | |||
| gap: 1, | |||
| flexWrap: "nowrap", | |||
| justifyContent: "flex-start", | |||
| }} | |||
| > | |||
| {/* 樓層 field with F inside on the right - F is automatically generated */} | |||
| <Controller | |||
| name="store_id" | |||
| control={control} | |||
| rules={{ required: t("store_id") + " " + t("is required") }} | |||
| render={({ field }) => ( | |||
| <TextField | |||
| {...field} | |||
| label={t("store_id")} | |||
| size="small" | |||
| sx={{ width: "150px", minWidth: "120px" }} | |||
| InputProps={{ | |||
| endAdornment: ( | |||
| <InputAdornment position="end">F</InputAdornment> | |||
| ), | |||
| }} | |||
| onChange={(e) => { | |||
| // Automatically remove "F" if user tries to type it (F is auto-generated) | |||
| const value = e.target.value.replace(/F/gi, "").trim(); | |||
| field.onChange(value); | |||
| }} | |||
| error={Boolean(errors.store_id)} | |||
| helperText={errors.store_id?.message} | |||
| /> | |||
| )} | |||
| /> | |||
| <Typography variant="body1" sx={{ mx: 0.5, mt: 1.5 }}> | |||
| - | |||
| </Typography> | |||
| {/* 倉庫 field */} | |||
| <Controller | |||
| name="warehouse" | |||
| control={control} | |||
| rules={{ required: t("warehouse") + " " + t("is required") }} | |||
| render={({ field }) => ( | |||
| <TextField | |||
| {...field} | |||
| label={t("warehouse")} | |||
| size="small" | |||
| sx={{ width: "150px", minWidth: "120px" }} | |||
| error={Boolean(errors.warehouse)} | |||
| helperText={errors.warehouse?.message} | |||
| /> | |||
| )} | |||
| /> | |||
| <Typography variant="body1" sx={{ mx: 0.5, mt: 1.5 }}> | |||
| - | |||
| </Typography> | |||
| {/* 區域 field */} | |||
| <Controller | |||
| name="area" | |||
| control={control} | |||
| rules={{ required: t("area") + " " + t("is required") }} | |||
| render={({ field }) => ( | |||
| <TextField | |||
| {...field} | |||
| label={t("area")} | |||
| size="small" | |||
| sx={{ width: "150px", minWidth: "120px" }} | |||
| error={Boolean(errors.area)} | |||
| helperText={errors.area?.message} | |||
| /> | |||
| )} | |||
| /> | |||
| <Typography variant="body1" sx={{ mx: 0.5, mt: 1.5 }}> | |||
| - | |||
| </Typography> | |||
| {/* 儲位 field */} | |||
| <Controller | |||
| name="slot" | |||
| control={control} | |||
| rules={{ required: t("slot") + " " + t("is required") }} | |||
| render={({ field }) => ( | |||
| <TextField | |||
| {...field} | |||
| label={t("slot")} | |||
| size="small" | |||
| sx={{ width: "150px", minWidth: "120px" }} | |||
| error={Boolean(errors.slot)} | |||
| helperText={errors.slot?.message} | |||
| /> | |||
| )} | |||
| /> | |||
| {/* stockTakeSection field in the same row */} | |||
| <Box sx={{ flex: 1, minWidth: "150px", ml: 2 }}> | |||
| <TextField | |||
| label={t("stockTakeSection")} | |||
| fullWidth | |||
| size="small" | |||
| {...register("stockTakeSection")} | |||
| error={Boolean(errors.stockTakeSection)} | |||
| helperText={errors.stockTakeSection?.message} | |||
| /> | |||
| </Box> | |||
| </Box> | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| }; | |||
| export default WarehouseDetail; | |||
| @@ -0,0 +1 @@ | |||
| export { default } from "./CreateWarehouseWrapper"; | |||
| @@ -7,6 +7,7 @@ import { EditNote } from "@mui/icons-material"; | |||
| import { useRouter, useSearchParams } from "next/navigation"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { ScheduleType } from "@/app/api/scheduling"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import { | |||
| ProdScheduleResult, | |||
| SearchProdSchedule, | |||
| @@ -14,6 +15,7 @@ import { | |||
| fetchProdSchedules, | |||
| exportProdSchedule, | |||
| testDetailedSchedule, | |||
| getFile6, | |||
| } from "@/app/api/scheduling/actions"; | |||
| import { defaultPagingController } from "../SearchResults/SearchResults"; | |||
| import { arrayToDateString, arrayToDayjs, decimalFormatter } from "@/app/utils/formatUtil"; | |||
| @@ -23,6 +25,9 @@ import { Button, Stack } from "@mui/material"; | |||
| import isToday from 'dayjs/plugin/isToday'; | |||
| import useUploadContext from "../UploadProvider/useUploadContext"; | |||
| import { FileDownload, CalendarMonth } from "@mui/icons-material"; | |||
| import { useSession } from "next-auth/react"; | |||
| import { VIEW_USER } from "@/authorities"; | |||
| dayjs.extend(isToday); | |||
| // may need move to "index" or "actions" | |||
| @@ -52,6 +57,10 @@ const DSOverview: React.FC<Props> = ({ type, defaultInputs }) => { | |||
| const { setIsUploading } = useUploadContext(); | |||
| const today = dayjs().format("YYYY-MM-DD"); | |||
| const { data: session } = useSession(); | |||
| // Extract abilities (safe fallback to empty array if not logged in / no abilities) | |||
| const abilities = session?.user?.abilities ?? []; | |||
| const router = useRouter(); | |||
| // const [filterObj, setFilterObj] = useState({}); | |||
| // const [tempSelectedValue, setTempSelectedValue] = useState({}); | |||
| @@ -226,6 +235,48 @@ const DSOverview: React.FC<Props> = ({ type, defaultInputs }) => { | |||
| refetchData(resetWithToday, "reset"); // Fetch data | |||
| }, [defaultInputs, refetchData]); | |||
| const handleDownloadPrintJob = async () => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| const params = { | |||
| itemCode: 'TT173', | |||
| lotNo: 'LOT342989', | |||
| expiryDate: '2026-02-28', | |||
| productName: 'Name2342' | |||
| }; | |||
| try { | |||
| // 1. Direct fetch call to avoid Next.js trying to parse JSON | |||
| const query = new URLSearchParams(params).toString(); | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/get-printer6?${query}`, { | |||
| method: 'GET', | |||
| headers: { | |||
| 'Authorization': `Bearer ${token}` | |||
| } | |||
| }); | |||
| if (!response.ok) throw new Error('Network response was not ok'); | |||
| // 2. GET THE DATA AS BLOB (This is the fix) | |||
| const blob = await response.blob(); | |||
| // 3. Create a download link | |||
| const url = window.URL.createObjectURL(blob); | |||
| const link = document.createElement('a'); | |||
| link.href = url; | |||
| link.setAttribute('download', `${params.lotNo}.zip`); | |||
| document.body.appendChild(link); | |||
| link.click(); | |||
| // Cleanup | |||
| link.parentNode?.removeChild(link); | |||
| window.URL.revokeObjectURL(url); | |||
| } catch (error) { | |||
| console.error("Download failed", error); | |||
| } | |||
| }; | |||
| const testDetailedScheduleClick = useCallback(async () => { | |||
| try { | |||
| setIsUploading(true) | |||
| @@ -332,6 +383,21 @@ const DSOverview: React.FC<Props> = ({ type, defaultInputs }) => { | |||
| > | |||
| {t("Export Schedule")} | |||
| </Button> | |||
| {false && abilities.includes(VIEW_USER) && ( | |||
| <Button | |||
| variant="contained" // Solid button for the "Export" action | |||
| color="success" // Green color often signifies a successful action/download | |||
| startIcon={<FileDownload />} | |||
| onClick={handleDownloadPrintJob} | |||
| sx={{ | |||
| boxShadow: 2, | |||
| '&:hover': { backgroundColor: 'success.dark', boxShadow: 4 } | |||
| }} | |||
| > | |||
| Get Printer File API | |||
| </Button> | |||
| )} | |||
| </Stack> | |||
| <SearchBox | |||
| criteria={searchCriteria} | |||
| @@ -1,20 +1,36 @@ | |||
| "use client"; | |||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { EquipmentResult } from "@/app/api/settings/equipment"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import EquipmentSearchResults, { Column } from "./EquipmentSearchResults"; | |||
| import { EditNote } from "@mui/icons-material"; | |||
| import { useRouter, useSearchParams } from "next/navigation"; | |||
| import { GridDeleteIcon } from "@mui/x-data-grid"; | |||
| import { TypeEnum } from "@/app/utils/typeEnum"; | |||
| import axios from "axios"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { BASE_API_URL, NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import axiosInstance from "@/app/(main)/axios/axiosInstance"; | |||
| import { arrayToDateTimeString } from "@/app/utils/formatUtil"; | |||
| import Box from "@mui/material/Box"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import IconButton from "@mui/material/IconButton"; | |||
| import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; | |||
| import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; | |||
| import CircularProgress from "@mui/material/CircularProgress"; | |||
| import TableRow from "@mui/material/TableRow"; | |||
| import TableCell from "@mui/material/TableCell"; | |||
| import Collapse from "@mui/material/Collapse"; | |||
| import Grid from "@mui/material/Grid"; | |||
| import DeleteIcon from "@mui/icons-material/Delete"; | |||
| import AddIcon from "@mui/icons-material/Add"; | |||
| import Button from "@mui/material/Button"; | |||
| import Dialog from "@mui/material/Dialog"; | |||
| import DialogTitle from "@mui/material/DialogTitle"; | |||
| import DialogContent from "@mui/material/DialogContent"; | |||
| import DialogContentText from "@mui/material/DialogContentText"; | |||
| import DialogActions from "@mui/material/DialogActions"; | |||
| import TextField from "@mui/material/TextField"; | |||
| import Autocomplete from "@mui/material/Autocomplete"; | |||
| import InputAdornment from "@mui/material/InputAdornment"; | |||
| type Props = { | |||
| equipments: EquipmentResult[]; | |||
| @@ -28,14 +44,39 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||
| useState<EquipmentResult[]>([]); | |||
| const { t } = useTranslation("common"); | |||
| const router = useRouter(); | |||
| const [filterObj, setFilterObj] = useState({}); | |||
| const [pagingController, setPagingController] = useState({ | |||
| pageNum: 1, | |||
| pageSize: 10, | |||
| const [filterObjByTab, setFilterObjByTab] = useState<Record<number, SearchQuery>>({ | |||
| 0: {}, | |||
| 1: {}, | |||
| }); | |||
| const [pagingControllerByTab, setPagingControllerByTab] = useState<Record<number, { pageNum: number; pageSize: number }>>({ | |||
| 0: { pageNum: 1, pageSize: 10 }, | |||
| 1: { pageNum: 1, pageSize: 10 }, | |||
| }); | |||
| const [totalCount, setTotalCount] = useState(0); | |||
| const [isLoading, setIsLoading] = useState(true); | |||
| const [isReady, setIsReady] = useState(false); | |||
| const filterObj = filterObjByTab[tabIndex] || {}; | |||
| const pagingController = pagingControllerByTab[tabIndex] || { pageNum: 1, pageSize: 10 }; | |||
| const [expandedRows, setExpandedRows] = useState<Set<string | number>>(new Set()); | |||
| const [equipmentDetailsMap, setEquipmentDetailsMap] = useState<Map<string | number, EquipmentResult[]>>(new Map()); | |||
| const [loadingDetailsMap, setLoadingDetailsMap] = useState<Map<string | number, boolean>>(new Map()); | |||
| const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); | |||
| const [itemToDelete, setItemToDelete] = useState<{ id: string | number; equipmentId: string | number } | null>(null); | |||
| const [deleting, setDeleting] = useState(false); | |||
| const [addDialogOpen, setAddDialogOpen] = useState(false); | |||
| const [equipmentList, setEquipmentList] = useState<EquipmentResult[]>([]); | |||
| const [selectedDescription, setSelectedDescription] = useState<string>(""); | |||
| const [selectedName, setSelectedName] = useState<string>(""); | |||
| const [selectedEquipmentCode, setSelectedEquipmentCode] = useState<string>(""); | |||
| const [equipmentCodePrefix, setEquipmentCodePrefix] = useState<string>(""); | |||
| const [equipmentCodeNumber, setEquipmentCodeNumber] = useState<string>(""); | |||
| const [isExistingCombination, setIsExistingCombination] = useState(false); | |||
| const [loadingEquipments, setLoadingEquipments] = useState(false); | |||
| const [saving, setSaving] = useState(false); | |||
| useEffect(() => { | |||
| const checkReady = () => { | |||
| @@ -90,20 +131,12 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||
| }, | |||
| ]; | |||
| } | |||
| return [ | |||
| { label: t("Code"), paramName: "code", type: "text" }, | |||
| { label: t("Description"), paramName: "description", type: "text" }, | |||
| { label: "設備編號", paramName: "code", type: "text" }, | |||
| ]; | |||
| }, [t, tabIndex]); | |||
| const onDetailClick = useCallback( | |||
| (equipment: EquipmentResult) => { | |||
| router.push(`/settings/equipment/edit?id=${equipment.id}`); | |||
| }, | |||
| [router], | |||
| ); | |||
| const onMaintenanceEditClick = useCallback( | |||
| (equipment: EquipmentResult) => { | |||
| router.push(`/settings/equipment/MaintenanceEdit?id=${equipment.id}`); | |||
| @@ -116,34 +149,292 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||
| [router], | |||
| ); | |||
| const fetchEquipmentDetailsByEquipmentId = useCallback(async (equipmentId: string | number) => { | |||
| setLoadingDetailsMap(prev => new Map(prev).set(equipmentId, true)); | |||
| try { | |||
| const response = await axiosInstance.get<{ | |||
| records: EquipmentResult[]; | |||
| total: number; | |||
| }>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/byEquipmentId/${equipmentId}`); | |||
| if (response.status === 200) { | |||
| setEquipmentDetailsMap(prev => new Map(prev).set(equipmentId, response.data.records || [])); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error fetching equipment details:", error); | |||
| setEquipmentDetailsMap(prev => new Map(prev).set(equipmentId, [])); | |||
| } finally { | |||
| setLoadingDetailsMap(prev => new Map(prev).set(equipmentId, false)); | |||
| } | |||
| }, []); | |||
| const handleDeleteClick = useCallback((detailId: string | number, equipmentId: string | number) => { | |||
| setItemToDelete({ id: detailId, equipmentId }); | |||
| setDeleteDialogOpen(true); | |||
| }, []); | |||
| const handleDeleteConfirm = useCallback(async () => { | |||
| if (!itemToDelete) return; | |||
| setDeleting(true); | |||
| try { | |||
| const response = await axiosInstance.delete( | |||
| `${NEXT_PUBLIC_API_URL}/EquipmentDetail/delete/${itemToDelete.id}` | |||
| ); | |||
| if (response.status === 200 || response.status === 204) { | |||
| setEquipmentDetailsMap(prev => { | |||
| const newMap = new Map(prev); | |||
| const currentDetails = newMap.get(itemToDelete.equipmentId) || []; | |||
| const updatedDetails = currentDetails.filter(detail => detail.id !== itemToDelete.id); | |||
| newMap.set(itemToDelete.equipmentId, updatedDetails); | |||
| return newMap; | |||
| }); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error deleting equipment detail:", error); | |||
| alert("刪除失敗,請稍後再試"); | |||
| } finally { | |||
| setDeleting(false); | |||
| setDeleteDialogOpen(false); | |||
| setItemToDelete(null); | |||
| } | |||
| }, [itemToDelete]); | |||
| const handleDeleteCancel = useCallback(() => { | |||
| setDeleteDialogOpen(false); | |||
| setItemToDelete(null); | |||
| }, []); | |||
| const fetchEquipmentList = useCallback(async () => { | |||
| setLoadingEquipments(true); | |||
| try { | |||
| const response = await axiosInstance.get<{ | |||
| records: EquipmentResult[]; | |||
| total: number; | |||
| }>(`${NEXT_PUBLIC_API_URL}/Equipment/getRecordByPage`, { | |||
| params: { | |||
| pageNum: 1, | |||
| pageSize: 1000, | |||
| }, | |||
| }); | |||
| if (response.status === 200) { | |||
| setEquipmentList(response.data.records || []); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error fetching equipment list:", error); | |||
| setEquipmentList([]); | |||
| } finally { | |||
| setLoadingEquipments(false); | |||
| } | |||
| }, []); | |||
| const handleAddClick = useCallback(() => { | |||
| setAddDialogOpen(true); | |||
| fetchEquipmentList(); | |||
| }, [fetchEquipmentList]); | |||
| const handleAddDialogClose = useCallback(() => { | |||
| setAddDialogOpen(false); | |||
| setSelectedDescription(""); | |||
| setSelectedName(""); | |||
| setSelectedEquipmentCode(""); | |||
| setEquipmentCodePrefix(""); | |||
| setEquipmentCodeNumber(""); | |||
| setIsExistingCombination(false); | |||
| }, []); | |||
| const availableDescriptions = useMemo(() => { | |||
| const descriptions = equipmentList | |||
| .map((eq) => eq.description) | |||
| .filter((desc): desc is string => Boolean(desc)); | |||
| return Array.from(new Set(descriptions)); | |||
| }, [equipmentList]); | |||
| const availableNames = useMemo(() => { | |||
| const names = equipmentList | |||
| .map((eq) => eq.name) | |||
| .filter((name): name is string => Boolean(name)); | |||
| return Array.from(new Set(names)); | |||
| }, [equipmentList]); | |||
| useEffect(() => { | |||
| const checkAndGenerateEquipmentCode = async () => { | |||
| if (!selectedDescription || !selectedName) { | |||
| setIsExistingCombination(false); | |||
| setSelectedEquipmentCode(""); | |||
| return; | |||
| } | |||
| const equipmentCode = `${selectedDescription}-${selectedName}`; | |||
| const existingEquipment = equipmentList.find((eq) => eq.code === equipmentCode); | |||
| if (existingEquipment) { | |||
| setIsExistingCombination(true); | |||
| try { | |||
| const existingDetailsResponse = await axiosInstance.get<{ | |||
| records: EquipmentResult[]; | |||
| total: number; | |||
| }>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/byDescriptionIncludingDeleted/${encodeURIComponent(equipmentCode)}`); | |||
| let newEquipmentCode = ""; | |||
| if (existingDetailsResponse.data.records && existingDetailsResponse.data.records.length > 0) { | |||
| const equipmentCodePatterns = existingDetailsResponse.data.records | |||
| .map((detail) => { | |||
| if (!detail.equipmentCode) return null; | |||
| const match = detail.equipmentCode.match(/^([A-Za-z]+)(\d+)$/); | |||
| if (match) { | |||
| const originalNumber = match[2]; | |||
| return { | |||
| prefix: match[1], | |||
| number: parseInt(match[2], 10), | |||
| paddingLength: originalNumber.length | |||
| }; | |||
| } | |||
| return null; | |||
| }) | |||
| .filter((pattern): pattern is { prefix: string; number: number; paddingLength: number } => pattern !== null); | |||
| if (equipmentCodePatterns.length > 0) { | |||
| const prefix = equipmentCodePatterns[0].prefix; | |||
| const maxEquipmentCodeNumber = Math.max(...equipmentCodePatterns.map(p => p.number)); | |||
| const maxPaddingLength = Math.max(...equipmentCodePatterns.map(p => p.paddingLength)); | |||
| const nextNumber = maxEquipmentCodeNumber + 1; | |||
| newEquipmentCode = `${prefix}${String(nextNumber).padStart(maxPaddingLength, '0')}`; | |||
| } else { | |||
| newEquipmentCode = `LSS${String(1).padStart(2, '0')}`; | |||
| } | |||
| } else { | |||
| newEquipmentCode = `LSS${String(1).padStart(2, '0')}`; | |||
| } | |||
| setSelectedEquipmentCode(newEquipmentCode); | |||
| } catch (error) { | |||
| console.error("Error checking existing equipment details:", error); | |||
| setIsExistingCombination(false); | |||
| setSelectedEquipmentCode(""); | |||
| } | |||
| } else { | |||
| setIsExistingCombination(false); | |||
| setSelectedEquipmentCode(""); | |||
| setEquipmentCodePrefix(""); | |||
| setEquipmentCodeNumber(""); | |||
| } | |||
| }; | |||
| checkAndGenerateEquipmentCode(); | |||
| }, [selectedDescription, selectedName, equipmentList]); | |||
| useEffect(() => { | |||
| const generateNumberForPrefix = async () => { | |||
| if (isExistingCombination || !equipmentCodePrefix) { | |||
| return; | |||
| } | |||
| if (equipmentCodePrefix.length !== 3 || !/^[A-Z]{3}$/.test(equipmentCodePrefix)) { | |||
| setEquipmentCodeNumber(""); | |||
| setSelectedEquipmentCode(equipmentCodePrefix); | |||
| return; | |||
| } | |||
| try { | |||
| const response = await axiosInstance.get<{ | |||
| records: EquipmentResult[]; | |||
| total: number; | |||
| }>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/getRecordByPage`, { | |||
| params: { | |||
| pageNum: 1, | |||
| pageSize: 1000, | |||
| }, | |||
| }); | |||
| let maxNumber = 0; | |||
| let maxPaddingLength = 2; | |||
| if (response.data.records && response.data.records.length > 0) { | |||
| const matchingCodes = response.data.records | |||
| .map((detail) => { | |||
| if (!detail.equipmentCode) return null; | |||
| const match = detail.equipmentCode.match(new RegExp(`^${equipmentCodePrefix}(\\d+)$`)); | |||
| if (match) { | |||
| const numberStr = match[1]; | |||
| return { | |||
| number: parseInt(numberStr, 10), | |||
| paddingLength: numberStr.length | |||
| }; | |||
| } | |||
| return null; | |||
| }) | |||
| .filter((item): item is { number: number; paddingLength: number } => item !== null); | |||
| if (matchingCodes.length > 0) { | |||
| maxNumber = Math.max(...matchingCodes.map(c => c.number)); | |||
| maxPaddingLength = Math.max(...matchingCodes.map(c => c.paddingLength)); | |||
| } | |||
| } | |||
| const nextNumber = maxNumber + 1; | |||
| const numberStr = String(nextNumber).padStart(maxPaddingLength, '0'); | |||
| setEquipmentCodeNumber(numberStr); | |||
| setSelectedEquipmentCode(`${equipmentCodePrefix}${numberStr}`); | |||
| } catch (error) { | |||
| console.error("Error generating equipment code number:", error); | |||
| setEquipmentCodeNumber(""); | |||
| setSelectedEquipmentCode(equipmentCodePrefix); | |||
| } | |||
| }; | |||
| generateNumberForPrefix(); | |||
| }, [equipmentCodePrefix, isExistingCombination]); | |||
| const handleToggleExpand = useCallback( | |||
| (id: string | number, code: string) => { | |||
| setExpandedRows(prev => { | |||
| const newSet = new Set(prev); | |||
| if (newSet.has(id)) { | |||
| newSet.delete(id); | |||
| } else { | |||
| newSet.add(id); | |||
| if (!equipmentDetailsMap.has(id)) { | |||
| fetchEquipmentDetailsByEquipmentId(id); | |||
| } | |||
| } | |||
| return newSet; | |||
| }); | |||
| }, | |||
| [equipmentDetailsMap, fetchEquipmentDetailsByEquipmentId] | |||
| ); | |||
| const generalDataColumns = useMemo<Column<EquipmentResult>[]>( | |||
| () => [ | |||
| { | |||
| name: "id", | |||
| label: t("Details"), | |||
| onClick: onDetailClick, | |||
| buttonIcon: <EditNote />, | |||
| }, | |||
| { | |||
| name: "code", | |||
| label: t("Code"), | |||
| }, | |||
| { | |||
| name: "description", | |||
| label: t("Description"), | |||
| }, | |||
| { | |||
| name: "equipmentTypeId", | |||
| label: t("Equipment Type"), | |||
| }, | |||
| { | |||
| name: "action", | |||
| label: t(""), | |||
| buttonIcon: <GridDeleteIcon />, | |||
| onClick: onDeleteClick, | |||
| label: "設備編號", | |||
| renderCell: (item) => ( | |||
| <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> | |||
| <IconButton | |||
| size="small" | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| handleToggleExpand(item.id, item.code); | |||
| }} | |||
| sx={{ padding: 0.5 }} | |||
| > | |||
| {expandedRows.has(item.id) ? ( | |||
| <KeyboardArrowUpIcon fontSize="small" /> | |||
| ) : ( | |||
| <KeyboardArrowDownIcon fontSize="small" /> | |||
| )} | |||
| </IconButton> | |||
| <Typography>{item.code}</Typography> | |||
| </Box> | |||
| ), | |||
| }, | |||
| ], | |||
| [onDetailClick, onDeleteClick, t], | |||
| [t, handleToggleExpand, expandedRows], | |||
| ); | |||
| const repairMaintenanceColumns = useMemo<Column<EquipmentResult>[]>( | |||
| @@ -250,8 +541,6 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||
| const transformedFilter: any = { ...filterObj }; | |||
| // For maintenance tab (tabIndex === 1), if equipmentCode is provided, | |||
| // also search by code (equipment name) with the same value | |||
| if (tabIndex === 1 && transformedFilter.equipmentCode) { | |||
| transformedFilter.code = transformedFilter.equipmentCode; | |||
| } | |||
| @@ -308,24 +597,263 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||
| }, [filterObj, pagingController.pageNum, pagingController.pageSize, tabIndex, isReady, refetchData]); | |||
| const onReset = useCallback(() => { | |||
| setFilterObj({}); | |||
| setPagingController({ | |||
| pageNum: 1, | |||
| pageSize: pagingController.pageSize, | |||
| }); | |||
| }, [pagingController.pageSize]); | |||
| setFilterObjByTab(prev => ({ | |||
| ...prev, | |||
| [tabIndex]: {}, | |||
| })); | |||
| setPagingControllerByTab(prev => ({ | |||
| ...prev, | |||
| [tabIndex]: { | |||
| pageNum: 1, | |||
| pageSize: prev[tabIndex]?.pageSize || 10, | |||
| }, | |||
| })); | |||
| }, [tabIndex]); | |||
| const handleSaveEquipmentDetail = useCallback(async () => { | |||
| if (!selectedName || !selectedDescription) { | |||
| return; | |||
| } | |||
| if (!isExistingCombination) { | |||
| if (equipmentCodePrefix.length !== 3 || !/^[A-Z]{3}$/.test(equipmentCodePrefix)) { | |||
| alert("請輸入3個大寫英文字母作為設備編號前綴"); | |||
| return; | |||
| } | |||
| if (!equipmentCodeNumber) { | |||
| alert("設備編號生成中,請稍候"); | |||
| return; | |||
| } | |||
| } | |||
| setSaving(true); | |||
| try { | |||
| const equipmentCode = `${selectedDescription}-${selectedName}`; | |||
| let equipment = equipmentList.find((eq) => eq.code === equipmentCode); | |||
| let equipmentId: string | number; | |||
| if (!equipment) { | |||
| const equipmentResponse = await axiosInstance.post<EquipmentResult>( | |||
| `${NEXT_PUBLIC_API_URL}/Equipment/save`, | |||
| { | |||
| code: equipmentCode, | |||
| name: selectedName, | |||
| description: selectedDescription, | |||
| id: null, | |||
| } | |||
| ); | |||
| equipment = equipmentResponse.data; | |||
| equipmentId = equipment.id; | |||
| } else { | |||
| equipmentId = equipment.id; | |||
| } | |||
| const existingDetailsResponse = await axiosInstance.get<{ | |||
| records: EquipmentResult[]; | |||
| total: number; | |||
| }>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/byDescriptionIncludingDeleted/${encodeURIComponent(equipmentCode)}`); | |||
| let newName = "1號"; | |||
| let newEquipmentCode = ""; | |||
| if (existingDetailsResponse.data.records && existingDetailsResponse.data.records.length > 0) { | |||
| const numbers = existingDetailsResponse.data.records | |||
| .map((detail) => { | |||
| const match = detail.name?.match(/(\d+)號/); | |||
| return match ? parseInt(match[1], 10) : 0; | |||
| }) | |||
| .filter((num) => num > 0); | |||
| if (numbers.length > 0) { | |||
| const maxNumber = Math.max(...numbers); | |||
| newName = `${maxNumber + 1}號`; | |||
| } | |||
| if (isExistingCombination) { | |||
| const equipmentCodePatterns = existingDetailsResponse.data.records | |||
| .map((detail) => { | |||
| if (!detail.equipmentCode) return null; | |||
| const match = detail.equipmentCode.match(/^([A-Za-z]+)(\d+)$/); | |||
| if (match) { | |||
| const originalNumber = match[2]; | |||
| return { | |||
| prefix: match[1], | |||
| number: parseInt(match[2], 10), | |||
| paddingLength: originalNumber.length | |||
| }; | |||
| } | |||
| return null; | |||
| }) | |||
| .filter((pattern): pattern is { prefix: string; number: number; paddingLength: number } => pattern !== null); | |||
| if (equipmentCodePatterns.length > 0) { | |||
| const prefix = equipmentCodePatterns[0].prefix; | |||
| const maxEquipmentCodeNumber = Math.max(...equipmentCodePatterns.map(p => p.number)); | |||
| const maxPaddingLength = Math.max(...equipmentCodePatterns.map(p => p.paddingLength)); | |||
| const nextNumber = maxEquipmentCodeNumber + 1; | |||
| newEquipmentCode = `${prefix}${String(nextNumber).padStart(maxPaddingLength, '0')}`; | |||
| } else { | |||
| newEquipmentCode = `LSS${String(1).padStart(2, '0')}`; | |||
| } | |||
| } else { | |||
| if (isExistingCombination) { | |||
| newEquipmentCode = selectedEquipmentCode; | |||
| } else { | |||
| newEquipmentCode = `${equipmentCodePrefix}${equipmentCodeNumber}`; | |||
| } | |||
| } | |||
| } else { | |||
| if (isExistingCombination) { | |||
| newEquipmentCode = `LSS${String(1).padStart(2, '0')}`; | |||
| } else { | |||
| newEquipmentCode = `${equipmentCodePrefix}${equipmentCodeNumber}`; | |||
| } | |||
| } | |||
| const detailCode = `${equipmentCode}-${newName}`; | |||
| await axiosInstance.post<EquipmentResult>( | |||
| `${NEXT_PUBLIC_API_URL}/EquipmentDetail/save`, | |||
| { | |||
| code: detailCode, | |||
| name: newName, | |||
| description: equipmentCode, | |||
| equipmentCode: newEquipmentCode, | |||
| id: null, | |||
| equipmentTypeId: equipmentId, | |||
| repairAndMaintenanceStatus: false, | |||
| } | |||
| ); | |||
| handleAddDialogClose(); | |||
| if (tabIndex === 0) { | |||
| await refetchData(filterObj); | |||
| if (equipmentDetailsMap.has(equipmentId)) { | |||
| await fetchEquipmentDetailsByEquipmentId(equipmentId); | |||
| } | |||
| } | |||
| alert("新增成功"); | |||
| } catch (error: any) { | |||
| console.error("Error saving equipment detail:", error); | |||
| const errorMessage = error.response?.data?.message || error.message || "保存失敗,請稍後再試"; | |||
| alert(errorMessage); | |||
| } finally { | |||
| setSaving(false); | |||
| } | |||
| }, [selectedName, selectedDescription, selectedEquipmentCode, equipmentCodePrefix, equipmentCodeNumber, isExistingCombination, equipmentList, refetchData, filterObj, handleAddDialogClose, tabIndex, equipmentDetailsMap, fetchEquipmentDetailsByEquipmentId]); | |||
| const renderExpandedRow = useCallback((item: EquipmentResult): React.ReactNode => { | |||
| if (tabIndex !== 0) { | |||
| return null; | |||
| } | |||
| const details = equipmentDetailsMap.get(item.id) || []; | |||
| const isLoading = loadingDetailsMap.get(item.id) || false; | |||
| return ( | |||
| <TableRow key={`expanded-${item.id}`}> | |||
| <TableCell colSpan={columns.length} sx={{ py: 0, border: 0 }}> | |||
| <Collapse in={expandedRows.has(item.id)} timeout="auto" unmountOnExit> | |||
| <Box sx={{ margin: 2 }}> | |||
| {isLoading ? ( | |||
| <Box sx={{ display: "flex", alignItems: "center", gap: 2, p: 2 }}> | |||
| <CircularProgress size={20} /> | |||
| <Typography>載入中...</Typography> | |||
| </Box> | |||
| ) : details.length === 0 ? ( | |||
| <Typography sx={{ p: 2 }}>無相關設備詳細資料</Typography> | |||
| ) : ( | |||
| <Box> | |||
| <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: "bold" }}> | |||
| 設備詳細資料 (設備編號: {item.code}) | |||
| </Typography> | |||
| <Grid container spacing={2}> | |||
| {details.map((detail) => ( | |||
| <Grid item xs={6} key={detail.id}> | |||
| <Box | |||
| sx={{ | |||
| p: 2, | |||
| border: "1px solid", | |||
| borderColor: "divider", | |||
| borderRadius: 1, | |||
| height: "100%", | |||
| position: "relative", | |||
| }} | |||
| > | |||
| <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 1 }}> | |||
| <Typography variant="body2" sx={{ fontWeight: 500 }}> | |||
| 編號: {detail.code || "-"} | |||
| </Typography> | |||
| <IconButton | |||
| size="small" | |||
| color="error" | |||
| onClick={() => handleDeleteClick(detail.id, item.id)} | |||
| sx={{ ml: 1 }} | |||
| > | |||
| <DeleteIcon fontSize="small" /> | |||
| </IconButton> | |||
| </Box> | |||
| {detail.name && ( | |||
| <Typography variant="caption" color="text.secondary" sx={{ display: "block" }}> | |||
| 名稱: {detail.name} | |||
| </Typography> | |||
| )} | |||
| {detail.description && ( | |||
| <Typography variant="caption" color="text.secondary" sx={{ display: "block" }}> | |||
| 描述: {detail.description} | |||
| </Typography> | |||
| )} | |||
| {detail.equipmentCode && ( | |||
| <Typography variant="caption" color="text.secondary" sx={{ display: "block" }}> | |||
| 設備編號: {detail.equipmentCode} | |||
| </Typography> | |||
| )} | |||
| </Box> | |||
| </Grid> | |||
| ))} | |||
| </Grid> | |||
| </Box> | |||
| )} | |||
| </Box> | |||
| </Collapse> | |||
| </TableCell> | |||
| </TableRow> | |||
| ); | |||
| }, [columns.length, equipmentDetailsMap, loadingDetailsMap, expandedRows, tabIndex, handleDeleteClick]); | |||
| return ( | |||
| <> | |||
| <SearchBox | |||
| criteria={searchCriteria} | |||
| onSearch={(query) => { | |||
| setFilterObj({ | |||
| ...query, | |||
| setFilterObjByTab(prev => { | |||
| const newState = { ...prev }; | |||
| newState[tabIndex] = query as unknown as SearchQuery; | |||
| return newState; | |||
| }); | |||
| }} | |||
| onReset={onReset} | |||
| /> | |||
| {tabIndex === 0 && ( | |||
| <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 2 }}> | |||
| <Typography variant="h6" component="h2"> | |||
| 設備編號 | |||
| </Typography> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<AddIcon />} | |||
| onClick={handleAddClick} | |||
| color="primary" | |||
| > | |||
| 新增 | |||
| </Button> | |||
| </Box> | |||
| )} | |||
| <Box sx={{ | |||
| "& .MuiTableContainer-root": { | |||
| overflowY: "auto", | |||
| @@ -337,14 +865,175 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||
| <EquipmentSearchResults<EquipmentResult> | |||
| items={filteredEquipments} | |||
| columns={columns} | |||
| setPagingController={setPagingController} | |||
| setPagingController={(newController) => { | |||
| setPagingControllerByTab(prev => { | |||
| const newState = { ...prev }; | |||
| newState[tabIndex] = typeof newController === 'function' | |||
| ? newController(prev[tabIndex] || { pageNum: 1, pageSize: 10 }) | |||
| : newController; | |||
| return newState; | |||
| }); | |||
| }} | |||
| pagingController={pagingController} | |||
| totalCount={totalCount} | |||
| isAutoPaging={false} | |||
| /> | |||
| </Box> | |||
| </> | |||
| ); | |||
| }; | |||
| renderExpandedRow={renderExpandedRow} | |||
| hideHeader={tabIndex === 0} | |||
| /> | |||
| </Box> | |||
| {/* Delete Confirmation Dialog */} | |||
| {deleteDialogOpen && ( | |||
| <Dialog | |||
| open={deleteDialogOpen} | |||
| onClose={handleDeleteCancel} | |||
| aria-labelledby="delete-dialog-title" | |||
| aria-describedby="delete-dialog-description" | |||
| > | |||
| <DialogTitle id="delete-dialog-title"> | |||
| 確認刪除 | |||
| </DialogTitle> | |||
| <DialogContent> | |||
| <DialogContentText id="delete-dialog-description"> | |||
| 您確定要刪除此設備詳細資料嗎?此操作無法復原。 | |||
| </DialogContentText> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={handleDeleteCancel} disabled={deleting}> | |||
| 取消 | |||
| </Button> | |||
| <Button onClick={handleDeleteConfirm} color="error" disabled={deleting} autoFocus> | |||
| {deleting ? "刪除中..." : "刪除"} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| )} | |||
| export default EquipmentSearch; | |||
| {/* Add Equipment Detail Dialog */} | |||
| <Dialog | |||
| open={addDialogOpen} | |||
| onClose={handleAddDialogClose} | |||
| aria-labelledby="add-dialog-title" | |||
| maxWidth="sm" | |||
| fullWidth | |||
| > | |||
| <DialogTitle id="add-dialog-title"> | |||
| 新增設備詳細資料 | |||
| </DialogTitle> | |||
| <DialogContent> | |||
| <Box sx={{ pt: 2 }}> | |||
| <Autocomplete | |||
| freeSolo | |||
| options={availableDescriptions} | |||
| value={selectedDescription || null} | |||
| onChange={(event, newValue) => { | |||
| setSelectedDescription(newValue || ''); | |||
| }} | |||
| onInputChange={(event, newInputValue) => { | |||
| setSelectedDescription(newInputValue); | |||
| }} | |||
| loading={loadingEquipments} | |||
| disabled={loadingEquipments || saving} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| label="種類" | |||
| placeholder="選擇或輸入種類" | |||
| /> | |||
| )} | |||
| sx={{ mb: 2 }} | |||
| /> | |||
| <Autocomplete | |||
| freeSolo | |||
| options={availableNames} | |||
| value={selectedName || null} | |||
| onChange={(event, newValue) => { | |||
| setSelectedName(newValue || ''); | |||
| }} | |||
| onInputChange={(event, newInputValue) => { | |||
| setSelectedName(newInputValue); | |||
| }} | |||
| loading={loadingEquipments} | |||
| disabled={loadingEquipments || saving} | |||
| componentsProps={{ | |||
| popper: { | |||
| placement: 'bottom-start', | |||
| modifiers: [ | |||
| { | |||
| name: 'flip', | |||
| enabled: false, | |||
| }, | |||
| { | |||
| name: 'preventOverflow', | |||
| enabled: true, | |||
| }, | |||
| ], | |||
| }, | |||
| }} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| label="名稱" | |||
| placeholder="選擇或輸入名稱" | |||
| /> | |||
| )} | |||
| /> | |||
| <Box sx={{ mt: 2 }}> | |||
| <TextField | |||
| fullWidth | |||
| label="設備編號" | |||
| value={isExistingCombination ? selectedEquipmentCode : equipmentCodePrefix} | |||
| onChange={(e) => { | |||
| if (!isExistingCombination) { | |||
| const input = e.target.value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 3); | |||
| setEquipmentCodePrefix(input); | |||
| } | |||
| }} | |||
| disabled={isExistingCombination || loadingEquipments || saving} | |||
| placeholder={isExistingCombination ? "自動生成" : "輸入3個大寫英文字母"} | |||
| required={!isExistingCombination} | |||
| InputProps={{ | |||
| endAdornment: !isExistingCombination && equipmentCodeNumber ? ( | |||
| <InputAdornment position="end"> | |||
| <Typography | |||
| sx={{ | |||
| color: 'text.secondary', | |||
| fontSize: '1rem', | |||
| fontWeight: 500, | |||
| minWidth: '30px', | |||
| textAlign: 'right', | |||
| }} | |||
| > | |||
| {equipmentCodeNumber} | |||
| </Typography> | |||
| </InputAdornment> | |||
| ) : null, | |||
| }} | |||
| helperText={!isExistingCombination && equipmentCodePrefix.length > 0 && equipmentCodePrefix.length !== 3 | |||
| ? "必須輸入3個大寫英文字母" | |||
| : !isExistingCombination && equipmentCodePrefix.length === 3 && !/^[A-Z]{3}$/.test(equipmentCodePrefix) | |||
| ? "必須是大寫英文字母" | |||
| : ""} | |||
| error={!isExistingCombination && equipmentCodePrefix.length > 0 && (equipmentCodePrefix.length !== 3 || !/^[A-Z]{3}$/.test(equipmentCodePrefix))} | |||
| /> | |||
| </Box> | |||
| </Box> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={handleAddDialogClose} disabled={saving}> | |||
| 取消 | |||
| </Button> | |||
| <Button | |||
| onClick={handleSaveEquipmentDetail} | |||
| variant="contained" | |||
| disabled={!selectedName || !selectedDescription || (!isExistingCombination && !selectedEquipmentCode) || loadingEquipments || saving} | |||
| > | |||
| {saving ? "保存中..." : "新增"} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </> | |||
| ); | |||
| }; | |||
| export default EquipmentSearch; | |||
| @@ -1,3 +1,5 @@ | |||
| "use client"; | |||
| import Card from "@mui/material/Card"; | |||
| import CardContent from "@mui/material/CardContent"; | |||
| import Skeleton from "@mui/material/Skeleton"; | |||
| @@ -48,6 +48,7 @@ interface BaseColumn<T extends ResultWithId> { | |||
| style?: Partial<HTMLElement["style"]> & { [propName: string]: string }; | |||
| type?: ColumnType; | |||
| renderCell?: (params: T) => React.ReactNode; | |||
| renderHeader?: () => React.ReactNode; | |||
| } | |||
| interface IconColumn<T extends ResultWithId> extends BaseColumn<T> { | |||
| @@ -104,6 +105,8 @@ interface Props<T extends ResultWithId> { | |||
| checkboxIds?: (string | number)[]; | |||
| setCheckboxIds?: Dispatch<SetStateAction<(string | number)[]>>; | |||
| onRowClick?: (item: T) => void; | |||
| renderExpandedRow?: (item: T) => React.ReactNode; | |||
| hideHeader?: boolean; | |||
| } | |||
| function isActionColumn<T extends ResultWithId>( | |||
| @@ -197,6 +200,8 @@ function EquipmentSearchResults<T extends ResultWithId>({ | |||
| checkboxIds = [], | |||
| setCheckboxIds = undefined, | |||
| onRowClick = undefined, | |||
| renderExpandedRow = undefined, | |||
| hideHeader = false, | |||
| }: Props<T>) { | |||
| const { t } = useTranslation("common"); | |||
| const [page, setPage] = React.useState(0); | |||
| @@ -303,35 +308,41 @@ function EquipmentSearchResults<T extends ResultWithId>({ | |||
| const table = ( | |||
| <> | |||
| <TableContainer sx={{ maxHeight: 440 }}> | |||
| <Table stickyHeader> | |||
| <TableHead> | |||
| <TableRow> | |||
| {columns.map((column, idx) => ( | |||
| isCheckboxColumn(column) ? | |||
| <TableCell | |||
| align={column.headerAlign} | |||
| sx={column.sx} | |||
| key={`${column.name.toString()}${idx}`} | |||
| > | |||
| <Checkbox | |||
| color="primary" | |||
| indeterminate={currItemsWithChecked.length > 0 && currItemsWithChecked.length < currItems.length} | |||
| checked={currItems.length > 0 && currItemsWithChecked.length >= currItems.length} | |||
| onChange={handleSelectAllClick} | |||
| /> | |||
| </TableCell> | |||
| : <TableCell | |||
| align={column.headerAlign} | |||
| sx={column.sx} | |||
| key={`${column.name.toString()}${idx}`} | |||
| > | |||
| {column.label.split('\n').map((line, index) => ( | |||
| <div key={index}>{line}</div> // Render each line in a div | |||
| ))} | |||
| </TableCell> | |||
| ))} | |||
| </TableRow> | |||
| </TableHead> | |||
| <Table stickyHeader={!hideHeader}> | |||
| {!hideHeader && ( | |||
| <TableHead> | |||
| <TableRow> | |||
| {columns.map((column, idx) => ( | |||
| isCheckboxColumn(column) ? | |||
| <TableCell | |||
| align={column.headerAlign} | |||
| sx={column.sx} | |||
| key={`${column.name.toString()}${idx}`} | |||
| > | |||
| <Checkbox | |||
| color="primary" | |||
| indeterminate={currItemsWithChecked.length > 0 && currItemsWithChecked.length < currItems.length} | |||
| checked={currItems.length > 0 && currItemsWithChecked.length >= currItems.length} | |||
| onChange={handleSelectAllClick} | |||
| /> | |||
| </TableCell> | |||
| : <TableCell | |||
| align={column.headerAlign} | |||
| sx={column.sx} | |||
| key={`${column.name.toString()}${idx}`} | |||
| > | |||
| {column.renderHeader ? ( | |||
| column.renderHeader() | |||
| ) : ( | |||
| column.label.split('\n').map((line, index) => ( | |||
| <div key={index}>{line}</div> // Render each line in a div | |||
| )) | |||
| )} | |||
| </TableCell> | |||
| ))} | |||
| </TableRow> | |||
| </TableHead> | |||
| )} | |||
| <TableBody> | |||
| {isAutoPaging | |||
| ? items | |||
| @@ -339,10 +350,45 @@ function EquipmentSearchResults<T extends ResultWithId>({ | |||
| (pagingController?.pageNum ? pagingController?.pageNum - 1 :page) * (pagingController?.pageSize ?? rowsPerPage) + (pagingController?.pageSize ?? rowsPerPage)) | |||
| .map((item) => { | |||
| return ( | |||
| <TableRow | |||
| hover | |||
| tabIndex={-1} | |||
| key={item.id} | |||
| <React.Fragment key={item.id}> | |||
| <TableRow | |||
| hover | |||
| tabIndex={-1} | |||
| onClick={(event) => { | |||
| setCheckboxIds | |||
| ? handleRowClick(event, item, columns) | |||
| : undefined | |||
| if (onRowClick) { | |||
| onRowClick(item) | |||
| } | |||
| } | |||
| } | |||
| role={setCheckboxIds ? "checkbox" : undefined} | |||
| > | |||
| {columns.map((column, idx) => { | |||
| const columnName = column.name; | |||
| return ( | |||
| <TabelCells | |||
| key={`${columnName.toString()}-${idx}`} | |||
| column={column} | |||
| columnName={columnName} | |||
| idx={idx} | |||
| item={item} | |||
| checkboxIds={checkboxIds} | |||
| /> | |||
| ); | |||
| })} | |||
| </TableRow> | |||
| {renderExpandedRow && renderExpandedRow(item)} | |||
| </React.Fragment> | |||
| ); | |||
| }) | |||
| : items.map((item) => { | |||
| return ( | |||
| <React.Fragment key={item.id}> | |||
| <TableRow hover tabIndex={-1} | |||
| onClick={(event) => { | |||
| setCheckboxIds | |||
| ? handleRowClick(event, item, columns) | |||
| @@ -370,38 +416,8 @@ function EquipmentSearchResults<T extends ResultWithId>({ | |||
| ); | |||
| })} | |||
| </TableRow> | |||
| ); | |||
| }) | |||
| : items.map((item) => { | |||
| return ( | |||
| <TableRow hover tabIndex={-1} key={item.id} | |||
| onClick={(event) => { | |||
| setCheckboxIds | |||
| ? handleRowClick(event, item, columns) | |||
| : undefined | |||
| if (onRowClick) { | |||
| onRowClick(item) | |||
| } | |||
| } | |||
| } | |||
| role={setCheckboxIds ? "checkbox" : undefined} | |||
| > | |||
| {columns.map((column, idx) => { | |||
| const columnName = column.name; | |||
| return ( | |||
| <TabelCells | |||
| key={`${columnName.toString()}-${idx}`} | |||
| column={column} | |||
| columnName={columnName} | |||
| idx={idx} | |||
| item={item} | |||
| checkboxIds={checkboxIds} | |||
| /> | |||
| ); | |||
| })} | |||
| </TableRow> | |||
| {renderExpandedRow && renderExpandedRow(item)} | |||
| </React.Fragment> | |||
| ); | |||
| })} | |||
| </TableBody> | |||
| @@ -1,3 +1,4 @@ | |||
| import { useSession } from "next-auth/react"; | |||
| import Divider from "@mui/material/Divider"; | |||
| import Box from "@mui/material/Box"; | |||
| import React, { useEffect } from "react"; | |||
| @@ -24,16 +25,38 @@ import { usePathname } from "next/navigation"; | |||
| import Link from "next/link"; | |||
| import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; | |||
| import Logo from "../Logo"; | |||
| import BugReportIcon from "@mui/icons-material/BugReport"; | |||
| import { | |||
| VIEW_USER, | |||
| MAINTAIN_USER, | |||
| VIEW_GROUP, | |||
| MAINTAIN_GROUP, | |||
| // Add more authorities as needed, e.g.: | |||
| TESTING, PROD, PACK, ADMIN, STOCK, Driver | |||
| } from "../../authorities"; | |||
| interface NavigationItem { | |||
| icon: React.ReactNode; | |||
| label: string; | |||
| path: string; | |||
| children?: NavigationItem[]; | |||
| isHidden?: true | undefined; | |||
| isHidden?: boolean | undefined; | |||
| requiredAbility?: string | string[]; | |||
| } | |||
| const NavigationContent: React.FC = () => { | |||
| const { data: session, status } = useSession(); | |||
| const abilities = session?.user?.abilities ?? []; | |||
| // Helper: check if user has required permission | |||
| const hasAbility = (required?: string | string[]): boolean => { | |||
| if (!required) return true; // no requirement → always show | |||
| if (Array.isArray(required)) { | |||
| return required.some(ability => abilities.includes(ability)); | |||
| } | |||
| return abilities.includes(required); | |||
| }; | |||
| const navigationItems: NavigationItem[] = [ | |||
| { | |||
| icon: <Dashboard />, | |||
| @@ -113,49 +136,11 @@ const NavigationContent: React.FC = () => { | |||
| }, | |||
| ], | |||
| }, | |||
| // { | |||
| // icon: <RequestQuote />, | |||
| // label: "Production", | |||
| // path: "", | |||
| // children: [ | |||
| // { | |||
| // icon: <RequestQuote />, | |||
| // label: "Job Order", | |||
| // path: "", | |||
| // }, | |||
| // { | |||
| // icon: <RequestQuote />, | |||
| // label: "Job Order Traceablity ", | |||
| // path: "", | |||
| // }, | |||
| // { | |||
| // icon: <RequestQuote />, | |||
| // label: "Work Order", | |||
| // path: "", | |||
| // }, | |||
| // { | |||
| // icon: <RequestQuote />, | |||
| // label: "Work Order Traceablity ", | |||
| // path: "", | |||
| // }, | |||
| // ], | |||
| // }, | |||
| // { | |||
| // icon: <RequestQuote />, | |||
| // label: "Quality Control Log", | |||
| // path: "", | |||
| // children: [ | |||
| // { | |||
| // icon: <RequestQuote />, | |||
| // label: "Quality Control Log", | |||
| // path: "", | |||
| // }, | |||
| // ], | |||
| // }, | |||
| { | |||
| icon: <RequestQuote />, | |||
| label: "Delivery", | |||
| path: "", | |||
| //requiredAbility: VIEW_DO, | |||
| children: [ | |||
| { | |||
| icon: <RequestQuote />, | |||
| @@ -249,20 +234,37 @@ const NavigationContent: React.FC = () => { | |||
| }, | |||
| ], | |||
| }, | |||
| { | |||
| icon: <BugReportIcon />, | |||
| label: "PS", | |||
| path: "/ps", | |||
| requiredAbility: TESTING, | |||
| isHidden: false, | |||
| }, | |||
| { | |||
| icon: <BugReportIcon />, | |||
| label: "Printer Testing", | |||
| path: "/testing", | |||
| requiredAbility: TESTING, | |||
| isHidden: false, | |||
| }, | |||
| { | |||
| icon: <RequestQuote />, | |||
| label: "Settings", | |||
| path: "", | |||
| requiredAbility: [VIEW_USER, VIEW_GROUP], | |||
| children: [ | |||
| { | |||
| icon: <RequestQuote />, | |||
| label: "User", | |||
| path: "/settings/user", | |||
| requiredAbility: VIEW_USER, | |||
| }, | |||
| { | |||
| icon: <RequestQuote />, | |||
| label: "User Group", | |||
| path: "/settings/user", | |||
| requiredAbility: VIEW_GROUP, | |||
| }, | |||
| // { | |||
| // icon: <RequestQuote />, | |||
| @@ -302,7 +304,7 @@ const NavigationContent: React.FC = () => { | |||
| { | |||
| icon: <RequestQuote />, | |||
| label: "Warehouse", | |||
| path: "/settings/user", | |||
| path: "/settings/warehouse", | |||
| }, | |||
| { | |||
| icon: <RequestQuote />, | |||
| @@ -365,7 +367,12 @@ const NavigationContent: React.FC = () => { | |||
| }; | |||
| const renderNavigationItem = (item: NavigationItem) => { | |||
| if (!hasAbility(item.requiredAbility)) { | |||
| return null; | |||
| } | |||
| const isOpen = openItems.includes(item.label); | |||
| const hasVisibleChildren = item.children?.some(child => hasAbility(child.requiredAbility)); | |||
| return ( | |||
| <Box | |||
| @@ -381,7 +388,7 @@ const NavigationContent: React.FC = () => { | |||
| <ListItemIcon>{item.icon}</ListItemIcon> | |||
| <ListItemText primary={t(item.label)} /> | |||
| </ListItemButton> | |||
| {item.children && isOpen && ( | |||
| {item.children && isOpen && hasVisibleChildren && ( | |||
| <List sx={{ pl: 2 }}> | |||
| {item.children.map( | |||
| (child) => !child.isHidden && renderNavigationItem(child), | |||
| @@ -392,6 +399,10 @@ const NavigationContent: React.FC = () => { | |||
| ); | |||
| }; | |||
| if (status === "loading") { | |||
| return <Box sx={{ width: NAVIGATION_CONTENT_WIDTH, p: 3 }}>Loading...</Box>; | |||
| } | |||
| return ( | |||
| <Box sx={{ width: NAVIGATION_CONTENT_WIDTH }}> | |||
| <Box sx={{ p: 3, display: "flex" }}> | |||
| @@ -402,7 +413,10 @@ const NavigationContent: React.FC = () => { | |||
| </Box> | |||
| <Divider /> | |||
| <List component="nav"> | |||
| {navigationItems.map((item) => renderNavigationItem(item))} | |||
| {navigationItems | |||
| .filter(item => !item.isHidden) | |||
| .map(renderNavigationItem) | |||
| .filter(Boolean)} | |||
| {/* {navigationItems.map(({ icon, label, path }, index) => { | |||
| return ( | |||
| <Box | |||
| @@ -52,6 +52,7 @@ interface OptionWithLabel<T extends string> { | |||
| interface TextCriterion<T extends string> extends BaseCriterion<T> { | |||
| type: "text"; | |||
| placeholder?: string; | |||
| } | |||
| interface SelectCriterion<T extends string> extends BaseCriterion<T> { | |||
| @@ -286,6 +287,7 @@ function SearchBox<T extends string>({ | |||
| <TextField | |||
| label={t(c.label)} | |||
| fullWidth | |||
| placeholder={c.placeholder} | |||
| onChange={makeInputChangeHandler(c.paramName)} | |||
| value={inputs[c.paramName]} | |||
| /> | |||
| @@ -306,7 +308,7 @@ function SearchBox<T extends string>({ | |||
| <Select | |||
| label={t(c.label)} | |||
| onChange={makeSelectChangeHandler(c.paramName)} | |||
| value={inputs[c.paramName]} | |||
| value={inputs[c.paramName] ?? "All"} | |||
| > | |||
| <MenuItem value={"All"}>{t("All")}</MenuItem> | |||
| {c.options.map((option) => ( | |||
| @@ -323,7 +325,7 @@ function SearchBox<T extends string>({ | |||
| <Select | |||
| label={t(c.label)} | |||
| onChange={makeSelectChangeHandler(c.paramName)} | |||
| value={inputs[c.paramName]} | |||
| value={inputs[c.paramName] ?? "All"} | |||
| > | |||
| <MenuItem value={"All"}>{t("All")}</MenuItem> | |||
| {c.options.map((option) => ( | |||
| @@ -18,7 +18,7 @@ import { | |||
| InputLabel, | |||
| } from "@mui/material"; | |||
| import { useState, useMemo, useCallback, useEffect } from "react"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { useRouter, useSearchParams } from "next/navigation"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import SearchResults, { Column } from "../SearchResults"; | |||
| @@ -43,6 +43,7 @@ type SearchParamNames = keyof SearchQuery; | |||
| const Shop: React.FC = () => { | |||
| const { t } = useTranslation("common"); | |||
| const router = useRouter(); | |||
| const searchParams = useSearchParams(); | |||
| const [activeTab, setActiveTab] = useState<number>(0); | |||
| const [rows, setRows] = useState<ShopRow[]>([]); | |||
| const [loading, setLoading] = useState<boolean>(false); | |||
| @@ -235,26 +236,33 @@ const Shop: React.FC = () => { | |||
| name: "id", | |||
| label: t("id"), | |||
| type: "integer", | |||
| sx: { width: "100px", minWidth: "100px", maxWidth: "100px" }, | |||
| renderCell: (item) => String(item.id ?? ""), | |||
| }, | |||
| { | |||
| name: "code", | |||
| label: t("Code"), | |||
| sx: { width: "150px", minWidth: "150px", maxWidth: "150px" }, | |||
| renderCell: (item) => String(item.code ?? ""), | |||
| }, | |||
| { | |||
| name: "name", | |||
| label: t("Name"), | |||
| sx: { width: "200px", minWidth: "200px", maxWidth: "200px" }, | |||
| renderCell: (item) => String(item.name ?? ""), | |||
| }, | |||
| { | |||
| name: "addr3", | |||
| label: t("Addr3"), | |||
| sx: { width: "200px", minWidth: "200px", maxWidth: "200px" }, | |||
| renderCell: (item) => String((item as any).addr3 ?? ""), | |||
| }, | |||
| { | |||
| name: "truckLanceStatus", | |||
| label: t("TruckLance Status"), | |||
| align: "center", | |||
| headerAlign: "center", | |||
| sx: { width: "150px", minWidth: "150px", maxWidth: "150px" }, | |||
| renderCell: (item) => { | |||
| const status = item.truckLanceStatus; | |||
| if (status === "complete") { | |||
| @@ -269,7 +277,9 @@ const Shop: React.FC = () => { | |||
| { | |||
| name: "actions", | |||
| label: t("Actions"), | |||
| align: "right", | |||
| headerAlign: "right", | |||
| sx: { width: "150px", minWidth: "150px", maxWidth: "150px" }, | |||
| renderCell: (item) => ( | |||
| <Button | |||
| size="small" | |||
| @@ -282,6 +292,17 @@ const Shop: React.FC = () => { | |||
| }, | |||
| ]; | |||
| // Initialize activeTab from URL parameter | |||
| useEffect(() => { | |||
| const tabParam = searchParams.get("tab"); | |||
| if (tabParam !== null) { | |||
| const tabIndex = parseInt(tabParam, 10); | |||
| if (!isNaN(tabIndex) && (tabIndex === 0 || tabIndex === 1)) { | |||
| setActiveTab(tabIndex); | |||
| } | |||
| } | |||
| }, [searchParams]); | |||
| useEffect(() => { | |||
| if (activeTab === 0) { | |||
| fetchAllShops(); | |||
| @@ -290,82 +311,99 @@ const Shop: React.FC = () => { | |||
| const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | |||
| setActiveTab(newValue); | |||
| // Update URL to reflect the selected tab | |||
| const url = new URL(window.location.href); | |||
| url.searchParams.set("tab", String(newValue)); | |||
| router.push(url.pathname + url.search); | |||
| }; | |||
| return ( | |||
| <Box> | |||
| <Card sx={{ mb: 2 }}> | |||
| <CardContent> | |||
| <Tabs | |||
| value={activeTab} | |||
| onChange={handleTabChange} | |||
| sx={{ | |||
| mb: 3, | |||
| borderBottom: 1, | |||
| borderColor: 'divider' | |||
| }} | |||
| > | |||
| <Tab label={t("Shop")} /> | |||
| <Tab label={t("Truck Lane")} /> | |||
| </Tabs> | |||
| {/* Header section with title */} | |||
| <Box sx={{ | |||
| p: 2, | |||
| borderBottom: '1px solid #e0e0e0' | |||
| }}> | |||
| <Typography variant="h4"> | |||
| 店鋪路線管理 | |||
| </Typography> | |||
| </Box> | |||
| {/* Tabs section */} | |||
| <Box sx={{ | |||
| borderBottom: '1px solid #e0e0e0' | |||
| }}> | |||
| <Tabs | |||
| value={activeTab} | |||
| onChange={handleTabChange} | |||
| > | |||
| <Tab label={t("Shop")} /> | |||
| <Tab label={t("Truck Lane")} /> | |||
| </Tabs> | |||
| </Box> | |||
| {activeTab === 0 && ( | |||
| <SearchBox | |||
| criteria={criteria as Criterion<string>[]} | |||
| onSearch={handleSearch} | |||
| onReset={() => { | |||
| setRows([]); | |||
| setFilters({}); | |||
| }} | |||
| /> | |||
| )} | |||
| </CardContent> | |||
| </Card> | |||
| {/* Content section */} | |||
| <Box sx={{ p: 2 }}> | |||
| {activeTab === 0 && ( | |||
| <> | |||
| <Card sx={{ mb: 2 }}> | |||
| <CardContent> | |||
| <SearchBox | |||
| criteria={criteria as Criterion<string>[]} | |||
| onSearch={handleSearch} | |||
| onReset={() => { | |||
| setRows([]); | |||
| setFilters({}); | |||
| }} | |||
| /> | |||
| </CardContent> | |||
| </Card> | |||
| {activeTab === 0 && ( | |||
| <Card> | |||
| <CardContent> | |||
| <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}> | |||
| <Typography variant="h6">{t("Shop")}</Typography> | |||
| <FormControl size="small" sx={{ minWidth: 200 }}> | |||
| <InputLabel>{t("Filter by Status")}</InputLabel> | |||
| <Select | |||
| value={statusFilter} | |||
| label={t("Filter by Status")} | |||
| onChange={(e) => setStatusFilter(e.target.value)} | |||
| > | |||
| <MenuItem value="all">{t("All")}</MenuItem> | |||
| <MenuItem value="complete">{t("Complete")}</MenuItem> | |||
| <MenuItem value="missing">{t("Missing Data")}</MenuItem> | |||
| <MenuItem value="no-truck">{t("No TruckLance")}</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| </Stack> | |||
| {error && ( | |||
| <Alert severity="error" sx={{ mb: 2 }}> | |||
| {error} | |||
| </Alert> | |||
| )} | |||
| <Card> | |||
| <CardContent> | |||
| <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}> | |||
| <Typography variant="h6">{t("Shop")}</Typography> | |||
| <FormControl size="small" sx={{ minWidth: 200 }}> | |||
| <InputLabel>{t("Filter by Status")}</InputLabel> | |||
| <Select | |||
| value={statusFilter} | |||
| label={t("Filter by Status")} | |||
| onChange={(e) => setStatusFilter(e.target.value)} | |||
| > | |||
| <MenuItem value="all">{t("All")}</MenuItem> | |||
| <MenuItem value="complete">{t("Complete")}</MenuItem> | |||
| <MenuItem value="missing">{t("Missing Data")}</MenuItem> | |||
| <MenuItem value="no-truck">{t("No TruckLance")}</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| </Stack> | |||
| {error && ( | |||
| <Alert severity="error" sx={{ mb: 2 }}> | |||
| {error} | |||
| </Alert> | |||
| )} | |||
| {loading ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : ( | |||
| <SearchResults | |||
| items={filteredRows} | |||
| columns={columns} | |||
| pagingController={pagingController} | |||
| setPagingController={setPagingController} | |||
| /> | |||
| )} | |||
| </CardContent> | |||
| </Card> | |||
| )} | |||
| {loading ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : ( | |||
| <SearchResults | |||
| items={filteredRows} | |||
| columns={columns} | |||
| pagingController={pagingController} | |||
| setPagingController={setPagingController} | |||
| /> | |||
| )} | |||
| </CardContent> | |||
| </Card> | |||
| </> | |||
| )} | |||
| {activeTab === 1 && ( | |||
| <TruckLane /> | |||
| )} | |||
| {activeTab === 1 && ( | |||
| <TruckLane /> | |||
| )} | |||
| </Box> | |||
| </Box> | |||
| ); | |||
| }; | |||
| @@ -48,6 +48,7 @@ import { | |||
| createTruckClient | |||
| } from "@/app/api/shop/client"; | |||
| import type { SessionWithTokens } from "@/config/authConfig"; | |||
| import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil"; | |||
| type ShopDetailData = { | |||
| id: number; | |||
| @@ -62,61 +63,6 @@ type ShopDetailData = { | |||
| contactName: String; | |||
| }; | |||
| // Utility function to format departureTime to HH:mm format | |||
| const formatDepartureTime = (time: string | number[] | null | undefined): string => { | |||
| if (!time) return "-"; | |||
| // Handle array format [hours, minutes] from API | |||
| if (Array.isArray(time) && time.length >= 2) { | |||
| const hours = time[0]; | |||
| const minutes = time[1]; | |||
| if (typeof hours === 'number' && typeof minutes === 'number' && | |||
| hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) { | |||
| return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; | |||
| } | |||
| } | |||
| const timeStr = String(time).trim(); | |||
| if (!timeStr || timeStr === "-") return "-"; | |||
| // If already in HH:mm format, return as is | |||
| if (/^\d{1,2}:\d{2}$/.test(timeStr)) { | |||
| const [hours, minutes] = timeStr.split(":"); | |||
| return `${hours.padStart(2, "0")}:${minutes.padStart(2, "0")}`; | |||
| } | |||
| // Handle decimal format (e.g., "17,0" or "17.0" representing hours) | |||
| const decimalMatch = timeStr.match(/^(\d+)[,.](\d+)$/); | |||
| if (decimalMatch) { | |||
| const hours = parseInt(decimalMatch[1], 10); | |||
| const minutes = Math.round(parseFloat(`0.${decimalMatch[2]}`) * 60); | |||
| return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; | |||
| } | |||
| // Handle single number as hours (e.g., "17" -> "17:00") | |||
| const hoursOnly = parseInt(timeStr, 10); | |||
| if (!isNaN(hoursOnly) && hoursOnly >= 0 && hoursOnly <= 23) { | |||
| return `${hoursOnly.toString().padStart(2, "0")}:00`; | |||
| } | |||
| // Try to parse as ISO time string or other formats | |||
| try { | |||
| // If it's already a valid time string, try to extract hours and minutes | |||
| const parts = timeStr.split(/[:,\s]/); | |||
| if (parts.length >= 2) { | |||
| const h = parseInt(parts[0], 10); | |||
| const m = parseInt(parts[1], 10); | |||
| if (!isNaN(h) && !isNaN(m) && h >= 0 && h <= 23 && m >= 0 && m <= 59) { | |||
| return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`; | |||
| } | |||
| } | |||
| } catch (e) { | |||
| // If parsing fails, return original string | |||
| } | |||
| return timeStr; | |||
| }; | |||
| // Utility function to convert HH:mm format to the format expected by backend | |||
| const parseDepartureTimeForBackend = (time: string): string => { | |||
| if (!time) return ""; | |||
| @@ -299,7 +245,7 @@ const ShopDetail: React.FC = () => { | |||
| } | |||
| // Convert storeId to string format (2F or 4F) | |||
| const storeIdStr = truck.storeId ? (typeof truck.storeId === 'string' ? truck.storeId : String(truck.storeId) === "2" ? "2F" : String(truck.storeId) === "4" ? "4F" : String(truck.storeId)) : "2F"; | |||
| const storeIdStr = normalizeStoreId(truck.storeId) || "2F"; | |||
| // Get remark value - use the remark from editedTruckData (user input) | |||
| // Only send remark if storeId is "4F", otherwise send null | |||
| @@ -482,7 +428,7 @@ const ShopDetail: React.FC = () => { | |||
| <Alert severity="error" sx={{ mb: 2 }}> | |||
| {error} | |||
| </Alert> | |||
| <Button onClick={() => router.back()}>{t("Back")}</Button> | |||
| <Button onClick={() => router.push("/settings/shop?tab=0")}>{t("Back")}</Button> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -493,7 +439,7 @@ const ShopDetail: React.FC = () => { | |||
| <Alert severity="warning" sx={{ mb: 2 }}> | |||
| {t("Shop not found")} | |||
| </Alert> | |||
| <Button onClick={() => router.back()}>{t("Back")}</Button> | |||
| <Button onClick={() => router.push("/settings/shop?tab=0")}>{t("Back")}</Button> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -504,7 +450,7 @@ const ShopDetail: React.FC = () => { | |||
| <CardContent> | |||
| <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | |||
| <Typography variant="h6">{t("Shop Information")}</Typography> | |||
| <Button onClick={() => router.back()}>{t("Back")}</Button> | |||
| <Button onClick={() => router.push("/settings/shop?tab=0")}>{t("Back")}</Button> | |||
| </Box> | |||
| <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> | |||
| @@ -682,22 +628,13 @@ const ShopDetail: React.FC = () => { | |||
| </Select> | |||
| </FormControl> | |||
| ) : ( | |||
| (() => { | |||
| const storeId = truck.storeId; | |||
| if (storeId === null || storeId === undefined) return "-"; | |||
| const storeIdStr = typeof storeId === 'string' ? storeId : String(storeId); | |||
| // Convert numeric values to display format | |||
| if (storeIdStr === "2" || storeIdStr === "2F") return "2F"; | |||
| if (storeIdStr === "4" || storeIdStr === "4F") return "4F"; | |||
| return storeIdStr; | |||
| })() | |||
| normalizeStoreId(truck.storeId) | |||
| )} | |||
| </TableCell> | |||
| <TableCell> | |||
| {isEditing ? ( | |||
| (() => { | |||
| const storeId = displayTruck?.storeId; | |||
| const storeIdStr = storeId ? (typeof storeId === 'string' ? storeId : String(storeId) === "2" ? "2F" : String(storeId) === "4" ? "4F" : String(storeId)) : "2F"; | |||
| const storeIdStr = normalizeStoreId(displayTruck?.storeId) || "2F"; | |||
| const isEditable = storeIdStr === "4F"; | |||
| return ( | |||
| @@ -16,39 +16,27 @@ import { | |||
| Button, | |||
| CircularProgress, | |||
| Alert, | |||
| Dialog, | |||
| DialogTitle, | |||
| DialogContent, | |||
| DialogActions, | |||
| TextField, | |||
| Grid, | |||
| FormControl, | |||
| InputLabel, | |||
| Select, | |||
| MenuItem, | |||
| Snackbar, | |||
| } from "@mui/material"; | |||
| import AddIcon from "@mui/icons-material/Add"; | |||
| import SaveIcon from "@mui/icons-material/Save"; | |||
| import { useState, useEffect, useMemo } from "react"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { findAllUniqueTruckLaneCombinationsClient } from "@/app/api/shop/client"; | |||
| import { findAllUniqueTruckLaneCombinationsClient, createTruckWithoutShopClient } from "@/app/api/shop/client"; | |||
| import type { Truck } from "@/app/api/shop/actions"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| // Utility function to format departureTime to HH:mm format | |||
| const formatDepartureTime = (time: string | number[] | null | undefined): string => { | |||
| if (!time) return "-"; | |||
| // Handle array format [hours, minutes] from API | |||
| if (Array.isArray(time) && time.length >= 2) { | |||
| const hours = time[0]; | |||
| const minutes = time[1]; | |||
| if (typeof hours === 'number' && typeof minutes === 'number' && | |||
| hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) { | |||
| return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; | |||
| } | |||
| } | |||
| const timeStr = String(time).trim(); | |||
| if (!timeStr || timeStr === "-") return "-"; | |||
| // If already in HH:mm format, return as is | |||
| if (/^\d{1,2}:\d{2}$/.test(timeStr)) { | |||
| const [hours, minutes] = timeStr.split(":"); | |||
| return `${hours.padStart(2, "0")}:${minutes.padStart(2, "0")}`; | |||
| } | |||
| return timeStr; | |||
| }; | |||
| import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil"; | |||
| type SearchQuery = { | |||
| truckLanceCode: string; | |||
| @@ -67,6 +55,15 @@ const TruckLane: React.FC = () => { | |||
| const [filters, setFilters] = useState<Record<string, string>>({}); | |||
| const [page, setPage] = useState(0); | |||
| const [rowsPerPage, setRowsPerPage] = useState(10); | |||
| const [addDialogOpen, setAddDialogOpen] = useState<boolean>(false); | |||
| const [newTruck, setNewTruck] = useState({ | |||
| truckLanceCode: "", | |||
| departureTime: "", | |||
| storeId: "2F", | |||
| }); | |||
| const [saving, setSaving] = useState<boolean>(false); | |||
| const [snackbarOpen, setSnackbarOpen] = useState<boolean>(false); | |||
| const [snackbarMessage, setSnackbarMessage] = useState<string>(""); | |||
| useEffect(() => { | |||
| const fetchTruckLanes = async () => { | |||
| @@ -92,39 +89,34 @@ const TruckLane: React.FC = () => { | |||
| }; | |||
| fetchTruckLanes(); | |||
| }, []); | |||
| }, [t]); | |||
| // Client-side filtered rows (contains-matching) | |||
| const filteredRows = useMemo(() => { | |||
| const fKeys = Object.keys(filters || {}).filter((k) => String((filters as any)[k]).trim() !== ""); | |||
| const normalized = (truckData || []).filter((r) => { | |||
| // Apply contains matching for each active filter | |||
| for (const k of fKeys) { | |||
| const v = String((filters as any)[k] ?? "").trim(); | |||
| const fKeys = Object.keys(filters).filter((k) => String(filters[k] ?? "").trim() !== ""); | |||
| if (fKeys.length === 0) return truckData; | |||
| return truckData.filter((truck) => { | |||
| for (const key of fKeys) { | |||
| const filterValue = String(filters[key] ?? "").trim().toLowerCase(); | |||
| if (k === "truckLanceCode") { | |||
| const rv = String((r as any).truckLanceCode ?? "").trim(); | |||
| if (!rv.toLowerCase().includes(v.toLowerCase())) return false; | |||
| } else if (k === "departureTime") { | |||
| if (key === "truckLanceCode") { | |||
| const truckCode = String(truck.truckLanceCode ?? "").trim().toLowerCase(); | |||
| if (!truckCode.includes(filterValue)) return false; | |||
| } else if (key === "departureTime") { | |||
| const formattedTime = formatDepartureTime( | |||
| Array.isArray(r.departureTime) | |||
| ? r.departureTime | |||
| : (r.departureTime ? String(r.departureTime) : null) | |||
| Array.isArray(truck.departureTime) | |||
| ? truck.departureTime | |||
| : (truck.departureTime ? String(truck.departureTime) : null) | |||
| ); | |||
| if (!formattedTime.toLowerCase().includes(v.toLowerCase())) return false; | |||
| } else if (k === "storeId") { | |||
| const rv = String((r as any).storeId ?? "").trim(); | |||
| const storeIdStr = typeof rv === 'string' ? rv : String(rv); | |||
| // Convert numeric values to display format for comparison | |||
| let displayStoreId = storeIdStr; | |||
| if (storeIdStr === "2" || storeIdStr === "2F") displayStoreId = "2F"; | |||
| if (storeIdStr === "4" || storeIdStr === "4F") displayStoreId = "4F"; | |||
| if (!displayStoreId.toLowerCase().includes(v.toLowerCase())) return false; | |||
| if (!formattedTime.toLowerCase().includes(filterValue)) return false; | |||
| } else if (key === "storeId") { | |||
| const displayStoreId = normalizeStoreId(truck.storeId); | |||
| if (!displayStoreId.toLowerCase().includes(filterValue)) return false; | |||
| } | |||
| } | |||
| return true; | |||
| }); | |||
| return normalized; | |||
| }, [truckData, filters]); | |||
| // Paginated rows | |||
| @@ -158,6 +150,89 @@ const TruckLane: React.FC = () => { | |||
| } | |||
| }; | |||
| const handleOpenAddDialog = () => { | |||
| setNewTruck({ | |||
| truckLanceCode: "", | |||
| departureTime: "", | |||
| storeId: "2F", | |||
| }); | |||
| setAddDialogOpen(true); | |||
| setError(null); | |||
| }; | |||
| const handleCloseAddDialog = () => { | |||
| setAddDialogOpen(false); | |||
| setNewTruck({ | |||
| truckLanceCode: "", | |||
| departureTime: "", | |||
| storeId: "2F", | |||
| }); | |||
| }; | |||
| const handleCreateTruck = async () => { | |||
| // Validate all required fields | |||
| const missingFields: string[] = []; | |||
| if (!newTruck.truckLanceCode.trim()) { | |||
| missingFields.push(t("TruckLance Code")); | |||
| } | |||
| if (!newTruck.departureTime) { | |||
| missingFields.push(t("Departure Time")); | |||
| } | |||
| if (missingFields.length > 0) { | |||
| const message = `${t("Please fill in the following required fields:")} ${missingFields.join(", ")}`; | |||
| setSnackbarMessage(message); | |||
| setSnackbarOpen(true); | |||
| return; | |||
| } | |||
| // Check if truckLanceCode already exists | |||
| const trimmedCode = newTruck.truckLanceCode.trim(); | |||
| const existingTruck = truckData.find( | |||
| (truck) => String(truck.truckLanceCode || "").trim().toLowerCase() === trimmedCode.toLowerCase() | |||
| ); | |||
| if (existingTruck) { | |||
| setSnackbarMessage(t("Truck lane code already exists. Please use a different code.")); | |||
| setSnackbarOpen(true); | |||
| return; | |||
| } | |||
| setSaving(true); | |||
| setError(null); | |||
| try { | |||
| await createTruckWithoutShopClient({ | |||
| store_id: newTruck.storeId, | |||
| truckLanceCode: newTruck.truckLanceCode.trim(), | |||
| departureTime: newTruck.departureTime.trim(), | |||
| loadingSequence: 0, | |||
| districtReference: null, | |||
| remark: null, | |||
| }); | |||
| // Refresh truck data after create | |||
| const data = await findAllUniqueTruckLaneCombinationsClient() as Truck[]; | |||
| const uniqueCodes = new Map<string, Truck>(); | |||
| data.forEach((truck) => { | |||
| const code = String(truck.truckLanceCode ?? "").trim(); | |||
| if (code && !uniqueCodes.has(code)) { | |||
| uniqueCodes.set(code, truck); | |||
| } | |||
| }); | |||
| setTruckData(Array.from(uniqueCodes.values())); | |||
| handleCloseAddDialog(); | |||
| } catch (err: unknown) { | |||
| console.error("Failed to create truck:", err); | |||
| const errorMessage = err instanceof Error ? err.message : String(err); | |||
| setError(errorMessage || t("Failed to create truck")); | |||
| } finally { | |||
| setSaving(false); | |||
| } | |||
| }; | |||
| if (loading) { | |||
| return ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}> | |||
| @@ -198,16 +273,34 @@ const TruckLane: React.FC = () => { | |||
| <Card> | |||
| <CardContent> | |||
| <Typography variant="h6" sx={{ mb: 2 }}>{t("Truck Lane")}</Typography> | |||
| <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | |||
| <Typography variant="h6">{t("Truck Lane")}</Typography> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<AddIcon />} | |||
| onClick={handleOpenAddDialog} | |||
| disabled={saving} | |||
| > | |||
| {t("Add Truck Lane")} | |||
| </Button> | |||
| </Box> | |||
| <TableContainer component={Paper}> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("TruckLance Code")}</TableCell> | |||
| <TableCell>{t("Departure Time")}</TableCell> | |||
| <TableCell>{t("Store ID")}</TableCell> | |||
| <TableCell align="right">{t("Actions")}</TableCell> | |||
| <TableCell sx={{ width: "250px", minWidth: "250px", maxWidth: "250px" }}> | |||
| {t("TruckLance Code")} | |||
| </TableCell> | |||
| <TableCell sx={{ width: "200px", minWidth: "200px", maxWidth: "200px" }}> | |||
| {t("Departure Time")} | |||
| </TableCell> | |||
| <TableCell sx={{ width: "150px", minWidth: "150px", maxWidth: "150px" }}> | |||
| {t("Store ID")} | |||
| </TableCell> | |||
| <TableCell align="right" sx={{ width: "150px", minWidth: "150px", maxWidth: "150px" }}> | |||
| {t("Actions")} | |||
| </TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| @@ -220,40 +313,36 @@ const TruckLane: React.FC = () => { | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| paginatedRows.map((truck, index) => { | |||
| const storeId = truck.storeId; | |||
| const storeIdStr = storeId ? (typeof storeId === 'string' ? storeId : String(storeId)) : "-"; | |||
| const displayStoreId = storeIdStr === "2" || storeIdStr === "2F" ? "2F" | |||
| : storeIdStr === "4" || storeIdStr === "4F" ? "4F" | |||
| : storeIdStr; | |||
| return ( | |||
| <TableRow key={truck.id ?? `truck-${index}`}> | |||
| <TableCell> | |||
| {String(truck.truckLanceCode || "-")} | |||
| </TableCell> | |||
| <TableCell> | |||
| {formatDepartureTime( | |||
| Array.isArray(truck.departureTime) | |||
| ? truck.departureTime | |||
| : (truck.departureTime ? String(truck.departureTime) : null) | |||
| )} | |||
| </TableCell> | |||
| <TableCell> | |||
| {displayStoreId} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| onClick={() => handleViewDetail(truck)} | |||
| > | |||
| {t("View Detail")} | |||
| </Button> | |||
| </TableCell> | |||
| </TableRow> | |||
| ); | |||
| }) | |||
| paginatedRows.map((truck) => ( | |||
| <TableRow key={truck.id ?? `truck-${truck.truckLanceCode}`}> | |||
| <TableCell sx={{ width: "250px", minWidth: "250px", maxWidth: "250px" }}> | |||
| {String(truck.truckLanceCode ?? "-")} | |||
| </TableCell> | |||
| <TableCell sx={{ width: "200px", minWidth: "200px", maxWidth: "200px" }}> | |||
| {formatDepartureTime( | |||
| Array.isArray(truck.departureTime) | |||
| ? truck.departureTime | |||
| : (truck.departureTime ? String(truck.departureTime) : null) | |||
| )} | |||
| </TableCell> | |||
| <TableCell sx={{ width: "150px", minWidth: "150px", maxWidth: "150px" }}> | |||
| {normalizeStoreId( | |||
| truck.storeId ? (typeof truck.storeId === 'string' || truck.storeId instanceof String | |||
| ? String(truck.storeId) | |||
| : String(truck.storeId)) : null | |||
| )} | |||
| </TableCell> | |||
| <TableCell align="right" sx={{ width: "150px", minWidth: "150px", maxWidth: "150px" }}> | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| onClick={() => handleViewDetail(truck)} | |||
| > | |||
| {t("View Detail")} | |||
| </Button> | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| @@ -269,6 +358,92 @@ const TruckLane: React.FC = () => { | |||
| </TableContainer> | |||
| </CardContent> | |||
| </Card> | |||
| {/* Add Truck Dialog */} | |||
| <Dialog open={addDialogOpen} onClose={handleCloseAddDialog} maxWidth="sm" fullWidth> | |||
| <DialogTitle>{t("Add New Truck Lane")}</DialogTitle> | |||
| <DialogContent> | |||
| <Box sx={{ pt: 2 }}> | |||
| <Grid container spacing={2}> | |||
| <Grid item xs={12}> | |||
| <TextField | |||
| label={t("TruckLance Code")} | |||
| fullWidth | |||
| required | |||
| value={newTruck.truckLanceCode} | |||
| onChange={(e) => setNewTruck({ ...newTruck, truckLanceCode: e.target.value })} | |||
| disabled={saving} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <TextField | |||
| label={t("Departure Time")} | |||
| type="time" | |||
| fullWidth | |||
| required | |||
| value={newTruck.departureTime} | |||
| onChange={(e) => setNewTruck({ ...newTruck, departureTime: e.target.value })} | |||
| disabled={saving} | |||
| InputLabelProps={{ | |||
| shrink: true, | |||
| }} | |||
| inputProps={{ | |||
| step: 300, // 5 minutes | |||
| }} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <FormControl fullWidth> | |||
| <InputLabel>{t("Store ID")}</InputLabel> | |||
| <Select | |||
| value={newTruck.storeId} | |||
| label={t("Store ID")} | |||
| onChange={(e) => { | |||
| setNewTruck({ | |||
| ...newTruck, | |||
| storeId: e.target.value | |||
| }); | |||
| }} | |||
| disabled={saving} | |||
| > | |||
| <MenuItem value="2F">2F</MenuItem> | |||
| <MenuItem value="4F">4F</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| </Grid> | |||
| </Grid> | |||
| </Box> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={handleCloseAddDialog} disabled={saving}> | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button | |||
| onClick={handleCreateTruck} | |||
| variant="contained" | |||
| startIcon={<SaveIcon />} | |||
| disabled={saving} | |||
| > | |||
| {saving ? t("Submitting...") : t("Save")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| {/* Snackbar for notifications */} | |||
| <Snackbar | |||
| open={snackbarOpen} | |||
| autoHideDuration={6000} | |||
| onClose={() => setSnackbarOpen(false)} | |||
| anchorOrigin={{ vertical: 'top', horizontal: 'center' }} | |||
| > | |||
| <Alert | |||
| onClose={() => setSnackbarOpen(false)} | |||
| severity="warning" | |||
| sx={{ width: '100%' }} | |||
| > | |||
| {snackbarMessage} | |||
| </Alert> | |||
| </Snackbar> | |||
| </Box> | |||
| ); | |||
| }; | |||
| @@ -19,42 +19,35 @@ import { | |||
| IconButton, | |||
| Snackbar, | |||
| TextField, | |||
| Autocomplete, | |||
| Dialog, | |||
| DialogTitle, | |||
| DialogContent, | |||
| DialogActions, | |||
| } from "@mui/material"; | |||
| import DeleteIcon from "@mui/icons-material/Delete"; | |||
| import EditIcon from "@mui/icons-material/Edit"; | |||
| import SaveIcon from "@mui/icons-material/Save"; | |||
| import CancelIcon from "@mui/icons-material/Cancel"; | |||
| import AddIcon from "@mui/icons-material/Add"; | |||
| import { useState, useEffect } from "react"; | |||
| import { useRouter, useSearchParams } from "next/navigation"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { findAllUniqueTruckLaneCombinationsClient, findAllShopsByTruckLanceCodeClient, deleteTruckLaneClient, updateLoadingSequenceClient } from "@/app/api/shop/client"; | |||
| import type { Truck, ShopAndTruck } from "@/app/api/shop/actions"; | |||
| // Utility function to format departureTime to HH:mm format | |||
| const formatDepartureTime = (time: string | number[] | null | undefined): string => { | |||
| if (!time) return "-"; | |||
| // Handle array format [hours, minutes] from API | |||
| if (Array.isArray(time) && time.length >= 2) { | |||
| const hours = time[0]; | |||
| const minutes = time[1]; | |||
| if (typeof hours === 'number' && typeof minutes === 'number' && | |||
| hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) { | |||
| return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; | |||
| } | |||
| } | |||
| const timeStr = String(time).trim(); | |||
| if (!timeStr || timeStr === "-") return "-"; | |||
| // If already in HH:mm format, return as is | |||
| if (/^\d{1,2}:\d{2}$/.test(timeStr)) { | |||
| const [hours, minutes] = timeStr.split(":"); | |||
| return `${hours.padStart(2, "0")}:${minutes.padStart(2, "0")}`; | |||
| } | |||
| return timeStr; | |||
| }; | |||
| import { | |||
| findAllUniqueTruckLaneCombinationsClient, | |||
| findAllShopsByTruckLanceCodeClient, | |||
| deleteTruckLaneClient, | |||
| updateTruckShopDetailsClient, | |||
| fetchAllShopsClient, | |||
| findAllUniqueShopNamesAndCodesFromTrucksClient, | |||
| findAllUniqueRemarksFromTrucksClient, | |||
| findAllUniqueShopCodesFromTrucksClient, | |||
| findAllUniqueShopNamesFromTrucksClient, | |||
| createTruckClient, | |||
| findAllByTruckLanceCodeAndDeletedFalseClient, | |||
| } from "@/app/api/shop/client"; | |||
| import type { Truck, ShopAndTruck, Shop } from "@/app/api/shop/actions"; | |||
| import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil"; | |||
| const TruckLaneDetail: React.FC = () => { | |||
| const { t } = useTranslation("common"); | |||
| @@ -72,12 +65,54 @@ const TruckLaneDetail: React.FC = () => { | |||
| const [shopsLoading, setShopsLoading] = useState<boolean>(false); | |||
| const [saving, setSaving] = useState<boolean>(false); | |||
| const [error, setError] = useState<string | null>(null); | |||
| const [allShops, setAllShops] = useState<Shop[]>([]); | |||
| const [uniqueRemarks, setUniqueRemarks] = useState<string[]>([]); | |||
| const [uniqueShopCodes, setUniqueShopCodes] = useState<string[]>([]); | |||
| const [uniqueShopNames, setUniqueShopNames] = useState<string[]>([]); | |||
| const [addShopDialogOpen, setAddShopDialogOpen] = useState<boolean>(false); | |||
| const [newShop, setNewShop] = useState({ | |||
| shopName: "", | |||
| shopCode: "", | |||
| loadingSequence: 0, | |||
| remark: "", | |||
| }); | |||
| const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: "success" | "error" }>({ | |||
| open: false, | |||
| message: "", | |||
| severity: "success", | |||
| }); | |||
| // Fetch autocomplete data on mount | |||
| useEffect(() => { | |||
| const fetchAutocompleteData = async () => { | |||
| try { | |||
| const [shopData, remarks, codes, names] = await Promise.all([ | |||
| findAllUniqueShopNamesAndCodesFromTrucksClient() as Promise<Array<{ name: string; code: string }>>, | |||
| findAllUniqueRemarksFromTrucksClient() as Promise<string[]>, | |||
| findAllUniqueShopCodesFromTrucksClient() as Promise<string[]>, | |||
| findAllUniqueShopNamesFromTrucksClient() as Promise<string[]>, | |||
| ]); | |||
| // Convert to Shop format (id will be 0 since we don't have shop IDs from truck table) | |||
| const shopList: Shop[] = shopData.map((shop) => ({ | |||
| id: 0, | |||
| name: shop.name || "", | |||
| code: shop.code || "", | |||
| addr3: "", | |||
| })); | |||
| setAllShops(shopList); | |||
| setUniqueRemarks(remarks || []); | |||
| setUniqueShopCodes(codes || []); | |||
| setUniqueShopNames(names || []); | |||
| } catch (err) { | |||
| console.error("Failed to load autocomplete data:", err); | |||
| } | |||
| }; | |||
| fetchAutocompleteData(); | |||
| }, []); | |||
| useEffect(() => { | |||
| // Wait a bit to ensure searchParams are fully available | |||
| if (!truckLanceCodeParam) { | |||
| @@ -183,28 +218,55 @@ const TruckLaneDetail: React.FC = () => { | |||
| setSaving(true); | |||
| setError(null); | |||
| try { | |||
| // Get LoadingSequence from edited data - handle both PascalCase and camelCase | |||
| // Get values from edited data | |||
| const editedShop = editedShopsData[index]; | |||
| const loadingSeq = (editedShop as any)?.LoadingSequence ?? (editedShop as any)?.loadingSequence; | |||
| const loadingSequenceValue = (loadingSeq !== null && loadingSeq !== undefined) ? Number(loadingSeq) : 0; | |||
| // Get shopName and shopCode from edited data | |||
| const shopNameValue = editedShop.name ? String(editedShop.name).trim() : null; | |||
| const shopCodeValue = editedShop.code ? String(editedShop.code).trim() : null; | |||
| const remarkValue = editedShop.remark ? String(editedShop.remark).trim() : null; | |||
| // Get shopId from editedShop.id (which was set when shopName or shopCode was selected) | |||
| // If not found, try to find it from shop table by shopCode | |||
| let shopIdValue: number | null = null; | |||
| if (editedShop.id && editedShop.id > 0) { | |||
| shopIdValue = editedShop.id; | |||
| } else if (shopCodeValue) { | |||
| // If shopId is 0 (from truck table), try to find it from shop table | |||
| try { | |||
| const allShopsFromShopTable = await fetchAllShopsClient() as ShopAndTruck[]; | |||
| const foundShop = allShopsFromShopTable.find(s => String(s.code).trim() === shopCodeValue); | |||
| if (foundShop) { | |||
| shopIdValue = foundShop.id; | |||
| } | |||
| } catch (err) { | |||
| console.error("Failed to lookup shopId:", err); | |||
| } | |||
| } | |||
| if (!shop.truckId) { | |||
| setSnackbar({ | |||
| open: true, | |||
| message: "Truck ID is required", | |||
| message: t("Truck ID is required"), | |||
| severity: "error", | |||
| }); | |||
| return; | |||
| } | |||
| await updateLoadingSequenceClient({ | |||
| await updateTruckShopDetailsClient({ | |||
| id: shop.truckId, | |||
| shopId: shopIdValue, | |||
| shopName: shopNameValue, | |||
| shopCode: shopCodeValue, | |||
| loadingSequence: loadingSequenceValue, | |||
| remark: remarkValue || null, | |||
| }); | |||
| setSnackbar({ | |||
| open: true, | |||
| message: t("Loading sequence updated successfully"), | |||
| message: t("Truck shop details updated successfully"), | |||
| severity: "success", | |||
| }); | |||
| @@ -214,10 +276,10 @@ const TruckLaneDetail: React.FC = () => { | |||
| } | |||
| setEditingRowIndex(null); | |||
| } catch (err: any) { | |||
| console.error("Failed to save loading sequence:", err); | |||
| console.error("Failed to save truck shop details:", err); | |||
| setSnackbar({ | |||
| open: true, | |||
| message: err?.message ?? String(err) ?? t("Failed to save loading sequence"), | |||
| message: err?.message ?? String(err) ?? t("Failed to save truck shop details"), | |||
| severity: "error", | |||
| }); | |||
| } finally { | |||
| @@ -235,6 +297,53 @@ const TruckLaneDetail: React.FC = () => { | |||
| setEditedShopsData(updated); | |||
| }; | |||
| const handleShopNameChange = (index: number, shop: Shop | null) => { | |||
| const updated = [...editedShopsData]; | |||
| if (shop) { | |||
| updated[index] = { | |||
| ...updated[index], | |||
| name: shop.name, | |||
| code: shop.code, | |||
| id: shop.id, // Store shopId for later use | |||
| }; | |||
| } else { | |||
| updated[index] = { | |||
| ...updated[index], | |||
| name: "", | |||
| code: "", | |||
| }; | |||
| } | |||
| setEditedShopsData(updated); | |||
| }; | |||
| const handleShopCodeChange = (index: number, shop: Shop | null) => { | |||
| const updated = [...editedShopsData]; | |||
| if (shop) { | |||
| updated[index] = { | |||
| ...updated[index], | |||
| name: shop.name, | |||
| code: shop.code, | |||
| id: shop.id, // Store shopId for later use | |||
| }; | |||
| } else { | |||
| updated[index] = { | |||
| ...updated[index], | |||
| name: "", | |||
| code: "", | |||
| }; | |||
| } | |||
| setEditedShopsData(updated); | |||
| }; | |||
| const handleRemarkChange = (index: number, value: string) => { | |||
| const updated = [...editedShopsData]; | |||
| updated[index] = { | |||
| ...updated[index], | |||
| remark: value, | |||
| }; | |||
| setEditedShopsData(updated); | |||
| }; | |||
| const handleDelete = async (truckIdToDelete: number) => { | |||
| if (!window.confirm(t("Are you sure you want to delete this truck lane?"))) { | |||
| return; | |||
| @@ -263,7 +372,213 @@ const TruckLaneDetail: React.FC = () => { | |||
| }; | |||
| const handleBack = () => { | |||
| router.push("/settings/shop"); | |||
| router.push("/settings/shop?tab=1"); | |||
| }; | |||
| const handleOpenAddShopDialog = () => { | |||
| setNewShop({ | |||
| shopName: "", | |||
| shopCode: "", | |||
| loadingSequence: 0, | |||
| remark: "", | |||
| }); | |||
| setAddShopDialogOpen(true); | |||
| setError(null); | |||
| }; | |||
| const handleCloseAddShopDialog = () => { | |||
| setAddShopDialogOpen(false); | |||
| setNewShop({ | |||
| shopName: "", | |||
| shopCode: "", | |||
| loadingSequence: 0, | |||
| remark: "", | |||
| }); | |||
| }; | |||
| const handleNewShopNameChange = (newValue: string | null) => { | |||
| if (newValue && typeof newValue === 'string') { | |||
| // When a name is selected, try to find matching shop code | |||
| const matchingShop = allShops.find(s => String(s.name) === newValue); | |||
| if (matchingShop) { | |||
| setNewShop({ | |||
| ...newShop, | |||
| shopName: newValue, | |||
| shopCode: String(matchingShop.code || ""), | |||
| }); | |||
| } else { | |||
| // If no matching shop found, allow free text input for shop name | |||
| setNewShop({ | |||
| ...newShop, | |||
| shopName: newValue, | |||
| }); | |||
| } | |||
| } else { | |||
| // Clear shop name when selection is cleared (but keep shop code if it exists) | |||
| setNewShop({ | |||
| ...newShop, | |||
| shopName: "", | |||
| }); | |||
| } | |||
| }; | |||
| const handleNewShopCodeChange = (newValue: string | null) => { | |||
| if (newValue && typeof newValue === 'string') { | |||
| // When a code is selected, try to find matching shop name | |||
| const matchingShop = allShops.find(s => String(s.code) === newValue); | |||
| if (matchingShop) { | |||
| setNewShop({ | |||
| ...newShop, | |||
| shopCode: newValue, | |||
| shopName: String(matchingShop.name || ""), | |||
| }); | |||
| } else { | |||
| // If no matching shop found, still set the code (shouldn't happen with restricted selection) | |||
| setNewShop({ | |||
| ...newShop, | |||
| shopCode: newValue, | |||
| }); | |||
| } | |||
| } else { | |||
| // Clear both fields when selection is cleared | |||
| setNewShop({ | |||
| ...newShop, | |||
| shopName: "", | |||
| shopCode: "", | |||
| }); | |||
| } | |||
| }; | |||
| const handleCreateShop = async () => { | |||
| // Validate required fields | |||
| const missingFields: string[] = []; | |||
| if (!newShop.shopName.trim()) { | |||
| missingFields.push(t("Shop Name")); | |||
| } | |||
| if (!newShop.shopCode.trim()) { | |||
| missingFields.push(t("Shop Code")); | |||
| } | |||
| if (missingFields.length > 0) { | |||
| setSnackbar({ | |||
| open: true, | |||
| message: `${t("Please fill in the following required fields:")} ${missingFields.join(", ")}`, | |||
| severity: "error", | |||
| }); | |||
| return; | |||
| } | |||
| if (!truckData || !truckLanceCode) { | |||
| setSnackbar({ | |||
| open: true, | |||
| message: t("Truck lane information is required"), | |||
| severity: "error", | |||
| }); | |||
| return; | |||
| } | |||
| setSaving(true); | |||
| setError(null); | |||
| try { | |||
| // Get storeId from truckData | |||
| const storeIdValue = truckData.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String | |||
| ? String(truckData.storeId) | |||
| : String(truckData.storeId)) : "2F"; | |||
| const displayStoreId = normalizeStoreId(storeIdValue) || "2F"; | |||
| // Get departureTime from truckData | |||
| let departureTimeStr = ""; | |||
| if (truckData.departureTime) { | |||
| if (Array.isArray(truckData.departureTime)) { | |||
| const [hours, minutes] = truckData.departureTime; | |||
| departureTimeStr = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`; | |||
| } else { | |||
| departureTimeStr = String(truckData.departureTime); | |||
| } | |||
| } | |||
| // Look up shopId from shop table by shopCode | |||
| let shopIdValue: number | null = null; | |||
| try { | |||
| const allShopsFromShopTable = await fetchAllShopsClient() as ShopAndTruck[]; | |||
| const foundShop = allShopsFromShopTable.find(s => String(s.code).trim() === newShop.shopCode.trim()); | |||
| if (foundShop) { | |||
| shopIdValue = foundShop.id; | |||
| } | |||
| } catch (err) { | |||
| console.error("Failed to lookup shopId:", err); | |||
| } | |||
| // Get remark - only if storeId is "4F" | |||
| const remarkValue = displayStoreId === "4F" ? (newShop.remark?.trim() || null) : null; | |||
| // Check if there's an "Unassign" row for this truck lane that should be replaced | |||
| let unassignTruck: Truck | null = null; | |||
| try { | |||
| const allTrucks = await findAllByTruckLanceCodeAndDeletedFalseClient(String(truckData.truckLanceCode || "")) as Truck[]; | |||
| unassignTruck = allTrucks.find(t => | |||
| String(t.shopName || "").trim() === "Unassign" && | |||
| String(t.shopCode || "").trim() === "Unassign" | |||
| ) || null; | |||
| } catch (err) { | |||
| console.error("Failed to check for Unassign truck:", err); | |||
| } | |||
| if (unassignTruck && unassignTruck.id) { | |||
| // Update the existing "Unassign" row instead of creating a new one | |||
| await updateTruckShopDetailsClient({ | |||
| id: unassignTruck.id, | |||
| shopId: shopIdValue || null, | |||
| shopName: newShop.shopName.trim(), | |||
| shopCode: newShop.shopCode.trim(), | |||
| loadingSequence: newShop.loadingSequence, | |||
| remark: remarkValue, | |||
| }); | |||
| setSnackbar({ | |||
| open: true, | |||
| message: t("Shop added to truck lane successfully"), | |||
| severity: "success", | |||
| }); | |||
| } else { | |||
| // No "Unassign" row found, create a new one | |||
| await createTruckClient({ | |||
| store_id: displayStoreId, | |||
| truckLanceCode: String(truckData.truckLanceCode || ""), | |||
| departureTime: departureTimeStr, | |||
| shopId: shopIdValue || 0, | |||
| shopName: newShop.shopName.trim(), | |||
| shopCode: newShop.shopCode.trim(), | |||
| loadingSequence: newShop.loadingSequence, | |||
| remark: remarkValue, | |||
| districtReference: null, | |||
| }); | |||
| setSnackbar({ | |||
| open: true, | |||
| message: t("Shop added to truck lane successfully"), | |||
| severity: "success", | |||
| }); | |||
| } | |||
| // Refresh the shops list | |||
| if (truckLanceCode) { | |||
| await fetchShopsByTruckLane(truckLanceCode); | |||
| } | |||
| handleCloseAddShopDialog(); | |||
| } catch (err: any) { | |||
| console.error("Failed to create shop in truck lane:", err); | |||
| setSnackbar({ | |||
| open: true, | |||
| message: err?.message ?? String(err) ?? t("Failed to create shop in truck lane"), | |||
| severity: "error", | |||
| }); | |||
| } finally { | |||
| setSaving(false); | |||
| } | |||
| }; | |||
| if (loading) { | |||
| @@ -300,11 +615,11 @@ const TruckLaneDetail: React.FC = () => { | |||
| ); | |||
| } | |||
| const storeId = truckData.storeId; | |||
| const storeIdStr = storeId ? (typeof storeId === 'string' ? storeId : String(storeId)) : "-"; | |||
| const displayStoreId = storeIdStr === "2" || storeIdStr === "2F" ? "2F" | |||
| : storeIdStr === "4" || storeIdStr === "4F" ? "4F" | |||
| : storeIdStr; | |||
| const displayStoreId = normalizeStoreId( | |||
| truckData.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String | |||
| ? String(truckData.storeId) | |||
| : String(truckData.storeId)) : null | |||
| ); | |||
| return ( | |||
| <Box> | |||
| @@ -323,7 +638,7 @@ const TruckLaneDetail: React.FC = () => { | |||
| <CardContent> | |||
| <Paper sx={{ p: 3 }}> | |||
| <Grid container spacing={3}> | |||
| <Grid item xs={12} sm={6}> | |||
| <Grid item xs={12} sm={4}> | |||
| <Typography variant="subtitle2" color="text.secondary"> | |||
| {t("TruckLance Code")} | |||
| </Typography> | |||
| @@ -332,7 +647,7 @@ const TruckLaneDetail: React.FC = () => { | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={12} sm={6}> | |||
| <Grid item xs={12} sm={4}> | |||
| <Typography variant="subtitle2" color="text.secondary"> | |||
| {t("Departure Time")} | |||
| </Typography> | |||
| @@ -345,7 +660,7 @@ const TruckLaneDetail: React.FC = () => { | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={12} sm={6}> | |||
| <Grid item xs={12} sm={4}> | |||
| <Typography variant="subtitle2" color="text.secondary"> | |||
| {t("Store ID")} | |||
| </Typography> | |||
| @@ -361,9 +676,19 @@ const TruckLaneDetail: React.FC = () => { | |||
| <Card sx={{ mt: 2 }}> | |||
| <CardContent> | |||
| <Typography variant="h6" sx={{ mb: 2 }}> | |||
| {t("Shops Using This Truck Lane")} | |||
| </Typography> | |||
| <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | |||
| <Typography variant="h6"> | |||
| {t("Shops Using This Truck Lane")} | |||
| </Typography> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<AddIcon />} | |||
| onClick={handleOpenAddShopDialog} | |||
| disabled={saving || editingRowIndex !== null} | |||
| > | |||
| {t("Add Shop")} | |||
| </Button> | |||
| </Box> | |||
| {shopsLoading ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}> | |||
| @@ -394,13 +719,144 @@ const TruckLaneDetail: React.FC = () => { | |||
| shopsData.map((shop, index) => ( | |||
| <TableRow key={shop.id ?? `shop-${index}`}> | |||
| <TableCell> | |||
| {String(shop.name || "-")} | |||
| {editingRowIndex === index ? ( | |||
| <Autocomplete | |||
| freeSolo | |||
| size="small" | |||
| options={uniqueShopNames} | |||
| value={String(editedShopsData[index]?.name || "")} | |||
| onChange={(event, newValue) => { | |||
| if (newValue && typeof newValue === 'string') { | |||
| // When a name is selected, try to find matching shop code | |||
| const matchingShop = allShops.find(s => String(s.name) === newValue); | |||
| if (matchingShop) { | |||
| handleShopNameChange(index, matchingShop); | |||
| } else { | |||
| // If no matching shop found, just update the name | |||
| const updated = [...editedShopsData]; | |||
| updated[index] = { | |||
| ...updated[index], | |||
| name: newValue, | |||
| }; | |||
| setEditedShopsData(updated); | |||
| } | |||
| } else if (newValue === null) { | |||
| handleShopNameChange(index, null); | |||
| } | |||
| }} | |||
| onInputChange={(event, newInputValue, reason) => { | |||
| if (reason === 'input') { | |||
| // Allow free text input | |||
| const updated = [...editedShopsData]; | |||
| updated[index] = { | |||
| ...updated[index], | |||
| name: newInputValue, | |||
| }; | |||
| setEditedShopsData(updated); | |||
| } | |||
| }} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| fullWidth | |||
| disabled={saving} | |||
| placeholder={t("Search or select shop name")} | |||
| /> | |||
| )} | |||
| /> | |||
| ) : ( | |||
| String(shop.name || "-") | |||
| )} | |||
| </TableCell> | |||
| <TableCell> | |||
| {String(shop.code || "-")} | |||
| {editingRowIndex === index ? ( | |||
| <Autocomplete | |||
| freeSolo | |||
| size="small" | |||
| options={uniqueShopCodes} | |||
| value={String(editedShopsData[index]?.code || "")} | |||
| onChange={(event, newValue) => { | |||
| if (newValue && typeof newValue === 'string') { | |||
| // When a code is selected, try to find matching shop name | |||
| const matchingShop = allShops.find(s => String(s.code) === newValue); | |||
| if (matchingShop) { | |||
| handleShopCodeChange(index, matchingShop); | |||
| } else { | |||
| // If no matching shop found, just update the code | |||
| const updated = [...editedShopsData]; | |||
| updated[index] = { | |||
| ...updated[index], | |||
| code: newValue, | |||
| }; | |||
| setEditedShopsData(updated); | |||
| } | |||
| } else if (newValue === null) { | |||
| handleShopCodeChange(index, null); | |||
| } | |||
| }} | |||
| onInputChange={(event, newInputValue, reason) => { | |||
| if (reason === 'input') { | |||
| // Allow free text input | |||
| const updated = [...editedShopsData]; | |||
| updated[index] = { | |||
| ...updated[index], | |||
| code: newInputValue, | |||
| }; | |||
| setEditedShopsData(updated); | |||
| } | |||
| }} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| fullWidth | |||
| disabled={saving} | |||
| placeholder={t("Search or select shop code")} | |||
| /> | |||
| )} | |||
| /> | |||
| ) : ( | |||
| String(shop.code || "-") | |||
| )} | |||
| </TableCell> | |||
| <TableCell> | |||
| {String(shop.remark || "-")} | |||
| {editingRowIndex === index ? ( | |||
| (() => { | |||
| const storeIdValue = truckData.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String | |||
| ? String(truckData.storeId) | |||
| : String(truckData.storeId)) : null; | |||
| const isEditable = normalizeStoreId(storeIdValue) === "4F"; | |||
| return ( | |||
| <Autocomplete | |||
| freeSolo | |||
| size="small" | |||
| options={uniqueRemarks} | |||
| value={String(editedShopsData[index]?.remark || "")} | |||
| onChange={(event, newValue) => { | |||
| if (isEditable) { | |||
| const remarkValue = typeof newValue === 'string' ? newValue : (newValue || ""); | |||
| handleRemarkChange(index, remarkValue); | |||
| } | |||
| }} | |||
| onInputChange={(event, newInputValue, reason) => { | |||
| if (isEditable && reason === 'input') { | |||
| handleRemarkChange(index, newInputValue); | |||
| } | |||
| }} | |||
| disabled={saving || !isEditable} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| fullWidth | |||
| placeholder={isEditable ? t("Search or select remark") : t("Not editable for this Store ID")} | |||
| disabled={saving || !isEditable} | |||
| /> | |||
| )} | |||
| /> | |||
| ); | |||
| })() | |||
| ) : ( | |||
| String(shop.remark || "-") | |||
| )} | |||
| </TableCell> | |||
| <TableCell> | |||
| {editingRowIndex === index ? ( | |||
| @@ -454,7 +910,7 @@ const TruckLaneDetail: React.FC = () => { | |||
| size="small" | |||
| color="primary" | |||
| onClick={() => handleEdit(index)} | |||
| title={t("Edit loading sequence")} | |||
| title={t("Edit shop details")} | |||
| > | |||
| <EditIcon /> | |||
| </IconButton> | |||
| @@ -482,6 +938,117 @@ const TruckLaneDetail: React.FC = () => { | |||
| </CardContent> | |||
| </Card> | |||
| {/* Add Shop Dialog */} | |||
| <Dialog open={addShopDialogOpen} onClose={handleCloseAddShopDialog} maxWidth="sm" fullWidth> | |||
| <DialogTitle>{t("Add Shop to Truck Lane")}</DialogTitle> | |||
| <DialogContent> | |||
| <Box sx={{ pt: 2 }}> | |||
| <Grid container spacing={2}> | |||
| <Grid item xs={12}> | |||
| <Autocomplete | |||
| freeSolo | |||
| options={uniqueShopNames} | |||
| value={newShop.shopName || null} | |||
| onChange={(event, newValue) => { | |||
| handleNewShopNameChange(newValue); | |||
| }} | |||
| onInputChange={(event, newInputValue, reason) => { | |||
| if (reason === 'input') { | |||
| // Allow free text input for shop name | |||
| setNewShop({ ...newShop, shopName: newInputValue }); | |||
| } | |||
| }} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| label={t("Shop Name")} | |||
| fullWidth | |||
| required | |||
| disabled={saving} | |||
| placeholder={t("Search or select shop name")} | |||
| /> | |||
| )} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <Autocomplete | |||
| options={uniqueShopCodes} | |||
| value={newShop.shopCode || null} | |||
| onChange={(event, newValue) => { | |||
| handleNewShopCodeChange(newValue); | |||
| }} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| label={t("Shop Code")} | |||
| fullWidth | |||
| required | |||
| disabled={saving} | |||
| placeholder={t("Search or select shop code")} | |||
| /> | |||
| )} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <TextField | |||
| label={t("Loading Sequence")} | |||
| type="number" | |||
| fullWidth | |||
| value={newShop.loadingSequence} | |||
| onChange={(e) => setNewShop({ ...newShop, loadingSequence: parseInt(e.target.value) || 0 })} | |||
| disabled={saving} | |||
| /> | |||
| </Grid> | |||
| {(() => { | |||
| const storeIdValue = truckData?.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String | |||
| ? String(truckData.storeId) | |||
| : String(truckData.storeId)) : null; | |||
| const isEditable = normalizeStoreId(storeIdValue) === "4F"; | |||
| return isEditable ? ( | |||
| <Grid item xs={12}> | |||
| <Autocomplete | |||
| freeSolo | |||
| options={uniqueRemarks} | |||
| value={newShop.remark || ""} | |||
| onChange={(event, newValue) => { | |||
| setNewShop({ ...newShop, remark: typeof newValue === 'string' ? newValue : (newValue || "") }); | |||
| }} | |||
| onInputChange={(event, newInputValue, reason) => { | |||
| if (reason === 'input') { | |||
| setNewShop({ ...newShop, remark: newInputValue }); | |||
| } | |||
| }} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| label={t("Remark")} | |||
| fullWidth | |||
| disabled={saving} | |||
| placeholder={t("Search or select remark")} | |||
| /> | |||
| )} | |||
| /> | |||
| </Grid> | |||
| ) : null; | |||
| })()} | |||
| </Grid> | |||
| </Box> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={handleCloseAddShopDialog} disabled={saving}> | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button | |||
| onClick={handleCreateShop} | |||
| variant="contained" | |||
| startIcon={<SaveIcon />} | |||
| disabled={saving} | |||
| > | |||
| {saving ? t("Submitting...") : t("Save")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| <Snackbar | |||
| open={snackbar.open} | |||
| autoHideDuration={6000} | |||
| @@ -492,6 +1059,4 @@ const TruckLaneDetail: React.FC = () => { | |||
| ); | |||
| }; | |||
| export default TruckLaneDetail; | |||
| export default TruckLaneDetail; | |||
| @@ -0,0 +1,364 @@ | |||
| "use client"; | |||
| import { useCallback, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import SearchResults, { Column } from "../SearchResults/index"; | |||
| import DeleteIcon from "@mui/icons-material/Delete"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; | |||
| import { WarehouseResult } from "@/app/api/warehouse"; | |||
| import { deleteWarehouse } from "@/app/api/warehouse/actions"; | |||
| import Card from "@mui/material/Card"; | |||
| import CardContent from "@mui/material/CardContent"; | |||
| import CardActions from "@mui/material/CardActions"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import TextField from "@mui/material/TextField"; | |||
| import Button from "@mui/material/Button"; | |||
| import Box from "@mui/material/Box"; | |||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | |||
| import Search from "@mui/icons-material/Search"; | |||
| import InputAdornment from "@mui/material/InputAdornment"; | |||
| interface Props { | |||
| warehouses: WarehouseResult[]; | |||
| } | |||
| type SearchQuery = Partial<Omit<WarehouseResult, "id">>; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| const { t } = useTranslation(["warehouse", "common"]); | |||
| const [filteredWarehouse, setFilteredWarehouse] = useState(warehouses); | |||
| const [pagingController, setPagingController] = useState({ | |||
| pageNum: 1, | |||
| pageSize: 10, | |||
| }); | |||
| const router = useRouter(); | |||
| const [isSearching, setIsSearching] = useState(false); | |||
| const [searchInputs, setSearchInputs] = useState({ | |||
| store_id: "", | |||
| warehouse: "", | |||
| area: "", | |||
| slot: "", | |||
| stockTakeSection: "", | |||
| }); | |||
| const onDeleteClick = useCallback((warehouse: WarehouseResult) => { | |||
| deleteDialog(async () => { | |||
| try { | |||
| await deleteWarehouse(warehouse.id); | |||
| setFilteredWarehouse(prev => prev.filter(w => w.id !== warehouse.id)); | |||
| router.refresh(); | |||
| successDialog(t("Delete Success"), t); | |||
| } catch (error) { | |||
| console.error("Failed to delete warehouse:", error); | |||
| // Don't redirect on error, just show error message | |||
| // The error will be logged but user stays on the page | |||
| } | |||
| }, t); | |||
| }, [t, router]); | |||
| const handleReset = useCallback(() => { | |||
| setSearchInputs({ | |||
| store_id: "", | |||
| warehouse: "", | |||
| area: "", | |||
| slot: "", | |||
| stockTakeSection: "", | |||
| }); | |||
| setFilteredWarehouse(warehouses); | |||
| setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); | |||
| }, [warehouses, pagingController.pageSize]); | |||
| const handleSearch = useCallback(() => { | |||
| setIsSearching(true); | |||
| try { | |||
| let results: WarehouseResult[] = warehouses; | |||
| // Build search pattern from the four fields: store_idF-warehouse-area-slot | |||
| // Only search by code field - match the code that follows this pattern | |||
| const storeId = searchInputs.store_id?.trim() || ""; | |||
| const warehouse = searchInputs.warehouse?.trim() || ""; | |||
| const area = searchInputs.area?.trim() || ""; | |||
| const slot = searchInputs.slot?.trim() || ""; | |||
| const stockTakeSection = searchInputs.stockTakeSection?.trim() || ""; | |||
| // If any field has a value, filter by code pattern and stockTakeSection | |||
| if (storeId || warehouse || area || slot || stockTakeSection) { | |||
| results = warehouses.filter((warehouseItem) => { | |||
| // Filter by stockTakeSection if provided | |||
| if (stockTakeSection) { | |||
| const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase(); | |||
| if (!itemStockTakeSection.includes(stockTakeSection.toLowerCase())) { | |||
| return false; | |||
| } | |||
| } | |||
| // Filter by code pattern if any code-related field is provided | |||
| if (storeId || warehouse || area || slot) { | |||
| if (!warehouseItem.code) { | |||
| return false; | |||
| } | |||
| const codeValue = String(warehouseItem.code).toLowerCase(); | |||
| // Check if code matches the pattern: store_id-warehouse-area-slot | |||
| // Match each part if provided | |||
| const codeParts = codeValue.split("-"); | |||
| if (codeParts.length >= 4) { | |||
| const codeStoreId = codeParts[0] || ""; | |||
| const codeWarehouse = codeParts[1] || ""; | |||
| const codeArea = codeParts[2] || ""; | |||
| const codeSlot = codeParts[3] || ""; | |||
| const storeIdMatch = !storeId || codeStoreId.includes(storeId.toLowerCase()); | |||
| const warehouseMatch = !warehouse || codeWarehouse.includes(warehouse.toLowerCase()); | |||
| const areaMatch = !area || codeArea.includes(area.toLowerCase()); | |||
| const slotMatch = !slot || codeSlot.includes(slot.toLowerCase()); | |||
| return storeIdMatch && warehouseMatch && areaMatch && slotMatch; | |||
| } | |||
| // Fallback: if code doesn't follow the pattern, check if it contains any of the search terms | |||
| const storeIdMatch = !storeId || codeValue.includes(storeId.toLowerCase()); | |||
| const warehouseMatch = !warehouse || codeValue.includes(warehouse.toLowerCase()); | |||
| const areaMatch = !area || codeValue.includes(area.toLowerCase()); | |||
| const slotMatch = !slot || codeValue.includes(slot.toLowerCase()); | |||
| return storeIdMatch && warehouseMatch && areaMatch && slotMatch; | |||
| } | |||
| // If only stockTakeSection is provided, return true (already filtered above) | |||
| return true; | |||
| }); | |||
| } else { | |||
| // If no search terms, show all warehouses | |||
| results = warehouses; | |||
| } | |||
| setFilteredWarehouse(results); | |||
| setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); | |||
| } catch (error) { | |||
| console.error("Error searching warehouses:", error); | |||
| // Fallback: filter by code pattern and stockTakeSection | |||
| const storeId = searchInputs.store_id?.trim().toLowerCase() || ""; | |||
| const warehouse = searchInputs.warehouse?.trim().toLowerCase() || ""; | |||
| const area = searchInputs.area?.trim().toLowerCase() || ""; | |||
| const slot = searchInputs.slot?.trim().toLowerCase() || ""; | |||
| const stockTakeSection = searchInputs.stockTakeSection?.trim().toLowerCase() || ""; | |||
| setFilteredWarehouse( | |||
| warehouses.filter((warehouseItem) => { | |||
| // Filter by stockTakeSection if provided | |||
| if (stockTakeSection) { | |||
| const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase(); | |||
| if (!itemStockTakeSection.includes(stockTakeSection)) { | |||
| return false; | |||
| } | |||
| } | |||
| // Filter by code if any code-related field is provided | |||
| if (storeId || warehouse || area || slot) { | |||
| if (!warehouseItem.code) { | |||
| return false; | |||
| } | |||
| const codeValue = String(warehouseItem.code).toLowerCase(); | |||
| const codeParts = codeValue.split("-"); | |||
| if (codeParts.length >= 4) { | |||
| const storeIdMatch = !storeId || codeParts[0].includes(storeId); | |||
| const warehouseMatch = !warehouse || codeParts[1].includes(warehouse); | |||
| const areaMatch = !area || codeParts[2].includes(area); | |||
| const slotMatch = !slot || codeParts[3].includes(slot); | |||
| return storeIdMatch && warehouseMatch && areaMatch && slotMatch; | |||
| } | |||
| return (!storeId || codeValue.includes(storeId)) && | |||
| (!warehouse || codeValue.includes(warehouse)) && | |||
| (!area || codeValue.includes(area)) && | |||
| (!slot || codeValue.includes(slot)); | |||
| } | |||
| return true; | |||
| }) | |||
| ); | |||
| } finally { | |||
| setIsSearching(false); | |||
| } | |||
| }, [searchInputs, warehouses, pagingController.pageSize]); | |||
| const columns = useMemo<Column<WarehouseResult>[]>( | |||
| () => [ | |||
| { | |||
| name: "code", | |||
| label: t("code"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "store_id", | |||
| label: t("store_id"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "warehouse", | |||
| label: t("warehouse"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "area", | |||
| label: t("area"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "slot", | |||
| label: t("slot"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "order", | |||
| label: t("order"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "stockTakeSection", | |||
| label: t("stockTakeSection"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "action", | |||
| label: t("Delete"), | |||
| onClick: onDeleteClick, | |||
| buttonIcon: <DeleteIcon />, | |||
| color: "error", | |||
| sx: { width: "10%", minWidth: "80px" }, | |||
| }, | |||
| ], | |||
| [t, onDeleteClick], | |||
| ); | |||
| return ( | |||
| <> | |||
| <Card> | |||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||
| <Typography variant="overline">{t("Search Criteria")}</Typography> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| alignItems: "center", | |||
| gap: 1, | |||
| flexWrap: "nowrap", | |||
| justifyContent: "flex-start", | |||
| }} | |||
| > | |||
| {/* 樓層 field with F inside on the right */} | |||
| <TextField | |||
| label={t("store_id")} | |||
| value={searchInputs.store_id} | |||
| onChange={(e) => | |||
| setSearchInputs((prev) => ({ ...prev, store_id: e.target.value })) | |||
| } | |||
| size="small" | |||
| sx={{ width: "150px", minWidth: "120px" }} | |||
| InputProps={{ | |||
| endAdornment: ( | |||
| <InputAdornment position="end">F</InputAdornment> | |||
| ), | |||
| }} | |||
| /> | |||
| <Typography variant="body1" sx={{ mx: 0.5 }}> | |||
| - | |||
| </Typography> | |||
| {/* 倉庫 field */} | |||
| <TextField | |||
| label={t("warehouse")} | |||
| value={searchInputs.warehouse} | |||
| onChange={(e) => | |||
| setSearchInputs((prev) => ({ ...prev, warehouse: e.target.value })) | |||
| } | |||
| size="small" | |||
| sx={{ width: "150px", minWidth: "120px" }} | |||
| /> | |||
| <Typography variant="body1" sx={{ mx: 0.5 }}> | |||
| - | |||
| </Typography> | |||
| {/* 區域 field */} | |||
| <TextField | |||
| label={t("area")} | |||
| value={searchInputs.area} | |||
| onChange={(e) => | |||
| setSearchInputs((prev) => ({ ...prev, area: e.target.value })) | |||
| } | |||
| size="small" | |||
| sx={{ width: "150px", minWidth: "120px" }} | |||
| /> | |||
| <Typography variant="body1" sx={{ mx: 0.5 }}> | |||
| - | |||
| </Typography> | |||
| {/* 儲位 field */} | |||
| <TextField | |||
| label={t("slot")} | |||
| value={searchInputs.slot} | |||
| onChange={(e) => | |||
| setSearchInputs((prev) => ({ ...prev, slot: e.target.value })) | |||
| } | |||
| size="small" | |||
| sx={{ width: "150px", minWidth: "120px" }} | |||
| /> | |||
| {/* 盤點區域 field */} | |||
| <Box sx={{ flex: 1, minWidth: "150px", ml: 2 }}> | |||
| <TextField | |||
| label={t("stockTakeSection")} | |||
| value={searchInputs.stockTakeSection} | |||
| onChange={(e) => | |||
| setSearchInputs((prev) => ({ ...prev, stockTakeSection: e.target.value })) | |||
| } | |||
| size="small" | |||
| fullWidth | |||
| /> | |||
| </Box> | |||
| </Box> | |||
| <CardActions sx={{ justifyContent: "flex-start", px: 0, pt: 1 }}> | |||
| <Button | |||
| variant="text" | |||
| startIcon={<RestartAlt />} | |||
| onClick={handleReset} | |||
| > | |||
| {t("Reset")} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<Search />} | |||
| onClick={handleSearch} | |||
| > | |||
| {t("Search")} | |||
| </Button> | |||
| </CardActions> | |||
| </CardContent> | |||
| </Card> | |||
| <SearchResults<WarehouseResult> | |||
| items={filteredWarehouse} | |||
| columns={columns} | |||
| pagingController={pagingController} | |||
| setPagingController={setPagingController} | |||
| /> | |||
| </> | |||
| ); | |||
| }; | |||
| export default WarehouseHandle; | |||
| @@ -0,0 +1,40 @@ | |||
| import Card from "@mui/material/Card"; | |||
| import CardContent from "@mui/material/CardContent"; | |||
| import Skeleton from "@mui/material/Skeleton"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import React from "react"; | |||
| // Can make this nicer | |||
| export const WarehouseHandleLoading: React.FC = () => { | |||
| return ( | |||
| <> | |||
| <Card> | |||
| <CardContent> | |||
| <Stack spacing={2}> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton | |||
| variant="rounded" | |||
| height={50} | |||
| width={100} | |||
| sx={{ alignSelf: "flex-end" }} | |||
| /> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| <Card> | |||
| <CardContent> | |||
| <Stack spacing={2}> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| </> | |||
| ); | |||
| }; | |||
| export default WarehouseHandleLoading; | |||
| @@ -0,0 +1,19 @@ | |||
| import React from "react"; | |||
| import WarehouseHandle from "./WarehouseHandle"; | |||
| import WarehouseHandleLoading from "./WarehouseHandleLoading"; | |||
| import { WarehouseResult, fetchWarehouseList } from "@/app/api/warehouse"; | |||
| interface SubComponents { | |||
| Loading: typeof WarehouseHandleLoading; | |||
| } | |||
| const WarehouseHandleWrapper: React.FC & SubComponents = async () => { | |||
| const warehouses = await fetchWarehouseList(); | |||
| console.log(warehouses); | |||
| return <WarehouseHandle warehouses={warehouses} />; | |||
| }; | |||
| WarehouseHandleWrapper.Loading = WarehouseHandleLoading; | |||
| export default WarehouseHandleWrapper; | |||
| @@ -0,0 +1 @@ | |||
| export { default } from "./WarehouseHandleWrapper"; | |||
| @@ -1,84 +1,102 @@ | |||
| "use client"; | |||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { EquipmentResult } from "@/app/api/settings/equipment"; | |||
| import { useCallback, useMemo, useState, useEffect } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import SearchResults, { Column } from "../SearchResults"; | |||
| import { EditNote } from "@mui/icons-material"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { GridDeleteIcon } from "@mui/x-data-grid"; | |||
| import { successDialog } from "../Swal/CustomAlerts"; | |||
| import useUploadContext from "../UploadProvider/useUploadContext"; | |||
| import { downloadFile } from "@/app/utils/commonUtil"; | |||
| import { EquipmentDetailResult } from "@/app/api/settings/equipmentDetail"; | |||
| import { exportEquipmentQrCode } from "@/app/api/settings/equipmentDetail/client"; | |||
| import { | |||
| Checkbox, | |||
| Box, | |||
| Button, | |||
| TextField, | |||
| Stack, | |||
| Autocomplete, | |||
| Modal, | |||
| Card, | |||
| IconButton, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| Paper, | |||
| Typography | |||
| } from "@mui/material"; | |||
| import DownloadIcon from "@mui/icons-material/Download"; | |||
| import PrintIcon from "@mui/icons-material/Print"; | |||
| import CloseIcon from "@mui/icons-material/Close"; | |||
| import { PrinterCombo } from "@/app/api/settings/printer"; | |||
| import axiosInstance from "@/app/(main)/axios/axiosInstance"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| type Props = { | |||
| equipments: EquipmentResult[]; | |||
| }; | |||
| interface Props { | |||
| equipmentDetails: EquipmentDetailResult[]; | |||
| printerCombo: PrinterCombo[]; | |||
| } | |||
| type SearchQuery = Partial<Omit<EquipmentResult, "id">>; | |||
| type SearchQuery = Partial<Omit<EquipmentDetailResult, "id">>; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => { | |||
| const [filteredEquipments, setFilteredEquipments] = | |||
| useState<EquipmentResult[]>([]); | |||
| const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printerCombo }) => { | |||
| const { t } = useTranslation("common"); | |||
| const [filteredEquipmentDetails, setFilteredEquipmentDetails] = useState<EquipmentDetailResult[]>([]); | |||
| const router = useRouter(); | |||
| const [filterObj, setFilterObj] = useState({}); | |||
| const { setIsUploading } = useUploadContext(); | |||
| const [pagingController, setPagingController] = useState({ | |||
| pageNum: 1, | |||
| pageSize: 10, | |||
| }); | |||
| const [filterObj, setFilterObj] = useState({}); | |||
| const [totalCount, setTotalCount] = useState(0); | |||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo(() => { | |||
| const searchCriteria: Criterion<SearchParamNames>[] = [ | |||
| { label: t("Code"), paramName: "code", type: "text" }, | |||
| { label: t("Description"), paramName: "description", type: "text" }, | |||
| ]; | |||
| return searchCriteria; | |||
| }, [t, equipments]); | |||
| const onDetailClick = useCallback( | |||
| (equipment: EquipmentResult) => { | |||
| router.push(`/settings/equipment/edit?id=${equipment.id}`); | |||
| }, | |||
| [router], | |||
| ); | |||
| const onDeleteClick = useCallback( | |||
| (equipment: EquipmentResult) => {}, | |||
| [router], | |||
| const [checkboxIds, setCheckboxIds] = useState<(string | number)[]>([]); | |||
| const [selectedEquipmentDetailsMap, setSelectedEquipmentDetailsMap] = useState<Map<string | number, EquipmentDetailResult>>(new Map()); | |||
| const [selectAll, setSelectAll] = useState(false); | |||
| const [printQty, setPrintQty] = useState(1); | |||
| const [isSearching, setIsSearching] = useState(false); | |||
| const [previewOpen, setPreviewOpen] = useState(false); | |||
| const [previewUrl, setPreviewUrl] = useState<string | null>(null); | |||
| const [selectedEquipmentDetailsModalOpen, setSelectedEquipmentDetailsModalOpen] = useState(false); | |||
| const filteredPrinters = useMemo(() => { | |||
| return printerCombo.filter((printer) => { | |||
| return printer.type === "A4"; | |||
| }); | |||
| }, [printerCombo]); | |||
| const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | undefined>( | |||
| filteredPrinters.length > 0 ? filteredPrinters[0] : undefined | |||
| ); | |||
| const columns = useMemo<Column<EquipmentResult>[]>( | |||
| useEffect(() => { | |||
| if (!selectedPrinter || !filteredPrinters.find(p => p.id === selectedPrinter.id)) { | |||
| setSelectedPrinter(filteredPrinters.length > 0 ? filteredPrinters[0] : undefined); | |||
| } | |||
| }, [filteredPrinters, selectedPrinter]); | |||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||
| () => [ | |||
| { | |||
| name: "id", | |||
| label: t("Details"), | |||
| onClick: onDetailClick, | |||
| buttonIcon: <EditNote />, | |||
| }, | |||
| { | |||
| name: "code", | |||
| label: t("Code"), | |||
| }, | |||
| { | |||
| name: "equipmentTypeId", | |||
| label: t("Equipment Type"), | |||
| sx: {minWidth: 180}, | |||
| }, | |||
| { | |||
| name: "description", | |||
| label: t("Description"), | |||
| label: "設備名稱", | |||
| paramName: "code", | |||
| type: "text", | |||
| }, | |||
| { | |||
| name: "action", | |||
| label: t(""), | |||
| buttonIcon: <GridDeleteIcon />, | |||
| onClick: onDeleteClick, | |||
| label: "設備編號", | |||
| paramName: "equipmentCode", | |||
| type: "text", | |||
| }, | |||
| ], | |||
| [filteredEquipments], | |||
| [], | |||
| ); | |||
| interface ApiResponse<T> { | |||
| @@ -101,20 +119,19 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => { | |||
| ...filterObj, | |||
| }; | |||
| try { | |||
| const response = await axiosInstance.get<ApiResponse<EquipmentResult>>( | |||
| `${NEXT_PUBLIC_API_URL}/Equipment/getRecordByPage`, | |||
| const response = await axiosInstance.get<ApiResponse<EquipmentDetailResult>>( | |||
| `${NEXT_PUBLIC_API_URL}/EquipmentDetail/getRecordByPage`, | |||
| { params }, | |||
| ); | |||
| console.log(response); | |||
| if (response.status == 200) { | |||
| setFilteredEquipments(response.data.records); | |||
| setFilteredEquipmentDetails(response.data.records); | |||
| setTotalCount(response.data.total); | |||
| return response; | |||
| } else { | |||
| throw "400"; | |||
| } | |||
| } catch (error) { | |||
| console.error("Error fetching equipment types:", error); | |||
| console.error("Error fetching equipment details:", error); | |||
| throw error; | |||
| } | |||
| }, | |||
| @@ -125,6 +142,228 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => { | |||
| refetchData(filterObj, pagingController.pageNum, pagingController.pageSize); | |||
| }, [filterObj, pagingController.pageNum, pagingController.pageSize]); | |||
| useEffect(() => { | |||
| if (filteredEquipmentDetails.length > 0) { | |||
| const allCurrentPageSelected = filteredEquipmentDetails.every(ed => checkboxIds.includes(ed.id)); | |||
| setSelectAll(allCurrentPageSelected); | |||
| } else { | |||
| setSelectAll(false); | |||
| } | |||
| }, [filteredEquipmentDetails, checkboxIds]); | |||
| const handleSelectEquipmentDetail = useCallback((equipmentDetailId: string | number, checked: boolean) => { | |||
| if (checked) { | |||
| const equipmentDetail = filteredEquipmentDetails.find(ed => ed.id === equipmentDetailId); | |||
| if (equipmentDetail) { | |||
| setCheckboxIds(prev => [...prev, equipmentDetailId]); | |||
| setSelectedEquipmentDetailsMap(prev => { | |||
| const newMap = new Map(prev); | |||
| newMap.set(equipmentDetailId, equipmentDetail); | |||
| return newMap; | |||
| }); | |||
| } | |||
| } else { | |||
| setCheckboxIds(prev => prev.filter(id => id !== equipmentDetailId)); | |||
| setSelectedEquipmentDetailsMap(prev => { | |||
| const newMap = new Map(prev); | |||
| newMap.delete(equipmentDetailId); | |||
| return newMap; | |||
| }); | |||
| setSelectAll(false); | |||
| } | |||
| }, [filteredEquipmentDetails]); | |||
| const fetchAllMatchingEquipmentDetails = useCallback(async (): Promise<EquipmentDetailResult[]> => { | |||
| const authHeader = axiosInstance.defaults.headers["Authorization"]; | |||
| if (!authHeader) { | |||
| return []; | |||
| } | |||
| if (totalCount === 0) { | |||
| return []; | |||
| } | |||
| const params = { | |||
| pageNum: 1, | |||
| pageSize: totalCount > 0 ? totalCount : 10000, | |||
| ...filterObj, | |||
| }; | |||
| try { | |||
| const response = await axiosInstance.get<ApiResponse<EquipmentDetailResult>>( | |||
| `${NEXT_PUBLIC_API_URL}/EquipmentDetail/getRecordByPage`, | |||
| { params }, | |||
| ); | |||
| if (response.status == 200) { | |||
| return response.data.records; | |||
| } | |||
| return []; | |||
| } catch (error) { | |||
| console.error("Error fetching all equipment details:", error); | |||
| return []; | |||
| } | |||
| }, [filterObj, totalCount]); | |||
| const handleSelectAll = useCallback(async (checked: boolean) => { | |||
| if (checked) { | |||
| try { | |||
| const allEquipmentDetails = await fetchAllMatchingEquipmentDetails(); | |||
| const allIds = allEquipmentDetails.map(equipmentDetail => equipmentDetail.id); | |||
| setCheckboxIds(allIds); | |||
| setSelectedEquipmentDetailsMap(prev => { | |||
| const newMap = new Map(prev); | |||
| allEquipmentDetails.forEach(equipmentDetail => { | |||
| newMap.set(equipmentDetail.id, equipmentDetail); | |||
| }); | |||
| return newMap; | |||
| }); | |||
| setSelectAll(true); | |||
| } catch (error) { | |||
| console.error("Error selecting all equipment:", error); | |||
| } | |||
| } else { | |||
| setCheckboxIds([]); | |||
| setSelectedEquipmentDetailsMap(new Map()); | |||
| setSelectAll(false); | |||
| } | |||
| }, [fetchAllMatchingEquipmentDetails]); | |||
| const showPdfPreview = useCallback(async (equipmentDetailIds: (string | number)[]) => { | |||
| if (equipmentDetailIds.length === 0) { | |||
| return; | |||
| } | |||
| try { | |||
| setIsUploading(true); | |||
| const numericIds = equipmentDetailIds.map(id => typeof id === 'string' ? parseInt(id) : id); | |||
| const response = await exportEquipmentQrCode(numericIds); | |||
| const blob = new Blob([new Uint8Array(response.blobValue)], { type: "application/pdf" }); | |||
| const url = URL.createObjectURL(blob); | |||
| setPreviewUrl(`${url}#toolbar=0`); | |||
| setPreviewOpen(true); | |||
| } catch (error) { | |||
| console.error("Error exporting QR code:", error); | |||
| } finally { | |||
| setIsUploading(false); | |||
| } | |||
| }, [setIsUploading]); | |||
| const handleClosePreview = useCallback(() => { | |||
| setPreviewOpen(false); | |||
| if (previewUrl) { | |||
| URL.revokeObjectURL(previewUrl); | |||
| setPreviewUrl(null); | |||
| } | |||
| }, [previewUrl]); | |||
| const handleDownloadQrCode = useCallback(async (equipmentDetailIds: (string | number)[]) => { | |||
| if (equipmentDetailIds.length === 0) { | |||
| return; | |||
| } | |||
| try { | |||
| setIsUploading(true); | |||
| const numericIds = equipmentDetailIds.map(id => typeof id === 'string' ? parseInt(id) : id); | |||
| const response = await exportEquipmentQrCode(numericIds); | |||
| downloadFile(response.blobValue, response.filename); | |||
| setSelectedEquipmentDetailsModalOpen(false); | |||
| successDialog("二維碼已下載", t); | |||
| } catch (error) { | |||
| console.error("Error exporting QR code:", error); | |||
| } finally { | |||
| setIsUploading(false); | |||
| } | |||
| }, [setIsUploading, t]); | |||
| const handlePrint = useCallback(async () => { | |||
| if (checkboxIds.length === 0) { | |||
| return; | |||
| } | |||
| try { | |||
| setIsUploading(true); | |||
| const numericIds = checkboxIds.map(id => typeof id === 'string' ? parseInt(id) : id); | |||
| const response = await exportEquipmentQrCode(numericIds); | |||
| const blob = new Blob([new Uint8Array(response.blobValue)], { type: "application/pdf" }); | |||
| const url = URL.createObjectURL(blob); | |||
| const printWindow = window.open(url, '_blank'); | |||
| if (printWindow) { | |||
| printWindow.onload = () => { | |||
| for (let i = 0; i < printQty; i++) { | |||
| setTimeout(() => { | |||
| printWindow.print(); | |||
| }, i * 500); | |||
| } | |||
| }; | |||
| } | |||
| setTimeout(() => { | |||
| URL.revokeObjectURL(url); | |||
| }, 1000); | |||
| setSelectedEquipmentDetailsModalOpen(false); | |||
| successDialog("二維碼已列印", t); | |||
| } catch (error) { | |||
| console.error("Error printing QR code:", error); | |||
| } finally { | |||
| setIsUploading(false); | |||
| } | |||
| }, [checkboxIds, printQty, setIsUploading, t]); | |||
| const handleViewSelectedQrCodes = useCallback(() => { | |||
| if (checkboxIds.length === 0) { | |||
| return; | |||
| } | |||
| setSelectedEquipmentDetailsModalOpen(true); | |||
| }, [checkboxIds]); | |||
| const selectedEquipmentDetails = useMemo(() => { | |||
| return Array.from(selectedEquipmentDetailsMap.values()); | |||
| }, [selectedEquipmentDetailsMap]); | |||
| const handleCloseSelectedEquipmentDetailsModal = useCallback(() => { | |||
| setSelectedEquipmentDetailsModalOpen(false); | |||
| }, []); | |||
| const columns = useMemo<Column<EquipmentDetailResult>[]>( | |||
| () => [ | |||
| { | |||
| name: "id", | |||
| label: "", | |||
| sx: { width: "50px", minWidth: "50px" }, | |||
| renderCell: (params) => ( | |||
| <Checkbox | |||
| checked={checkboxIds.includes(params.id)} | |||
| onChange={(e) => handleSelectEquipmentDetail(params.id, e.target.checked)} | |||
| onClick={(e) => e.stopPropagation()} | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| name: "code", | |||
| label: "設備名稱", | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "150px", minWidth: "150px" }, | |||
| }, | |||
| { | |||
| name: "description", | |||
| label: "設備描述", | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "200px", minWidth: "200px" }, | |||
| }, | |||
| { | |||
| name: "equipmentCode", | |||
| label: "設備編號", | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "150px", minWidth: "150px" }, | |||
| }, | |||
| ], | |||
| [t, checkboxIds, handleSelectEquipmentDetail], | |||
| ); | |||
| const onReset = useCallback(() => { | |||
| setFilterObj({}); | |||
| setPagingController({ pageNum: 1, pageSize: 10 }); | |||
| @@ -138,19 +377,238 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => { | |||
| setFilterObj({ | |||
| ...query, | |||
| }); | |||
| setPagingController({ pageNum: 1, pageSize: 10 }); | |||
| }} | |||
| onReset={onReset} | |||
| /> | |||
| <SearchResults<EquipmentResult> | |||
| items={filteredEquipments} | |||
| <SearchResults<EquipmentDetailResult> | |||
| items={filteredEquipmentDetails} | |||
| columns={columns} | |||
| setPagingController={setPagingController} | |||
| pagingController={pagingController} | |||
| setPagingController={setPagingController} | |||
| totalCount={totalCount} | |||
| isAutoPaging={false} | |||
| /> | |||
| <Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 2 }}> | |||
| <Button | |||
| variant="outlined" | |||
| onClick={() => handleSelectAll(!selectAll)} | |||
| startIcon={<Checkbox checked={selectAll} />} | |||
| disabled={isSearching || totalCount === 0} | |||
| > | |||
| 選擇全部設備 ({checkboxIds.length} / {totalCount}) | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| onClick={handleViewSelectedQrCodes} | |||
| disabled={checkboxIds.length === 0} | |||
| color="primary" | |||
| > | |||
| 查看已選擇設備二維碼 ({checkboxIds.length}) | |||
| </Button> | |||
| </Box> | |||
| <Modal | |||
| open={selectedEquipmentDetailsModalOpen} | |||
| onClose={handleCloseSelectedEquipmentDetailsModal} | |||
| sx={{ | |||
| display: 'flex', | |||
| alignItems: 'center', | |||
| justifyContent: 'center', | |||
| }} | |||
| > | |||
| <Card | |||
| sx={{ | |||
| position: 'relative', | |||
| width: '90%', | |||
| maxWidth: '800px', | |||
| maxHeight: '90vh', | |||
| display: 'flex', | |||
| flexDirection: 'column', | |||
| outline: 'none', | |||
| }} | |||
| > | |||
| <Box | |||
| sx={{ | |||
| display: 'flex', | |||
| justifyContent: 'space-between', | |||
| alignItems: 'center', | |||
| p: 2, | |||
| borderBottom: 1, | |||
| borderColor: 'divider', | |||
| }} | |||
| > | |||
| <Typography variant="h6" component="h2"> | |||
| 已選擇設備 ({selectedEquipmentDetails.length}) | |||
| </Typography> | |||
| <IconButton onClick={handleCloseSelectedEquipmentDetailsModal}> | |||
| <CloseIcon /> | |||
| </IconButton> | |||
| </Box> | |||
| <Box | |||
| sx={{ | |||
| flex: 1, | |||
| overflow: 'auto', | |||
| p: 2, | |||
| }} | |||
| > | |||
| <TableContainer component={Paper} variant="outlined"> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell> | |||
| <strong>設備名稱</strong> | |||
| </TableCell> | |||
| <TableCell> | |||
| <strong>設備描述</strong> | |||
| </TableCell> | |||
| <TableCell> | |||
| <strong>設備編號</strong> | |||
| </TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {selectedEquipmentDetails.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={3} align="center"> | |||
| 沒有選擇的設備 | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| selectedEquipmentDetails.map((equipmentDetail) => ( | |||
| <TableRow key={equipmentDetail.id}> | |||
| <TableCell>{equipmentDetail.code || '-'}</TableCell> | |||
| <TableCell>{equipmentDetail.description || '-'}</TableCell> | |||
| <TableCell>{equipmentDetail.equipmentCode || '-'}</TableCell> | |||
| </TableRow> | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| </Box> | |||
| <Box | |||
| sx={{ | |||
| p: 2, | |||
| borderTop: 1, | |||
| borderColor: 'divider', | |||
| bgcolor: 'background.paper', | |||
| }} | |||
| > | |||
| <Stack direction="row" justifyContent="flex-end" alignItems="center" gap={2}> | |||
| <Autocomplete<PrinterCombo> | |||
| options={filteredPrinters} | |||
| value={selectedPrinter ?? null} | |||
| onChange={(event, value) => { | |||
| setSelectedPrinter(value ?? undefined); | |||
| }} | |||
| getOptionLabel={(option) => option.name || option.label || option.code || String(option.id)} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| variant="outlined" | |||
| label="列印機" | |||
| sx={{ width: 300 }} | |||
| /> | |||
| )} | |||
| /> | |||
| <TextField | |||
| variant="outlined" | |||
| label="列印數量" | |||
| type="number" | |||
| value={printQty} | |||
| onChange={(e) => { | |||
| const value = parseInt(e.target.value) || 1; | |||
| setPrintQty(Math.max(1, value)); | |||
| }} | |||
| inputProps={{ min: 1 }} | |||
| sx={{ width: 120 }} | |||
| /> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<PrintIcon />} | |||
| onClick={handlePrint} | |||
| disabled={checkboxIds.length === 0 || filteredPrinters.length === 0} | |||
| color="primary" | |||
| > | |||
| 列印 | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<DownloadIcon />} | |||
| onClick={() => handleDownloadQrCode(checkboxIds)} | |||
| disabled={checkboxIds.length === 0} | |||
| color="primary" | |||
| > | |||
| 下載二維碼 | |||
| </Button> | |||
| </Stack> | |||
| </Box> | |||
| </Card> | |||
| </Modal> | |||
| <Modal | |||
| open={previewOpen} | |||
| onClose={handleClosePreview} | |||
| sx={{ | |||
| display: 'flex', | |||
| alignItems: 'center', | |||
| justifyContent: 'center', | |||
| }} | |||
| > | |||
| <Card | |||
| sx={{ | |||
| position: 'relative', | |||
| width: '90%', | |||
| maxWidth: '900px', | |||
| maxHeight: '90vh', | |||
| display: 'flex', | |||
| flexDirection: 'column', | |||
| outline: 'none', | |||
| }} | |||
| > | |||
| <Box | |||
| sx={{ | |||
| display: 'flex', | |||
| justifyContent: 'flex-end', | |||
| alignItems: 'center', | |||
| p: 2, | |||
| borderBottom: 1, | |||
| borderColor: 'divider', | |||
| }} | |||
| > | |||
| <IconButton | |||
| onClick={handleClosePreview} | |||
| > | |||
| <CloseIcon /> | |||
| </IconButton> | |||
| </Box> | |||
| <Box | |||
| sx={{ | |||
| flex: 1, | |||
| overflow: 'auto', | |||
| p: 2, | |||
| }} | |||
| > | |||
| {previewUrl && ( | |||
| <iframe | |||
| src={previewUrl} | |||
| width="100%" | |||
| height="600px" | |||
| style={{ | |||
| border: 'none', | |||
| }} | |||
| title="PDF Preview" | |||
| /> | |||
| )} | |||
| </Box> | |||
| </Card> | |||
| </Modal> | |||
| </> | |||
| ); | |||
| }; | |||
| export default QrCodeHandleEquipmentSearch; | |||
| export default QrCodeHandleEquipmentSearch; | |||
| @@ -1,15 +1,19 @@ | |||
| import React from "react"; | |||
| import QrCodeHandleEquipmentSearch from "./qrCodeHandleEquipmentSearch"; | |||
| import EquipmentSearchLoading from "../EquipmentSearch/EquipmentSearchLoading"; | |||
| import { fetchAllEquipments } from "@/app/api/settings/equipment"; | |||
| import { fetchAllEquipmentDetails } from "@/app/api/settings/equipmentDetail"; | |||
| import { fetchPrinterCombo } from "@/app/api/settings/printer"; | |||
| interface SubComponents { | |||
| Loading: typeof EquipmentSearchLoading; | |||
| } | |||
| const QrCodeHandleEquipmentSearchWrapper: React.FC & SubComponents = async () => { | |||
| const equipments = await fetchAllEquipments(); | |||
| return <QrCodeHandleEquipmentSearch equipments={equipments} />; | |||
| const [equipmentDetails, printerCombo] = await Promise.all([ | |||
| fetchAllEquipmentDetails(), | |||
| fetchPrinterCombo(), | |||
| ]); | |||
| return <QrCodeHandleEquipmentSearch equipmentDetails={equipmentDetails} printerCombo={printerCombo} />; | |||
| }; | |||
| QrCodeHandleEquipmentSearchWrapper.Loading = EquipmentSearchLoading; | |||
| @@ -1,8 +1,9 @@ | |||
| "use client"; | |||
| import { useState, ReactNode } from "react"; | |||
| import { useState, ReactNode, useEffect } from "react"; | |||
| import { Box, Tabs, Tab } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { useSearchParams, useRouter } from "next/navigation"; | |||
| interface TabPanelProps { | |||
| children?: ReactNode; | |||
| @@ -37,10 +38,33 @@ const QrCodeHandleTabs: React.FC<QrCodeHandleTabsProps> = ({ | |||
| }) => { | |||
| const { t } = useTranslation("common"); | |||
| const { t: tUser } = useTranslation("user"); | |||
| const [currentTab, setCurrentTab] = useState(0); | |||
| const searchParams = useSearchParams(); | |||
| const router = useRouter(); | |||
| const getInitialTab = () => { | |||
| const tab = searchParams.get("tab"); | |||
| if (tab === "equipment") return 1; | |||
| if (tab === "user") return 0; | |||
| return 0; | |||
| }; | |||
| const [currentTab, setCurrentTab] = useState(getInitialTab); | |||
| useEffect(() => { | |||
| const tab = searchParams.get("tab"); | |||
| if (tab === "equipment") { | |||
| setCurrentTab(1); | |||
| } else if (tab === "user") { | |||
| setCurrentTab(0); | |||
| } | |||
| }, [searchParams]); | |||
| const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | |||
| setCurrentTab(newValue); | |||
| const tabName = newValue === 1 ? "equipment" : "user"; | |||
| const params = new URLSearchParams(searchParams.toString()); | |||
| params.set("tab", tabName); | |||
| router.push(`?${params.toString()}`, { scroll: false }); | |||
| }; | |||
| return ( | |||
| @@ -1,12 +1,32 @@ | |||
| import { AuthOptions, Session } from "next-auth"; | |||
| // config/authConfig.ts (or wherever your authOptions live) | |||
| import { AuthOptions } from "next-auth"; | |||
| import CredentialsProvider from "next-auth/providers/credentials"; | |||
| import { LOGIN_API_PATH } from "./api"; | |||
| export interface SessionWithTokens extends Session { | |||
| accessToken: string | null; | |||
| refreshToken?: string; | |||
| abilities: string[]; | |||
| id?: string | null; | |||
| // Extend the built-in types | |||
| declare module "next-auth" { | |||
| interface Session { | |||
| accessToken: string | null; | |||
| refreshToken?: string; | |||
| abilities: string[]; | |||
| id?: string; | |||
| } | |||
| interface User { | |||
| id?: string; | |||
| accessToken: string | null; | |||
| refreshToken?: string; | |||
| abilities: string[]; | |||
| } | |||
| } | |||
| declare module "next-auth/jwt" { | |||
| interface JWT { | |||
| id?: string; | |||
| accessToken: string | null; | |||
| refreshToken?: string; | |||
| abilities: string[]; | |||
| } | |||
| } | |||
| export const authOptions: AuthOptions = { | |||
| @@ -19,18 +39,26 @@ export const authOptions: AuthOptions = { | |||
| username: { label: "Username", type: "text" }, | |||
| password: { label: "Password", type: "password" }, | |||
| }, | |||
| async authorize(credentials, req) { | |||
| async authorize(credentials) { | |||
| if (!credentials?.username || !credentials?.password) return null; | |||
| const res = await fetch(LOGIN_API_PATH, { | |||
| method: "POST", | |||
| body: JSON.stringify(credentials), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| if (!res.ok) return null; | |||
| const user = await res.json(); | |||
| if (res.ok && user) { | |||
| return user; | |||
| // Important: next-auth expects the user object returned here | |||
| // to be serializable and contain the fields you want in token/session | |||
| // Ensure your backend returns: { id, accessToken, abilities, ...other fields } | |||
| if (user && user.abilities) { | |||
| return user; // this will be passed to jwt callback as `user` | |||
| } | |||
| return null; | |||
| }, | |||
| }), | |||
| @@ -39,25 +67,36 @@ export const authOptions: AuthOptions = { | |||
| signIn: "/login", | |||
| }, | |||
| callbacks: { | |||
| jwt(params) { | |||
| // Add the data from user to the token | |||
| const { token, user } = params; | |||
| const newToken = { ...token, ...user }; | |||
| return newToken; | |||
| // Persist custom fields into the JWT token | |||
| async jwt({ token, user }) { | |||
| // First sign-in: `user` is available | |||
| if (user) { | |||
| token.id = user.id ?? token.sub; // fallback to sub if no id | |||
| token.accessToken = user.accessToken; | |||
| token.refreshToken = user.refreshToken; | |||
| token.abilities = user.abilities ?? []; | |||
| } | |||
| // On subsequent calls (token refresh, session access), user is not present | |||
| // so we just return the existing token with custom fields preserved | |||
| return token; | |||
| }, | |||
| session({ session, token }) { | |||
| const sessionWithToken: SessionWithTokens = { | |||
| ...session, | |||
| // Add the data from the token to the session | |||
| id: token.id as string | undefined, | |||
| accessToken: token.accessToken as string | null, | |||
| refreshToken: token.refreshToken as string | undefined, | |||
| abilities: token.abilities as string[], | |||
| }; | |||
| if (sessionWithToken.user) { | |||
| sessionWithToken.user.abilities = token.abilities as string[]; | |||
| // Expose custom fields to the client session | |||
| async session({ session, token }) { | |||
| session.id = token.id as string | undefined; | |||
| session.accessToken = token.accessToken as string | null; | |||
| session.refreshToken = token.refreshToken as string | undefined; | |||
| session.abilities = token.abilities as string[]; | |||
| // Also add abilities to session.user for easier client-side access | |||
| if (session.user) { | |||
| session.user.abilities = token.abilities as string[]; | |||
| } | |||
| return sessionWithToken; | |||
| return session; | |||
| }, | |||
| }, | |||
| }; | |||
| export default authOptions; | |||
| @@ -12,12 +12,24 @@ | |||
| "Equipment not found": "Equipment not found", | |||
| "Error saving data": "Error saving data", | |||
| "Cancel": "Cancel", | |||
| "Do you want to delete?": "Do you want to delete?", | |||
| "Save": "Save", | |||
| "Yes": "Yes", | |||
| "No": "No", | |||
| "Equipment Name": "Equipment Name", | |||
| "Equipment Code": "Equipment Code", | |||
| "ShopAndTruck": "ShopAndTruck" | |||
| "ShopAndTruck": "ShopAndTruck", | |||
| "TruckLance Code is required": "TruckLance Code is required", | |||
| "Truck shop details updated successfully": "Truck shop details updated successfully", | |||
| "Failed to save truck shop details": "Failed to save truck shop details", | |||
| "Truck lane information is required": "Truck lane information is required", | |||
| "Shop added to truck lane successfully": "Shop added to truck lane successfully", | |||
| "Failed to create shop in truck lane": "Failed to create shop in truck lane", | |||
| "Add Shop": "Add Shop", | |||
| "Search or select shop name": "Search or select shop name", | |||
| "Search or select shop code": "Search or select shop code", | |||
| "Search or select remark": "Search or select remark", | |||
| "Edit shop details": "Edit shop details", | |||
| "Add Shop to Truck Lane": "Add Shop to Truck Lane", | |||
| "Truck lane code already exists. Please use a different code.": "Truck lane code already exists. Please use a different code." | |||
| } | |||
| @@ -14,5 +14,7 @@ | |||
| "User ID": "用戶ID", | |||
| "User Name": "用戶名稱", | |||
| "User Group": "用戶群組", | |||
| "Authority": "權限" | |||
| "Authority": "權限", | |||
| "Delete Success": "Delete Success", | |||
| "Do you want to delete?": "Do you want to delete?" | |||
| } | |||
| @@ -0,0 +1,27 @@ | |||
| { | |||
| "Create Warehouse": "Create Warehouse", | |||
| "Edit Warehouse": "Edit Warehouse", | |||
| "Warehouse Detail": "Warehouse Detail", | |||
| "code": "Code", | |||
| "name": "Name", | |||
| "description": "Description", | |||
| "Edit": "Edit", | |||
| "Delete": "Delete", | |||
| "Delete Success": "Delete Success", | |||
| "Warehouse": "Warehouse", | |||
| "warehouse": "warehouse", | |||
| "Rows per page": "Rows per page", | |||
| "capacity": "Capacity", | |||
| "store_id": "Store ID", | |||
| "area": "Area", | |||
| "slot": "Slot", | |||
| "order": "Order", | |||
| "stockTakeSection": "Stock Take Section", | |||
| "Do you want to delete?": "Do you want to delete?", | |||
| "Cancel": "Cancel", | |||
| "Reset": "Reset", | |||
| "Confirm": "Confirm", | |||
| "is required": "is required", | |||
| "Search Criteria": "Search Criteria", | |||
| "Search": "Search" | |||
| } | |||
| @@ -77,6 +77,7 @@ | |||
| "Setup Time": "生產前預備時間", | |||
| "Changeover Time": "生產後轉換時間", | |||
| "Warehouse": "倉庫", | |||
| "warehouse": "倉庫", | |||
| "Supplier": "供應商", | |||
| "Purchase Order": "採購單", | |||
| "Demand Forecast": "需求預測", | |||
| @@ -268,6 +269,7 @@ | |||
| "Seq No Remark": "序號明細", | |||
| "Stock Available": "庫存可用", | |||
| "Confirm": "確認", | |||
| "Do you want to delete?": "您確定要刪除嗎?", | |||
| "Stock Status": "庫存狀態", | |||
| "Target Production Date": "目標生產日期", | |||
| "id": "ID", | |||
| @@ -383,19 +385,33 @@ | |||
| "Filter by Status": "按狀態篩選", | |||
| "All": "全部", | |||
| "General Data": "基本資料", | |||
| "Repair and Maintenance": "維修和保養", | |||
| "Repair and Maintenance Status": "維修和保養狀態", | |||
| "Latest Repair and Maintenance Date": "最新維修和保養日期", | |||
| "Last Repair and Maintenance Date": "上次維修和保養日期", | |||
| "Repair and Maintenance Remarks": "維修和保養備註", | |||
| "Repair and Maintenance": "維護和保養", | |||
| "Repair and Maintenance Status": "維護和保養狀態", | |||
| "Latest Repair and Maintenance Date": "最新維護和保養日期", | |||
| "Last Repair and Maintenance Date": "上次維護和保養日期", | |||
| "Repair and Maintenance Remarks": "維護和保養備註", | |||
| "Rows per page": "每頁行數", | |||
| "Equipment Name": "設備名稱", | |||
| "Equipment Code": "設備編號", | |||
| "Yes": "是", | |||
| "No": "否", | |||
| "Update Equipment Maintenance and Repair": "更新設備的維修和保養", | |||
| "Update Equipment Maintenance and Repair": "更新設備的維護和保養", | |||
| "Equipment Information": "設備資訊", | |||
| "Loading": "載入中...", | |||
| "Equipment not found": "找不到設備", | |||
| "Error saving data": "保存數據時出錯" | |||
| "Error saving data": "保存數據時出錯", | |||
| "TruckLance Code is required": "需要卡車路線編號", | |||
| "Truck shop details updated successfully": "卡車店鋪詳情更新成功", | |||
| "Failed to save truck shop details": "儲存卡車店鋪詳情失敗", | |||
| "Truck lane information is required": "需要卡車路線資訊", | |||
| "Shop added to truck lane successfully": "店鋪已成功新增至卡車路線", | |||
| "Failed to create shop in truck lane": "新增店鋪至卡車路線失敗", | |||
| "Add Shop": "新增店鋪", | |||
| "Search or select shop name": "搜尋或選擇店鋪名稱", | |||
| "Search or select shop code": "搜尋或選擇店鋪編號", | |||
| "Search or select remark": "搜尋或選擇備註", | |||
| "Edit shop details": "編輯店鋪詳情", | |||
| "Add Shop to Truck Lane": "新增店鋪至卡車路線", | |||
| "Truck lane code already exists. Please use a different code.": "卡車路線編號已存在,請使用其他編號。", | |||
| "MaintenanceEdit": "編輯維護和保養" | |||
| } | |||
| @@ -13,14 +13,20 @@ | |||
| "Stock take record status updated to not match": "盤點記錄狀態更新為數值不符", | |||
| "available": "可用", | |||
| "Item-lotNo-ExpiryDate": "貨品-批號-到期日", | |||
| "Item-lotNo-ExpiryDate": "貨品-批號-到期日", | |||
| "not available": "不可用", | |||
| "Batch Submit All": "批量提交所有", | |||
| "Batch Save All": "批量保存所有", | |||
| "Batch Submit All": "批量提交所有", | |||
| "Batch Save All": "批量保存所有", | |||
| "not match": "數值不符", | |||
| "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數(含壞品)= 可用數", | |||
| "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數(含壞品)= 可用數", | |||
| "View ReStockTake": "查看重新盤點", | |||
| "Stock Take Qty": "盤點數", | |||
| "Stock Take Qty": "盤點數", | |||
| "ReStockTake": "重新盤點", | |||
| "Stock Taker": "盤點員", | |||
| "Total Item Number": "貨品數量", | |||
| @@ -31,6 +37,16 @@ | |||
| "book qty": "帳面庫存", | |||
| "start time": "開始時間", | |||
| "end time": "結束時間", | |||
| "Control Time": "操作時間", | |||
| "Stock Taker": "盤點員", | |||
| "Total Item Number": "貨品數量", | |||
| "Start Time": "開始時間", | |||
| "Difference": "差異", | |||
| "stockTaking": "盤點中", | |||
| "selected stock take qty": "已選擇盤點數量", | |||
| "book qty": "帳面庫存", | |||
| "start time": "開始時間", | |||
| "end time": "結束時間", | |||
| "Only Variance": "僅差異", | |||
| "Control Time": "操作時間", | |||
| "pass": "通過", | |||
| @@ -41,6 +57,7 @@ | |||
| "Last Stock Take Date": "上次盤點日期", | |||
| "Remark": "備註", | |||
| "notMatch": "數值不符", | |||
| "notMatch": "數值不符", | |||
| "Stock take record saved successfully": "盤點記錄保存成功", | |||
| "View Details": "查看詳細", | |||
| "Input": "輸入", | |||
| @@ -28,5 +28,7 @@ | |||
| "user": "用戶", | |||
| "qrcode": "二維碼", | |||
| "staffNo": "員工編號", | |||
| "Rows per page": "每頁行數" | |||
| "Rows per page": "每頁行數", | |||
| "Delete Success": "刪除成功", | |||
| "Do you want to delete?": "您確定要刪除嗎?" | |||
| } | |||
| @@ -0,0 +1,27 @@ | |||
| { | |||
| "Create Warehouse": "新增倉庫", | |||
| "Edit Warehouse": "編輯倉庫資料", | |||
| "Warehouse Detail": "倉庫詳細資料", | |||
| "code": "編號", | |||
| "name": "名稱", | |||
| "description": "描述", | |||
| "Edit": "編輯", | |||
| "Delete": "刪除", | |||
| "Delete Success": "刪除成功", | |||
| "Warehouse": "倉庫", | |||
| "warehouse": "倉庫", | |||
| "Rows per page": "每頁行數", | |||
| "capacity": "容量", | |||
| "store_id": "樓層", | |||
| "area": "區域", | |||
| "slot": "位置", | |||
| "order": "提料單次序", | |||
| "stockTakeSection": "盤點區域", | |||
| "Do you want to delete?": "您確定要刪除嗎?", | |||
| "Cancel": "取消", | |||
| "Reset": "重置", | |||
| "Confirm": "確認", | |||
| "is required": "必填", | |||
| "Search Criteria": "搜尋條件", | |||
| "Search": "搜尋" | |||
| } | |||