# 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 { fetchAllEquipments } from "@/app/api/settings/equipment"; | ||||
| import { I18nProvider } from "@/i18n"; | import { I18nProvider } from "@/i18n"; | ||||
| import EquipmentSearchWrapper from "@/components/EquipmentSearch/EquipmentSearchWrapper"; | import EquipmentSearchWrapper from "@/components/EquipmentSearch/EquipmentSearchWrapper"; | ||||
| import EquipmentSearchLoading from "@/components/EquipmentSearch/EquipmentSearchLoading"; | |||||
| export const metadata: Metadata = { | export const metadata: Metadata = { | ||||
| title: "Equipment Type", | title: "Equipment Type", | ||||
| @@ -33,7 +34,7 @@ const productSetting: React.FC = async () => { | |||||
| {t("Equipment")} | {t("Equipment")} | ||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| <Suspense fallback={<EquipmentSearchWrapper.Loading />}> | |||||
| <Suspense fallback={<EquipmentSearchLoading />}> | |||||
| <I18nProvider namespaces={["common", "project"]}> | <I18nProvider namespaces={["common", "project"]}> | ||||
| <EquipmentSearchWrapper /> | <EquipmentSearchWrapper /> | ||||
| </I18nProvider> | </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; | id: number; | ||||
| } | } | ||||
| export interface print6FilesReq { | |||||
| itemCode: 'string', | |||||
| lotNo: 'string', | |||||
| expiryDate: 'string', | |||||
| productName: 'string' | |||||
| } | |||||
| export interface ReleaseProdScheduleResponse { | export interface ReleaseProdScheduleResponse { | ||||
| id: number; | id: number; | ||||
| code: string; | 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) => { | export const releaseProdScheduleLine = cache(async (data: ReleaseProdScheduleInputs) => { | ||||
| const response = serverFetchJson<ReleaseProdScheduleResponse>( | const response = serverFetchJson<ReleaseProdScheduleResponse>( | ||||
| `${BASE_API_URL}/productionSchedule/detail/detailed/releaseLine`, | `${BASE_API_URL}/productionSchedule/detail/detailed/releaseLine`, | ||||
| @@ -9,6 +9,7 @@ export type ScheduleType = "all" | "rough" | "detailed" | "manual"; | |||||
| export interface RoughProdScheduleResult { | export interface RoughProdScheduleResult { | ||||
| id: number; | id: number; | ||||
| scheduleAt: number[]; | scheduleAt: number[]; | ||||
| produceAt: number[]; | |||||
| schedulePeriod: number[]; | schedulePeriod: number[]; | ||||
| schedulePeriodTo: number[]; | schedulePeriodTo: number[]; | ||||
| totalEstProdCount: number; | totalEstProdCount: number; | ||||
| @@ -80,6 +81,7 @@ export interface RoughProdScheduleLineResultByBomByDate { | |||||
| // Detailed | // Detailed | ||||
| export interface DetailedProdScheduleResult { | export interface DetailedProdScheduleResult { | ||||
| id: number; | id: number; | ||||
| produceAt: number[]; | |||||
| scheduleAt: number[]; | scheduleAt: number[]; | ||||
| totalEstProdCount: number; | totalEstProdCount: number; | ||||
| totalFGType: 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; | districtReference: Number; | ||||
| storeId: Number | String; | storeId: Number | String; | ||||
| remark?: String | null; | remark?: String | null; | ||||
| shopName?: String | null; | |||||
| shopCode?: String | null; | |||||
| } | } | ||||
| export interface SaveTruckLane { | export interface SaveTruckLane { | ||||
| @@ -62,9 +64,13 @@ export interface DeleteTruckLane { | |||||
| id: number; | id: number; | ||||
| } | } | ||||
| export interface UpdateLoadingSequenceRequest { | |||||
| export interface UpdateTruckShopDetailsRequest { | |||||
| id: number; | id: number; | ||||
| shopId?: number | null; | |||||
| shopName: string | null; | |||||
| shopCode: string | null; | |||||
| loadingSequence: number; | loadingSequence: number; | ||||
| remark?: string | null; | |||||
| } | } | ||||
| export interface SaveTruckRequest { | export interface SaveTruckRequest { | ||||
| @@ -80,6 +86,15 @@ export interface SaveTruckRequest { | |||||
| remark?: string | null; | remark?: string | null; | ||||
| } | } | ||||
| export interface CreateTruckWithoutShopRequest { | |||||
| store_id: string; | |||||
| truckLanceCode: string; | |||||
| departureTime: string; | |||||
| loadingSequence?: number; | |||||
| districtReference?: number | null; | |||||
| remark?: string | null; | |||||
| } | |||||
| export interface MessageResponse { | export interface MessageResponse { | ||||
| id: number | null; | id: number | null; | ||||
| name: string | null; | name: string | null; | ||||
| @@ -137,7 +152,7 @@ export const deleteTruckLaneAction = async (data: DeleteTruckLane) => { | |||||
| }; | }; | ||||
| export const createTruckAction = async (data: SaveTruckRequest) => { | export const createTruckAction = async (data: SaveTruckRequest) => { | ||||
| const endpoint = `${BASE_API_URL}/truck/create`; | |||||
| const endpoint = `${BASE_API_URL}/truck/createTruckInShop`; | |||||
| return serverFetchJson<MessageResponse>(endpoint, { | return serverFetchJson<MessageResponse>(endpoint, { | ||||
| method: "POST", | 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, { | return serverFetchJson<MessageResponse>(endpoint, { | ||||
| method: "POST", | method: "POST", | ||||
| body: JSON.stringify(data), | body: JSON.stringify(data), | ||||
| headers: { "Content-Type": "application/json" }, | 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, | findAllUniqueTruckLaneCombinationsAction, | ||||
| findAllShopsByTruckLanceCodeAndRemarkAction, | findAllShopsByTruckLanceCodeAndRemarkAction, | ||||
| findAllShopsByTruckLanceCodeAction, | findAllShopsByTruckLanceCodeAction, | ||||
| updateLoadingSequenceAction, | |||||
| createTruckWithoutShopAction, | |||||
| updateTruckShopDetailsAction, | |||||
| findAllUniqueShopNamesAndCodesFromTrucksAction, | |||||
| findAllUniqueRemarksFromTrucksAction, | |||||
| findAllUniqueShopCodesFromTrucksAction, | |||||
| findAllUniqueShopNamesFromTrucksAction, | |||||
| findAllByTruckLanceCodeAndDeletedFalseAction, | |||||
| type SaveTruckLane, | type SaveTruckLane, | ||||
| type DeleteTruckLane, | type DeleteTruckLane, | ||||
| type SaveTruckRequest, | type SaveTruckRequest, | ||||
| type UpdateLoadingSequenceRequest, | |||||
| type UpdateTruckShopDetailsRequest, | |||||
| type CreateTruckWithoutShopRequest, | |||||
| type MessageResponse | type MessageResponse | ||||
| } from "./actions"; | } from "./actions"; | ||||
| @@ -49,8 +56,32 @@ export const findAllShopsByTruckLanceCodeClient = async (truckLanceCode: string) | |||||
| return await findAllShopsByTruckLanceCodeAction(truckLanceCode); | 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; | export default fetchAllShopsClient; | ||||
| @@ -1,7 +1,63 @@ | |||||
| "use server"; | "use server"; | ||||
| import { serverFetchString } from "@/app/utils/fetchUtil"; | |||||
| import { serverFetchString, serverFetchWithNoContent, serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | 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) => { | export const importWarehouse = async (data: FormData) => { | ||||
| const importWarehouse = await serverFetchString<string>( | const importWarehouse = await serverFetchString<string>( | ||||
| @@ -4,10 +4,17 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| export interface WarehouseResult { | export interface WarehouseResult { | ||||
| action: any; | |||||
| id: number; | id: number; | ||||
| code: string; | code: string; | ||||
| name: string; | name: string; | ||||
| description: string; | description: string; | ||||
| store_id?: string; | |||||
| warehouse?: string; | |||||
| area?: string; | |||||
| slot?: string; | |||||
| order?: number; | |||||
| stockTakeSection?: string; | |||||
| } | } | ||||
| export interface WarehouseCombo { | export interface WarehouseCombo { | ||||
| @@ -151,3 +151,45 @@ export const calculateWeight = (qty: number, uom: Uom) => { | |||||
| export const returnWeightUnit = (uom: Uom) => { | export const returnWeightUnit = (uom: Uom) => { | ||||
| return uom.unit4 || uom.unit3 || uom.unit2 || uom.unit1; | 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/qrCodeHandle": "QR Code Handle", | ||||
| "/settings/rss": "Demand Forecast Setting", | "/settings/rss": "Demand Forecast Setting", | ||||
| "/settings/equipment": "Equipment", | "/settings/equipment": "Equipment", | ||||
| "/settings/equipment/MaintenanceEdit": "MaintenanceEdit", | |||||
| "/settings/shop": "ShopAndTruck", | "/settings/shop": "ShopAndTruck", | ||||
| "/settings/shop/detail": "Shop Detail", | "/settings/shop/detail": "Shop Detail", | ||||
| "/settings/shop/truckdetail": "Truck Lane 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 { useRouter, useSearchParams } from "next/navigation"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { ScheduleType } from "@/app/api/scheduling"; | import { ScheduleType } from "@/app/api/scheduling"; | ||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| import { | import { | ||||
| ProdScheduleResult, | ProdScheduleResult, | ||||
| SearchProdSchedule, | SearchProdSchedule, | ||||
| @@ -14,6 +15,7 @@ import { | |||||
| fetchProdSchedules, | fetchProdSchedules, | ||||
| exportProdSchedule, | exportProdSchedule, | ||||
| testDetailedSchedule, | testDetailedSchedule, | ||||
| getFile6, | |||||
| } from "@/app/api/scheduling/actions"; | } from "@/app/api/scheduling/actions"; | ||||
| import { defaultPagingController } from "../SearchResults/SearchResults"; | import { defaultPagingController } from "../SearchResults/SearchResults"; | ||||
| import { arrayToDateString, arrayToDayjs, decimalFormatter } from "@/app/utils/formatUtil"; | 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 isToday from 'dayjs/plugin/isToday'; | ||||
| import useUploadContext from "../UploadProvider/useUploadContext"; | import useUploadContext from "../UploadProvider/useUploadContext"; | ||||
| import { FileDownload, CalendarMonth } from "@mui/icons-material"; | import { FileDownload, CalendarMonth } from "@mui/icons-material"; | ||||
| import { useSession } from "next-auth/react"; | |||||
| import { VIEW_USER } from "@/authorities"; | |||||
| dayjs.extend(isToday); | dayjs.extend(isToday); | ||||
| // may need move to "index" or "actions" | // may need move to "index" or "actions" | ||||
| @@ -52,6 +57,10 @@ const DSOverview: React.FC<Props> = ({ type, defaultInputs }) => { | |||||
| const { setIsUploading } = useUploadContext(); | const { setIsUploading } = useUploadContext(); | ||||
| const today = dayjs().format("YYYY-MM-DD"); | 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 router = useRouter(); | ||||
| // const [filterObj, setFilterObj] = useState({}); | // const [filterObj, setFilterObj] = useState({}); | ||||
| // const [tempSelectedValue, setTempSelectedValue] = useState({}); | // const [tempSelectedValue, setTempSelectedValue] = useState({}); | ||||
| @@ -226,6 +235,48 @@ const DSOverview: React.FC<Props> = ({ type, defaultInputs }) => { | |||||
| refetchData(resetWithToday, "reset"); // Fetch data | refetchData(resetWithToday, "reset"); // Fetch data | ||||
| }, [defaultInputs, refetchData]); | }, [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 () => { | const testDetailedScheduleClick = useCallback(async () => { | ||||
| try { | try { | ||||
| setIsUploading(true) | setIsUploading(true) | ||||
| @@ -332,6 +383,21 @@ const DSOverview: React.FC<Props> = ({ type, defaultInputs }) => { | |||||
| > | > | ||||
| {t("Export Schedule")} | {t("Export Schedule")} | ||||
| </Button> | </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> | </Stack> | ||||
| <SearchBox | <SearchBox | ||||
| criteria={searchCriteria} | criteria={searchCriteria} | ||||
| @@ -1,20 +1,36 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import SearchBox, { Criterion } from "../SearchBox"; | import SearchBox, { Criterion } from "../SearchBox"; | ||||
| import { EquipmentResult } from "@/app/api/settings/equipment"; | import { EquipmentResult } from "@/app/api/settings/equipment"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import EquipmentSearchResults, { Column } from "./EquipmentSearchResults"; | import EquipmentSearchResults, { Column } from "./EquipmentSearchResults"; | ||||
| import { EditNote } from "@mui/icons-material"; | 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 { BASE_API_URL, NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| import axiosInstance from "@/app/(main)/axios/axiosInstance"; | import axiosInstance from "@/app/(main)/axios/axiosInstance"; | ||||
| import { arrayToDateTimeString } from "@/app/utils/formatUtil"; | import { arrayToDateTimeString } from "@/app/utils/formatUtil"; | ||||
| import Box from "@mui/material/Box"; | import Box from "@mui/material/Box"; | ||||
| import Typography from "@mui/material/Typography"; | 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 = { | type Props = { | ||||
| equipments: EquipmentResult[]; | equipments: EquipmentResult[]; | ||||
| @@ -28,14 +44,39 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| useState<EquipmentResult[]>([]); | useState<EquipmentResult[]>([]); | ||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const router = useRouter(); | 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 [totalCount, setTotalCount] = useState(0); | ||||
| const [isLoading, setIsLoading] = useState(true); | const [isLoading, setIsLoading] = useState(true); | ||||
| const [isReady, setIsReady] = useState(false); | 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(() => { | useEffect(() => { | ||||
| const checkReady = () => { | const checkReady = () => { | ||||
| @@ -90,20 +131,12 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| }, | }, | ||||
| ]; | ]; | ||||
| } | } | ||||
| return [ | return [ | ||||
| { label: t("Code"), paramName: "code", type: "text" }, | |||||
| { label: t("Description"), paramName: "description", type: "text" }, | |||||
| { label: "設備編號", paramName: "code", type: "text" }, | |||||
| ]; | ]; | ||||
| }, [t, tabIndex]); | }, [t, tabIndex]); | ||||
| const onDetailClick = useCallback( | |||||
| (equipment: EquipmentResult) => { | |||||
| router.push(`/settings/equipment/edit?id=${equipment.id}`); | |||||
| }, | |||||
| [router], | |||||
| ); | |||||
| const onMaintenanceEditClick = useCallback( | const onMaintenanceEditClick = useCallback( | ||||
| (equipment: EquipmentResult) => { | (equipment: EquipmentResult) => { | ||||
| router.push(`/settings/equipment/MaintenanceEdit?id=${equipment.id}`); | router.push(`/settings/equipment/MaintenanceEdit?id=${equipment.id}`); | ||||
| @@ -116,34 +149,292 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| [router], | [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>[]>( | const generalDataColumns = useMemo<Column<EquipmentResult>[]>( | ||||
| () => [ | () => [ | ||||
| { | |||||
| name: "id", | |||||
| label: t("Details"), | |||||
| onClick: onDetailClick, | |||||
| buttonIcon: <EditNote />, | |||||
| }, | |||||
| { | { | ||||
| name: "code", | 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>[]>( | const repairMaintenanceColumns = useMemo<Column<EquipmentResult>[]>( | ||||
| @@ -250,8 +541,6 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| const transformedFilter: any = { ...filterObj }; | 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) { | if (tabIndex === 1 && transformedFilter.equipmentCode) { | ||||
| transformedFilter.code = 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]); | }, [filterObj, pagingController.pageNum, pagingController.pageSize, tabIndex, isReady, refetchData]); | ||||
| const onReset = useCallback(() => { | 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 ( | return ( | ||||
| <> | <> | ||||
| <SearchBox | <SearchBox | ||||
| criteria={searchCriteria} | criteria={searchCriteria} | ||||
| onSearch={(query) => { | onSearch={(query) => { | ||||
| setFilterObj({ | |||||
| ...query, | |||||
| setFilterObjByTab(prev => { | |||||
| const newState = { ...prev }; | |||||
| newState[tabIndex] = query as unknown as SearchQuery; | |||||
| return newState; | |||||
| }); | }); | ||||
| }} | }} | ||||
| onReset={onReset} | 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={{ | <Box sx={{ | ||||
| "& .MuiTableContainer-root": { | "& .MuiTableContainer-root": { | ||||
| overflowY: "auto", | overflowY: "auto", | ||||
| @@ -337,14 +865,175 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| <EquipmentSearchResults<EquipmentResult> | <EquipmentSearchResults<EquipmentResult> | ||||
| items={filteredEquipments} | items={filteredEquipments} | ||||
| columns={columns} | 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} | pagingController={pagingController} | ||||
| totalCount={totalCount} | totalCount={totalCount} | ||||
| isAutoPaging={false} | 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 Card from "@mui/material/Card"; | ||||
| import CardContent from "@mui/material/CardContent"; | import CardContent from "@mui/material/CardContent"; | ||||
| import Skeleton from "@mui/material/Skeleton"; | import Skeleton from "@mui/material/Skeleton"; | ||||
| @@ -48,6 +48,7 @@ interface BaseColumn<T extends ResultWithId> { | |||||
| style?: Partial<HTMLElement["style"]> & { [propName: string]: string }; | style?: Partial<HTMLElement["style"]> & { [propName: string]: string }; | ||||
| type?: ColumnType; | type?: ColumnType; | ||||
| renderCell?: (params: T) => React.ReactNode; | renderCell?: (params: T) => React.ReactNode; | ||||
| renderHeader?: () => React.ReactNode; | |||||
| } | } | ||||
| interface IconColumn<T extends ResultWithId> extends BaseColumn<T> { | interface IconColumn<T extends ResultWithId> extends BaseColumn<T> { | ||||
| @@ -104,6 +105,8 @@ interface Props<T extends ResultWithId> { | |||||
| checkboxIds?: (string | number)[]; | checkboxIds?: (string | number)[]; | ||||
| setCheckboxIds?: Dispatch<SetStateAction<(string | number)[]>>; | setCheckboxIds?: Dispatch<SetStateAction<(string | number)[]>>; | ||||
| onRowClick?: (item: T) => void; | onRowClick?: (item: T) => void; | ||||
| renderExpandedRow?: (item: T) => React.ReactNode; | |||||
| hideHeader?: boolean; | |||||
| } | } | ||||
| function isActionColumn<T extends ResultWithId>( | function isActionColumn<T extends ResultWithId>( | ||||
| @@ -197,6 +200,8 @@ function EquipmentSearchResults<T extends ResultWithId>({ | |||||
| checkboxIds = [], | checkboxIds = [], | ||||
| setCheckboxIds = undefined, | setCheckboxIds = undefined, | ||||
| onRowClick = undefined, | onRowClick = undefined, | ||||
| renderExpandedRow = undefined, | |||||
| hideHeader = false, | |||||
| }: Props<T>) { | }: Props<T>) { | ||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const [page, setPage] = React.useState(0); | const [page, setPage] = React.useState(0); | ||||
| @@ -303,35 +308,41 @@ function EquipmentSearchResults<T extends ResultWithId>({ | |||||
| const table = ( | const table = ( | ||||
| <> | <> | ||||
| <TableContainer sx={{ maxHeight: 440 }}> | <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> | <TableBody> | ||||
| {isAutoPaging | {isAutoPaging | ||||
| ? items | ? items | ||||
| @@ -339,10 +350,45 @@ function EquipmentSearchResults<T extends ResultWithId>({ | |||||
| (pagingController?.pageNum ? pagingController?.pageNum - 1 :page) * (pagingController?.pageSize ?? rowsPerPage) + (pagingController?.pageSize ?? rowsPerPage)) | (pagingController?.pageNum ? pagingController?.pageNum - 1 :page) * (pagingController?.pageSize ?? rowsPerPage) + (pagingController?.pageSize ?? rowsPerPage)) | ||||
| .map((item) => { | .map((item) => { | ||||
| return ( | 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) => { | onClick={(event) => { | ||||
| setCheckboxIds | setCheckboxIds | ||||
| ? handleRowClick(event, item, columns) | ? handleRowClick(event, item, columns) | ||||
| @@ -370,38 +416,8 @@ function EquipmentSearchResults<T extends ResultWithId>({ | |||||
| ); | ); | ||||
| })} | })} | ||||
| </TableRow> | </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> | </TableBody> | ||||
| @@ -1,3 +1,4 @@ | |||||
| import { useSession } from "next-auth/react"; | |||||
| import Divider from "@mui/material/Divider"; | import Divider from "@mui/material/Divider"; | ||||
| import Box from "@mui/material/Box"; | import Box from "@mui/material/Box"; | ||||
| import React, { useEffect } from "react"; | import React, { useEffect } from "react"; | ||||
| @@ -24,16 +25,38 @@ import { usePathname } from "next/navigation"; | |||||
| import Link from "next/link"; | import Link from "next/link"; | ||||
| import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; | import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; | ||||
| import Logo from "../Logo"; | 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 { | interface NavigationItem { | ||||
| icon: React.ReactNode; | icon: React.ReactNode; | ||||
| label: string; | label: string; | ||||
| path: string; | path: string; | ||||
| children?: NavigationItem[]; | children?: NavigationItem[]; | ||||
| isHidden?: true | undefined; | |||||
| isHidden?: boolean | undefined; | |||||
| requiredAbility?: string | string[]; | |||||
| } | } | ||||
| const NavigationContent: React.FC = () => { | 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[] = [ | const navigationItems: NavigationItem[] = [ | ||||
| { | { | ||||
| icon: <Dashboard />, | 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 />, | icon: <RequestQuote />, | ||||
| label: "Delivery", | label: "Delivery", | ||||
| path: "", | path: "", | ||||
| //requiredAbility: VIEW_DO, | |||||
| children: [ | children: [ | ||||
| { | { | ||||
| icon: <RequestQuote />, | 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 />, | icon: <RequestQuote />, | ||||
| label: "Settings", | label: "Settings", | ||||
| path: "", | path: "", | ||||
| requiredAbility: [VIEW_USER, VIEW_GROUP], | |||||
| children: [ | children: [ | ||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "User", | label: "User", | ||||
| path: "/settings/user", | path: "/settings/user", | ||||
| requiredAbility: VIEW_USER, | |||||
| }, | }, | ||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "User Group", | label: "User Group", | ||||
| path: "/settings/user", | path: "/settings/user", | ||||
| requiredAbility: VIEW_GROUP, | |||||
| }, | }, | ||||
| // { | // { | ||||
| // icon: <RequestQuote />, | // icon: <RequestQuote />, | ||||
| @@ -302,7 +304,7 @@ const NavigationContent: React.FC = () => { | |||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Warehouse", | label: "Warehouse", | ||||
| path: "/settings/user", | |||||
| path: "/settings/warehouse", | |||||
| }, | }, | ||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| @@ -365,7 +367,12 @@ const NavigationContent: React.FC = () => { | |||||
| }; | }; | ||||
| const renderNavigationItem = (item: NavigationItem) => { | const renderNavigationItem = (item: NavigationItem) => { | ||||
| if (!hasAbility(item.requiredAbility)) { | |||||
| return null; | |||||
| } | |||||
| const isOpen = openItems.includes(item.label); | const isOpen = openItems.includes(item.label); | ||||
| const hasVisibleChildren = item.children?.some(child => hasAbility(child.requiredAbility)); | |||||
| return ( | return ( | ||||
| <Box | <Box | ||||
| @@ -381,7 +388,7 @@ const NavigationContent: React.FC = () => { | |||||
| <ListItemIcon>{item.icon}</ListItemIcon> | <ListItemIcon>{item.icon}</ListItemIcon> | ||||
| <ListItemText primary={t(item.label)} /> | <ListItemText primary={t(item.label)} /> | ||||
| </ListItemButton> | </ListItemButton> | ||||
| {item.children && isOpen && ( | |||||
| {item.children && isOpen && hasVisibleChildren && ( | |||||
| <List sx={{ pl: 2 }}> | <List sx={{ pl: 2 }}> | ||||
| {item.children.map( | {item.children.map( | ||||
| (child) => !child.isHidden && renderNavigationItem(child), | (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 ( | return ( | ||||
| <Box sx={{ width: NAVIGATION_CONTENT_WIDTH }}> | <Box sx={{ width: NAVIGATION_CONTENT_WIDTH }}> | ||||
| <Box sx={{ p: 3, display: "flex" }}> | <Box sx={{ p: 3, display: "flex" }}> | ||||
| @@ -402,7 +413,10 @@ const NavigationContent: React.FC = () => { | |||||
| </Box> | </Box> | ||||
| <Divider /> | <Divider /> | ||||
| <List component="nav"> | <List component="nav"> | ||||
| {navigationItems.map((item) => renderNavigationItem(item))} | |||||
| {navigationItems | |||||
| .filter(item => !item.isHidden) | |||||
| .map(renderNavigationItem) | |||||
| .filter(Boolean)} | |||||
| {/* {navigationItems.map(({ icon, label, path }, index) => { | {/* {navigationItems.map(({ icon, label, path }, index) => { | ||||
| return ( | return ( | ||||
| <Box | <Box | ||||
| @@ -52,6 +52,7 @@ interface OptionWithLabel<T extends string> { | |||||
| interface TextCriterion<T extends string> extends BaseCriterion<T> { | interface TextCriterion<T extends string> extends BaseCriterion<T> { | ||||
| type: "text"; | type: "text"; | ||||
| placeholder?: string; | |||||
| } | } | ||||
| interface SelectCriterion<T extends string> extends BaseCriterion<T> { | interface SelectCriterion<T extends string> extends BaseCriterion<T> { | ||||
| @@ -286,6 +287,7 @@ function SearchBox<T extends string>({ | |||||
| <TextField | <TextField | ||||
| label={t(c.label)} | label={t(c.label)} | ||||
| fullWidth | fullWidth | ||||
| placeholder={c.placeholder} | |||||
| onChange={makeInputChangeHandler(c.paramName)} | onChange={makeInputChangeHandler(c.paramName)} | ||||
| value={inputs[c.paramName]} | value={inputs[c.paramName]} | ||||
| /> | /> | ||||
| @@ -306,7 +308,7 @@ function SearchBox<T extends string>({ | |||||
| <Select | <Select | ||||
| label={t(c.label)} | label={t(c.label)} | ||||
| onChange={makeSelectChangeHandler(c.paramName)} | onChange={makeSelectChangeHandler(c.paramName)} | ||||
| value={inputs[c.paramName]} | |||||
| value={inputs[c.paramName] ?? "All"} | |||||
| > | > | ||||
| <MenuItem value={"All"}>{t("All")}</MenuItem> | <MenuItem value={"All"}>{t("All")}</MenuItem> | ||||
| {c.options.map((option) => ( | {c.options.map((option) => ( | ||||
| @@ -323,7 +325,7 @@ function SearchBox<T extends string>({ | |||||
| <Select | <Select | ||||
| label={t(c.label)} | label={t(c.label)} | ||||
| onChange={makeSelectChangeHandler(c.paramName)} | onChange={makeSelectChangeHandler(c.paramName)} | ||||
| value={inputs[c.paramName]} | |||||
| value={inputs[c.paramName] ?? "All"} | |||||
| > | > | ||||
| <MenuItem value={"All"}>{t("All")}</MenuItem> | <MenuItem value={"All"}>{t("All")}</MenuItem> | ||||
| {c.options.map((option) => ( | {c.options.map((option) => ( | ||||
| @@ -18,7 +18,7 @@ import { | |||||
| InputLabel, | InputLabel, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { useState, useMemo, useCallback, useEffect } from "react"; | import { useState, useMemo, useCallback, useEffect } from "react"; | ||||
| import { useRouter } from "next/navigation"; | |||||
| import { useRouter, useSearchParams } from "next/navigation"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import SearchBox, { Criterion } from "../SearchBox"; | import SearchBox, { Criterion } from "../SearchBox"; | ||||
| import SearchResults, { Column } from "../SearchResults"; | import SearchResults, { Column } from "../SearchResults"; | ||||
| @@ -43,6 +43,7 @@ type SearchParamNames = keyof SearchQuery; | |||||
| const Shop: React.FC = () => { | const Shop: React.FC = () => { | ||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const searchParams = useSearchParams(); | |||||
| const [activeTab, setActiveTab] = useState<number>(0); | const [activeTab, setActiveTab] = useState<number>(0); | ||||
| const [rows, setRows] = useState<ShopRow[]>([]); | const [rows, setRows] = useState<ShopRow[]>([]); | ||||
| const [loading, setLoading] = useState<boolean>(false); | const [loading, setLoading] = useState<boolean>(false); | ||||
| @@ -235,26 +236,33 @@ const Shop: React.FC = () => { | |||||
| name: "id", | name: "id", | ||||
| label: t("id"), | label: t("id"), | ||||
| type: "integer", | type: "integer", | ||||
| sx: { width: "100px", minWidth: "100px", maxWidth: "100px" }, | |||||
| renderCell: (item) => String(item.id ?? ""), | renderCell: (item) => String(item.id ?? ""), | ||||
| }, | }, | ||||
| { | { | ||||
| name: "code", | name: "code", | ||||
| label: t("Code"), | label: t("Code"), | ||||
| sx: { width: "150px", minWidth: "150px", maxWidth: "150px" }, | |||||
| renderCell: (item) => String(item.code ?? ""), | renderCell: (item) => String(item.code ?? ""), | ||||
| }, | }, | ||||
| { | { | ||||
| name: "name", | name: "name", | ||||
| label: t("Name"), | label: t("Name"), | ||||
| sx: { width: "200px", minWidth: "200px", maxWidth: "200px" }, | |||||
| renderCell: (item) => String(item.name ?? ""), | renderCell: (item) => String(item.name ?? ""), | ||||
| }, | }, | ||||
| { | { | ||||
| name: "addr3", | name: "addr3", | ||||
| label: t("Addr3"), | label: t("Addr3"), | ||||
| sx: { width: "200px", minWidth: "200px", maxWidth: "200px" }, | |||||
| renderCell: (item) => String((item as any).addr3 ?? ""), | renderCell: (item) => String((item as any).addr3 ?? ""), | ||||
| }, | }, | ||||
| { | { | ||||
| name: "truckLanceStatus", | name: "truckLanceStatus", | ||||
| label: t("TruckLance Status"), | label: t("TruckLance Status"), | ||||
| align: "center", | |||||
| headerAlign: "center", | |||||
| sx: { width: "150px", minWidth: "150px", maxWidth: "150px" }, | |||||
| renderCell: (item) => { | renderCell: (item) => { | ||||
| const status = item.truckLanceStatus; | const status = item.truckLanceStatus; | ||||
| if (status === "complete") { | if (status === "complete") { | ||||
| @@ -269,7 +277,9 @@ const Shop: React.FC = () => { | |||||
| { | { | ||||
| name: "actions", | name: "actions", | ||||
| label: t("Actions"), | label: t("Actions"), | ||||
| align: "right", | |||||
| headerAlign: "right", | headerAlign: "right", | ||||
| sx: { width: "150px", minWidth: "150px", maxWidth: "150px" }, | |||||
| renderCell: (item) => ( | renderCell: (item) => ( | ||||
| <Button | <Button | ||||
| size="small" | 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(() => { | useEffect(() => { | ||||
| if (activeTab === 0) { | if (activeTab === 0) { | ||||
| fetchAllShops(); | fetchAllShops(); | ||||
| @@ -290,82 +311,99 @@ const Shop: React.FC = () => { | |||||
| const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | ||||
| setActiveTab(newValue); | 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 ( | return ( | ||||
| <Box> | <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> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -48,6 +48,7 @@ import { | |||||
| createTruckClient | createTruckClient | ||||
| } from "@/app/api/shop/client"; | } from "@/app/api/shop/client"; | ||||
| import type { SessionWithTokens } from "@/config/authConfig"; | import type { SessionWithTokens } from "@/config/authConfig"; | ||||
| import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil"; | |||||
| type ShopDetailData = { | type ShopDetailData = { | ||||
| id: number; | id: number; | ||||
| @@ -62,61 +63,6 @@ type ShopDetailData = { | |||||
| contactName: String; | 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 | // Utility function to convert HH:mm format to the format expected by backend | ||||
| const parseDepartureTimeForBackend = (time: string): string => { | const parseDepartureTimeForBackend = (time: string): string => { | ||||
| if (!time) return ""; | if (!time) return ""; | ||||
| @@ -299,7 +245,7 @@ const ShopDetail: React.FC = () => { | |||||
| } | } | ||||
| // Convert storeId to string format (2F or 4F) | // 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) | // Get remark value - use the remark from editedTruckData (user input) | ||||
| // Only send remark if storeId is "4F", otherwise send null | // Only send remark if storeId is "4F", otherwise send null | ||||
| @@ -482,7 +428,7 @@ const ShopDetail: React.FC = () => { | |||||
| <Alert severity="error" sx={{ mb: 2 }}> | <Alert severity="error" sx={{ mb: 2 }}> | ||||
| {error} | {error} | ||||
| </Alert> | </Alert> | ||||
| <Button onClick={() => router.back()}>{t("Back")}</Button> | |||||
| <Button onClick={() => router.push("/settings/shop?tab=0")}>{t("Back")}</Button> | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -493,7 +439,7 @@ const ShopDetail: React.FC = () => { | |||||
| <Alert severity="warning" sx={{ mb: 2 }}> | <Alert severity="warning" sx={{ mb: 2 }}> | ||||
| {t("Shop not found")} | {t("Shop not found")} | ||||
| </Alert> | </Alert> | ||||
| <Button onClick={() => router.back()}>{t("Back")}</Button> | |||||
| <Button onClick={() => router.push("/settings/shop?tab=0")}>{t("Back")}</Button> | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -504,7 +450,7 @@ const ShopDetail: React.FC = () => { | |||||
| <CardContent> | <CardContent> | ||||
| <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | ||||
| <Typography variant="h6">{t("Shop Information")}</Typography> | <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> | ||||
| <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> | <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> | ||||
| @@ -682,22 +628,13 @@ const ShopDetail: React.FC = () => { | |||||
| </Select> | </Select> | ||||
| </FormControl> | </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> | ||||
| <TableCell> | <TableCell> | ||||
| {isEditing ? ( | {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"; | const isEditable = storeIdStr === "4F"; | ||||
| return ( | return ( | ||||
| @@ -16,39 +16,27 @@ import { | |||||
| Button, | Button, | ||||
| CircularProgress, | CircularProgress, | ||||
| Alert, | Alert, | ||||
| Dialog, | |||||
| DialogTitle, | |||||
| DialogContent, | |||||
| DialogActions, | |||||
| TextField, | |||||
| Grid, | |||||
| FormControl, | |||||
| InputLabel, | |||||
| Select, | |||||
| MenuItem, | |||||
| Snackbar, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import AddIcon from "@mui/icons-material/Add"; | |||||
| import SaveIcon from "@mui/icons-material/Save"; | |||||
| import { useState, useEffect, useMemo } from "react"; | import { useState, useEffect, useMemo } from "react"; | ||||
| import { useRouter } from "next/navigation"; | import { useRouter } from "next/navigation"; | ||||
| import { useTranslation } from "react-i18next"; | 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 type { Truck } from "@/app/api/shop/actions"; | ||||
| import SearchBox, { Criterion } from "../SearchBox"; | 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 = { | type SearchQuery = { | ||||
| truckLanceCode: string; | truckLanceCode: string; | ||||
| @@ -67,6 +55,15 @@ const TruckLane: React.FC = () => { | |||||
| const [filters, setFilters] = useState<Record<string, string>>({}); | const [filters, setFilters] = useState<Record<string, string>>({}); | ||||
| const [page, setPage] = useState(0); | const [page, setPage] = useState(0); | ||||
| const [rowsPerPage, setRowsPerPage] = useState(10); | 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(() => { | useEffect(() => { | ||||
| const fetchTruckLanes = async () => { | const fetchTruckLanes = async () => { | ||||
| @@ -92,39 +89,34 @@ const TruckLane: React.FC = () => { | |||||
| }; | }; | ||||
| fetchTruckLanes(); | fetchTruckLanes(); | ||||
| }, []); | |||||
| }, [t]); | |||||
| // Client-side filtered rows (contains-matching) | // Client-side filtered rows (contains-matching) | ||||
| const filteredRows = useMemo(() => { | 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( | 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 true; | ||||
| }); | }); | ||||
| return normalized; | |||||
| }, [truckData, filters]); | }, [truckData, filters]); | ||||
| // Paginated rows | // 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) { | if (loading) { | ||||
| return ( | return ( | ||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}> | <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}> | ||||
| @@ -198,16 +273,34 @@ const TruckLane: React.FC = () => { | |||||
| <Card> | <Card> | ||||
| <CardContent> | <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}> | <TableContainer component={Paper}> | ||||
| <Table> | <Table> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <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> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| @@ -220,40 +313,36 @@ const TruckLane: React.FC = () => { | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </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> | </TableBody> | ||||
| </Table> | </Table> | ||||
| @@ -269,6 +358,92 @@ const TruckLane: React.FC = () => { | |||||
| </TableContainer> | </TableContainer> | ||||
| </CardContent> | </CardContent> | ||||
| </Card> | </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> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -19,42 +19,35 @@ import { | |||||
| IconButton, | IconButton, | ||||
| Snackbar, | Snackbar, | ||||
| TextField, | TextField, | ||||
| Autocomplete, | |||||
| Dialog, | |||||
| DialogTitle, | |||||
| DialogContent, | |||||
| DialogActions, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import DeleteIcon from "@mui/icons-material/Delete"; | import DeleteIcon from "@mui/icons-material/Delete"; | ||||
| import EditIcon from "@mui/icons-material/Edit"; | import EditIcon from "@mui/icons-material/Edit"; | ||||
| import SaveIcon from "@mui/icons-material/Save"; | import SaveIcon from "@mui/icons-material/Save"; | ||||
| import CancelIcon from "@mui/icons-material/Cancel"; | import CancelIcon from "@mui/icons-material/Cancel"; | ||||
| import AddIcon from "@mui/icons-material/Add"; | |||||
| import { useState, useEffect } from "react"; | import { useState, useEffect } from "react"; | ||||
| import { useRouter, useSearchParams } from "next/navigation"; | import { useRouter, useSearchParams } from "next/navigation"; | ||||
| import { useTranslation } from "react-i18next"; | 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 TruckLaneDetail: React.FC = () => { | ||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| @@ -72,12 +65,54 @@ const TruckLaneDetail: React.FC = () => { | |||||
| const [shopsLoading, setShopsLoading] = useState<boolean>(false); | const [shopsLoading, setShopsLoading] = useState<boolean>(false); | ||||
| const [saving, setSaving] = useState<boolean>(false); | const [saving, setSaving] = useState<boolean>(false); | ||||
| const [error, setError] = useState<string | null>(null); | 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" }>({ | const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: "success" | "error" }>({ | ||||
| open: false, | open: false, | ||||
| message: "", | message: "", | ||||
| severity: "success", | 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(() => { | useEffect(() => { | ||||
| // Wait a bit to ensure searchParams are fully available | // Wait a bit to ensure searchParams are fully available | ||||
| if (!truckLanceCodeParam) { | if (!truckLanceCodeParam) { | ||||
| @@ -183,28 +218,55 @@ const TruckLaneDetail: React.FC = () => { | |||||
| setSaving(true); | setSaving(true); | ||||
| setError(null); | setError(null); | ||||
| try { | try { | ||||
| // Get LoadingSequence from edited data - handle both PascalCase and camelCase | |||||
| // Get values from edited data | |||||
| const editedShop = editedShopsData[index]; | const editedShop = editedShopsData[index]; | ||||
| const loadingSeq = (editedShop as any)?.LoadingSequence ?? (editedShop as any)?.loadingSequence; | const loadingSeq = (editedShop as any)?.LoadingSequence ?? (editedShop as any)?.loadingSequence; | ||||
| const loadingSequenceValue = (loadingSeq !== null && loadingSeq !== undefined) ? Number(loadingSeq) : 0; | 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) { | if (!shop.truckId) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | open: true, | ||||
| message: "Truck ID is required", | |||||
| message: t("Truck ID is required"), | |||||
| severity: "error", | severity: "error", | ||||
| }); | }); | ||||
| return; | return; | ||||
| } | } | ||||
| await updateLoadingSequenceClient({ | |||||
| await updateTruckShopDetailsClient({ | |||||
| id: shop.truckId, | id: shop.truckId, | ||||
| shopId: shopIdValue, | |||||
| shopName: shopNameValue, | |||||
| shopCode: shopCodeValue, | |||||
| loadingSequence: loadingSequenceValue, | loadingSequence: loadingSequenceValue, | ||||
| remark: remarkValue || null, | |||||
| }); | }); | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | open: true, | ||||
| message: t("Loading sequence updated successfully"), | |||||
| message: t("Truck shop details updated successfully"), | |||||
| severity: "success", | severity: "success", | ||||
| }); | }); | ||||
| @@ -214,10 +276,10 @@ const TruckLaneDetail: React.FC = () => { | |||||
| } | } | ||||
| setEditingRowIndex(null); | setEditingRowIndex(null); | ||||
| } catch (err: any) { | } catch (err: any) { | ||||
| console.error("Failed to save loading sequence:", err); | |||||
| console.error("Failed to save truck shop details:", err); | |||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | 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", | severity: "error", | ||||
| }); | }); | ||||
| } finally { | } finally { | ||||
| @@ -235,6 +297,53 @@ const TruckLaneDetail: React.FC = () => { | |||||
| setEditedShopsData(updated); | 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) => { | const handleDelete = async (truckIdToDelete: number) => { | ||||
| if (!window.confirm(t("Are you sure you want to delete this truck lane?"))) { | if (!window.confirm(t("Are you sure you want to delete this truck lane?"))) { | ||||
| return; | return; | ||||
| @@ -263,7 +372,213 @@ const TruckLaneDetail: React.FC = () => { | |||||
| }; | }; | ||||
| const handleBack = () => { | 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) { | 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 ( | return ( | ||||
| <Box> | <Box> | ||||
| @@ -323,7 +638,7 @@ const TruckLaneDetail: React.FC = () => { | |||||
| <CardContent> | <CardContent> | ||||
| <Paper sx={{ p: 3 }}> | <Paper sx={{ p: 3 }}> | ||||
| <Grid container spacing={3}> | <Grid container spacing={3}> | ||||
| <Grid item xs={12} sm={6}> | |||||
| <Grid item xs={12} sm={4}> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | <Typography variant="subtitle2" color="text.secondary"> | ||||
| {t("TruckLance Code")} | {t("TruckLance Code")} | ||||
| </Typography> | </Typography> | ||||
| @@ -332,7 +647,7 @@ const TruckLaneDetail: React.FC = () => { | |||||
| </Typography> | </Typography> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={12} sm={6}> | |||||
| <Grid item xs={12} sm={4}> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | <Typography variant="subtitle2" color="text.secondary"> | ||||
| {t("Departure Time")} | {t("Departure Time")} | ||||
| </Typography> | </Typography> | ||||
| @@ -345,7 +660,7 @@ const TruckLaneDetail: React.FC = () => { | |||||
| </Typography> | </Typography> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={12} sm={6}> | |||||
| <Grid item xs={12} sm={4}> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | <Typography variant="subtitle2" color="text.secondary"> | ||||
| {t("Store ID")} | {t("Store ID")} | ||||
| </Typography> | </Typography> | ||||
| @@ -361,9 +676,19 @@ const TruckLaneDetail: React.FC = () => { | |||||
| <Card sx={{ mt: 2 }}> | <Card sx={{ mt: 2 }}> | ||||
| <CardContent> | <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 ? ( | {shopsLoading ? ( | ||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}> | <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}> | ||||
| @@ -394,13 +719,144 @@ const TruckLaneDetail: React.FC = () => { | |||||
| shopsData.map((shop, index) => ( | shopsData.map((shop, index) => ( | ||||
| <TableRow key={shop.id ?? `shop-${index}`}> | <TableRow key={shop.id ?? `shop-${index}`}> | ||||
| <TableCell> | <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> | ||||
| <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> | ||||
| <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> | ||||
| <TableCell> | <TableCell> | ||||
| {editingRowIndex === index ? ( | {editingRowIndex === index ? ( | ||||
| @@ -454,7 +910,7 @@ const TruckLaneDetail: React.FC = () => { | |||||
| size="small" | size="small" | ||||
| color="primary" | color="primary" | ||||
| onClick={() => handleEdit(index)} | onClick={() => handleEdit(index)} | ||||
| title={t("Edit loading sequence")} | |||||
| title={t("Edit shop details")} | |||||
| > | > | ||||
| <EditIcon /> | <EditIcon /> | ||||
| </IconButton> | </IconButton> | ||||
| @@ -482,6 +938,117 @@ const TruckLaneDetail: React.FC = () => { | |||||
| </CardContent> | </CardContent> | ||||
| </Card> | </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 | <Snackbar | ||||
| open={snackbar.open} | open={snackbar.open} | ||||
| autoHideDuration={6000} | 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"; | "use client"; | ||||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import SearchBox, { Criterion } from "../SearchBox"; | 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 { useTranslation } from "react-i18next"; | ||||
| import SearchResults, { Column } from "../SearchResults"; | import SearchResults, { Column } from "../SearchResults"; | ||||
| import { EditNote } from "@mui/icons-material"; | |||||
| import { useRouter } from "next/navigation"; | 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 axiosInstance from "@/app/(main)/axios/axiosInstance"; | ||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | 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; | 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 { t } = useTranslation("common"); | ||||
| const [filteredEquipmentDetails, setFilteredEquipmentDetails] = useState<EquipmentDetailResult[]>([]); | |||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const [filterObj, setFilterObj] = useState({}); | |||||
| const { setIsUploading } = useUploadContext(); | |||||
| const [pagingController, setPagingController] = useState({ | const [pagingController, setPagingController] = useState({ | ||||
| pageNum: 1, | pageNum: 1, | ||||
| pageSize: 10, | pageSize: 10, | ||||
| }); | }); | ||||
| const [filterObj, setFilterObj] = useState({}); | |||||
| const [totalCount, setTotalCount] = useState(0); | 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> { | interface ApiResponse<T> { | ||||
| @@ -101,20 +119,19 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => { | |||||
| ...filterObj, | ...filterObj, | ||||
| }; | }; | ||||
| try { | 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 }, | { params }, | ||||
| ); | ); | ||||
| console.log(response); | |||||
| if (response.status == 200) { | if (response.status == 200) { | ||||
| setFilteredEquipments(response.data.records); | |||||
| setFilteredEquipmentDetails(response.data.records); | |||||
| setTotalCount(response.data.total); | setTotalCount(response.data.total); | ||||
| return response; | return response; | ||||
| } else { | } else { | ||||
| throw "400"; | throw "400"; | ||||
| } | } | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error fetching equipment types:", error); | |||||
| console.error("Error fetching equipment details:", error); | |||||
| throw error; | throw error; | ||||
| } | } | ||||
| }, | }, | ||||
| @@ -125,6 +142,228 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => { | |||||
| refetchData(filterObj, pagingController.pageNum, pagingController.pageSize); | refetchData(filterObj, pagingController.pageNum, pagingController.pageSize); | ||||
| }, [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(() => { | const onReset = useCallback(() => { | ||||
| setFilterObj({}); | setFilterObj({}); | ||||
| setPagingController({ pageNum: 1, pageSize: 10 }); | setPagingController({ pageNum: 1, pageSize: 10 }); | ||||
| @@ -138,19 +377,238 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => { | |||||
| setFilterObj({ | setFilterObj({ | ||||
| ...query, | ...query, | ||||
| }); | }); | ||||
| setPagingController({ pageNum: 1, pageSize: 10 }); | |||||
| }} | }} | ||||
| onReset={onReset} | onReset={onReset} | ||||
| /> | /> | ||||
| <SearchResults<EquipmentResult> | |||||
| items={filteredEquipments} | |||||
| <SearchResults<EquipmentDetailResult> | |||||
| items={filteredEquipmentDetails} | |||||
| columns={columns} | columns={columns} | ||||
| setPagingController={setPagingController} | |||||
| pagingController={pagingController} | pagingController={pagingController} | ||||
| setPagingController={setPagingController} | |||||
| totalCount={totalCount} | totalCount={totalCount} | ||||
| isAutoPaging={false} | 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 React from "react"; | ||||
| import QrCodeHandleEquipmentSearch from "./qrCodeHandleEquipmentSearch"; | import QrCodeHandleEquipmentSearch from "./qrCodeHandleEquipmentSearch"; | ||||
| import EquipmentSearchLoading from "../EquipmentSearch/EquipmentSearchLoading"; | 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 { | interface SubComponents { | ||||
| Loading: typeof EquipmentSearchLoading; | Loading: typeof EquipmentSearchLoading; | ||||
| } | } | ||||
| const QrCodeHandleEquipmentSearchWrapper: React.FC & SubComponents = async () => { | 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; | QrCodeHandleEquipmentSearchWrapper.Loading = EquipmentSearchLoading; | ||||
| @@ -1,8 +1,9 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useState, ReactNode } from "react"; | |||||
| import { useState, ReactNode, useEffect } from "react"; | |||||
| import { Box, Tabs, Tab } from "@mui/material"; | import { Box, Tabs, Tab } from "@mui/material"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useSearchParams, useRouter } from "next/navigation"; | |||||
| interface TabPanelProps { | interface TabPanelProps { | ||||
| children?: ReactNode; | children?: ReactNode; | ||||
| @@ -37,10 +38,33 @@ const QrCodeHandleTabs: React.FC<QrCodeHandleTabsProps> = ({ | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const { t: tUser } = useTranslation("user"); | 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) => { | const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | ||||
| setCurrentTab(newValue); | 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 ( | 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 CredentialsProvider from "next-auth/providers/credentials"; | ||||
| import { LOGIN_API_PATH } from "./api"; | 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 = { | export const authOptions: AuthOptions = { | ||||
| @@ -19,18 +39,26 @@ export const authOptions: AuthOptions = { | |||||
| username: { label: "Username", type: "text" }, | username: { label: "Username", type: "text" }, | ||||
| password: { label: "Password", type: "password" }, | 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, { | const res = await fetch(LOGIN_API_PATH, { | ||||
| method: "POST", | method: "POST", | ||||
| body: JSON.stringify(credentials), | body: JSON.stringify(credentials), | ||||
| headers: { "Content-Type": "application/json" }, | headers: { "Content-Type": "application/json" }, | ||||
| }); | }); | ||||
| if (!res.ok) return null; | |||||
| const user = await res.json(); | 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; | return null; | ||||
| }, | }, | ||||
| }), | }), | ||||
| @@ -39,25 +67,36 @@ export const authOptions: AuthOptions = { | |||||
| signIn: "/login", | signIn: "/login", | ||||
| }, | }, | ||||
| callbacks: { | 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", | "Equipment not found": "Equipment not found", | ||||
| "Error saving data": "Error saving data", | "Error saving data": "Error saving data", | ||||
| "Cancel": "Cancel", | "Cancel": "Cancel", | ||||
| "Do you want to delete?": "Do you want to delete?", | |||||
| "Save": "Save", | "Save": "Save", | ||||
| "Yes": "Yes", | "Yes": "Yes", | ||||
| "No": "No", | "No": "No", | ||||
| "Equipment Name": "Equipment Name", | "Equipment Name": "Equipment Name", | ||||
| "Equipment Code": "Equipment Code", | "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 ID": "用戶ID", | ||||
| "User Name": "用戶名稱", | "User Name": "用戶名稱", | ||||
| "User Group": "用戶群組", | "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": "生產前預備時間", | "Setup Time": "生產前預備時間", | ||||
| "Changeover Time": "生產後轉換時間", | "Changeover Time": "生產後轉換時間", | ||||
| "Warehouse": "倉庫", | "Warehouse": "倉庫", | ||||
| "warehouse": "倉庫", | |||||
| "Supplier": "供應商", | "Supplier": "供應商", | ||||
| "Purchase Order": "採購單", | "Purchase Order": "採購單", | ||||
| "Demand Forecast": "需求預測", | "Demand Forecast": "需求預測", | ||||
| @@ -268,6 +269,7 @@ | |||||
| "Seq No Remark": "序號明細", | "Seq No Remark": "序號明細", | ||||
| "Stock Available": "庫存可用", | "Stock Available": "庫存可用", | ||||
| "Confirm": "確認", | "Confirm": "確認", | ||||
| "Do you want to delete?": "您確定要刪除嗎?", | |||||
| "Stock Status": "庫存狀態", | "Stock Status": "庫存狀態", | ||||
| "Target Production Date": "目標生產日期", | "Target Production Date": "目標生產日期", | ||||
| "id": "ID", | "id": "ID", | ||||
| @@ -383,19 +385,33 @@ | |||||
| "Filter by Status": "按狀態篩選", | "Filter by Status": "按狀態篩選", | ||||
| "All": "全部", | "All": "全部", | ||||
| "General Data": "基本資料", | "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": "每頁行數", | "Rows per page": "每頁行數", | ||||
| "Equipment Name": "設備名稱", | "Equipment Name": "設備名稱", | ||||
| "Equipment Code": "設備編號", | "Equipment Code": "設備編號", | ||||
| "Yes": "是", | "Yes": "是", | ||||
| "No": "否", | "No": "否", | ||||
| "Update Equipment Maintenance and Repair": "更新設備的維修和保養", | |||||
| "Update Equipment Maintenance and Repair": "更新設備的維護和保養", | |||||
| "Equipment Information": "設備資訊", | "Equipment Information": "設備資訊", | ||||
| "Loading": "載入中...", | "Loading": "載入中...", | ||||
| "Equipment not found": "找不到設備", | "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": "盤點記錄狀態更新為數值不符", | "Stock take record status updated to not match": "盤點記錄狀態更新為數值不符", | ||||
| "available": "可用", | "available": "可用", | ||||
| "Item-lotNo-ExpiryDate": "貨品-批號-到期日", | "Item-lotNo-ExpiryDate": "貨品-批號-到期日", | ||||
| "Item-lotNo-ExpiryDate": "貨品-批號-到期日", | |||||
| "not available": "不可用", | "not available": "不可用", | ||||
| "Batch Submit All": "批量提交所有", | "Batch Submit All": "批量提交所有", | ||||
| "Batch Save All": "批量保存所有", | "Batch Save All": "批量保存所有", | ||||
| "Batch Submit All": "批量提交所有", | |||||
| "Batch Save All": "批量保存所有", | |||||
| "not match": "數值不符", | "not match": "數值不符", | ||||
| "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數(含壞品)= 可用數", | "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數(含壞品)= 可用數", | ||||
| "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數(含壞品)= 可用數", | |||||
| "View ReStockTake": "查看重新盤點", | "View ReStockTake": "查看重新盤點", | ||||
| "Stock Take Qty": "盤點數", | "Stock Take Qty": "盤點數", | ||||
| "Stock Take Qty": "盤點數", | |||||
| "ReStockTake": "重新盤點", | "ReStockTake": "重新盤點", | ||||
| "Stock Taker": "盤點員", | "Stock Taker": "盤點員", | ||||
| "Total Item Number": "貨品數量", | "Total Item Number": "貨品數量", | ||||
| @@ -31,6 +37,16 @@ | |||||
| "book qty": "帳面庫存", | "book qty": "帳面庫存", | ||||
| "start time": "開始時間", | "start time": "開始時間", | ||||
| "end 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": "僅差異", | "Only Variance": "僅差異", | ||||
| "Control Time": "操作時間", | "Control Time": "操作時間", | ||||
| "pass": "通過", | "pass": "通過", | ||||
| @@ -41,6 +57,7 @@ | |||||
| "Last Stock Take Date": "上次盤點日期", | "Last Stock Take Date": "上次盤點日期", | ||||
| "Remark": "備註", | "Remark": "備註", | ||||
| "notMatch": "數值不符", | "notMatch": "數值不符", | ||||
| "notMatch": "數值不符", | |||||
| "Stock take record saved successfully": "盤點記錄保存成功", | "Stock take record saved successfully": "盤點記錄保存成功", | ||||
| "View Details": "查看詳細", | "View Details": "查看詳細", | ||||
| "Input": "輸入", | "Input": "輸入", | ||||
| @@ -28,5 +28,7 @@ | |||||
| "user": "用戶", | "user": "用戶", | ||||
| "qrcode": "二維碼", | "qrcode": "二維碼", | ||||
| "staffNo": "員工編號", | "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": "搜尋" | |||||
| } | |||||