Procházet zdrojové kódy

Merge branch 'master' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend

# Conflicts:
#	src/i18n/zh/inventory.json
master
CANCERYS\kw093 před 3 týdny
rodič
revize
af67861fad
48 změnil soubory, kde provedl 4488 přidání a 571 odebrání
  1. +316
    -0
      src/app/(main)/ps/page.tsx
  2. +2
    -1
      src/app/(main)/settings/equipment/page.tsx
  3. +21
    -0
      src/app/(main)/settings/warehouse/create/page.tsx
  4. +45
    -0
      src/app/(main)/settings/warehouse/page.tsx
  5. +306
    -0
      src/app/(main)/testing/page.tsx
  6. +24
    -0
      src/app/api/scheduling/actions.ts
  7. +2
    -0
      src/app/api/scheduling/index.ts
  8. +33
    -0
      src/app/api/settings/equipment/client.ts
  9. +33
    -0
      src/app/api/settings/equipmentDetail/client.ts
  10. +32
    -0
      src/app/api/settings/equipmentDetail/index.ts
  11. +76
    -5
      src/app/api/shop/actions.ts
  12. +35
    -4
      src/app/api/shop/client.ts
  13. +57
    -1
      src/app/api/warehouse/actions.ts
  14. +7
    -0
      src/app/api/warehouse/index.ts
  15. +42
    -0
      src/app/utils/formatUtil.ts
  16. +14
    -0
      src/authorities.ts
  17. +0
    -6
      src/authorties.ts
  18. +1
    -0
      src/components/Breadcrumb/Breadcrumb.tsx
  19. +148
    -0
      src/components/CreateWarehouse/CreateWarehouse.tsx
  20. +29
    -0
      src/components/CreateWarehouse/CreateWarehouseLoading.tsx
  21. +15
    -0
      src/components/CreateWarehouse/CreateWarehouseWrapper.tsx
  22. +139
    -0
      src/components/CreateWarehouse/WarehouseDetail.tsx
  23. +1
    -0
      src/components/CreateWarehouse/index.ts
  24. +66
    -0
      src/components/DetailedSchedule/DetailedScheduleSearchView.tsx
  25. +747
    -58
      src/components/EquipmentSearch/EquipmentSearch.tsx
  26. +2
    -0
      src/components/EquipmentSearch/EquipmentSearchLoading.tsx
  27. +81
    -65
      src/components/EquipmentSearch/EquipmentSearchResults.tsx
  28. +57
    -43
      src/components/NavigationContent/NavigationContent.tsx
  29. +4
    -2
      src/components/SearchBox/SearchBox.tsx
  30. +107
    -69
      src/components/Shop/Shop.tsx
  31. +7
    -70
      src/components/Shop/ShopDetail.tsx
  32. +264
    -89
      src/components/Shop/TruckLane.tsx
  33. +618
    -53
      src/components/Shop/TruckLaneDetail.tsx
  34. +364
    -0
      src/components/WarehouseHandle/WarehouseHandle.tsx
  35. +40
    -0
      src/components/WarehouseHandle/WarehouseHandleLoading.tsx
  36. +19
    -0
      src/components/WarehouseHandle/WarehouseHandleWrapper.tsx
  37. +1
    -0
      src/components/WarehouseHandle/index.ts
  38. +520
    -62
      src/components/qrCodeHandles/qrCodeHandleEquipmentSearch.tsx
  39. +7
    -3
      src/components/qrCodeHandles/qrCodeHandleEquipmentSearchWrapper.tsx
  40. +26
    -2
      src/components/qrCodeHandles/qrCodeHandleTabs.tsx
  41. +65
    -26
      src/config/authConfig.ts
  42. +15
    -3
      src/i18n/en/common.json
  43. +3
    -1
      src/i18n/en/user.json
  44. +27
    -0
      src/i18n/en/warehouse.json
  45. +23
    -7
      src/i18n/zh/common.json
  46. +17
    -0
      src/i18n/zh/inventory.json
  47. +3
    -1
      src/i18n/zh/user.json
  48. +27
    -0
      src/i18n/zh/warehouse.json

+ 316
- 0
src/app/(main)/ps/page.tsx Zobrazit soubor

@@ -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>
);
}

+ 2
- 1
src/app/(main)/settings/equipment/page.tsx Zobrazit soubor

@@ -12,6 +12,7 @@ import { Suspense } from "react";
import { fetchAllEquipments } from "@/app/api/settings/equipment";
import { I18nProvider } from "@/i18n";
import EquipmentSearchWrapper from "@/components/EquipmentSearch/EquipmentSearchWrapper";
import EquipmentSearchLoading from "@/components/EquipmentSearch/EquipmentSearchLoading";

export const metadata: Metadata = {
title: "Equipment Type",
@@ -33,7 +34,7 @@ const productSetting: React.FC = async () => {
{t("Equipment")}
</Typography>
</Stack>
<Suspense fallback={<EquipmentSearchWrapper.Loading />}>
<Suspense fallback={<EquipmentSearchLoading />}>
<I18nProvider namespaces={["common", "project"]}>
<EquipmentSearchWrapper />
</I18nProvider>


+ 21
- 0
src/app/(main)/settings/warehouse/create/page.tsx Zobrazit soubor

@@ -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;

+ 45
- 0
src/app/(main)/settings/warehouse/page.tsx Zobrazit soubor

@@ -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;

+ 306
- 0
src/app/(main)/testing/page.tsx Zobrazit soubor

@@ -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>
);
}

+ 24
- 0
src/app/api/scheduling/actions.ts Zobrazit soubor

@@ -43,6 +43,13 @@ export interface ReleaseProdScheduleReq {
id: number;
}

export interface print6FilesReq {
itemCode: 'string',
lotNo: 'string',
expiryDate: 'string',
productName: 'string'
}

export interface ReleaseProdScheduleResponse {
id: number;
code: string;
@@ -145,6 +152,23 @@ export const testDetailedSchedule = cache(async (date?: string) => {
);
});

export const getFile6 = cache(async (
token: string | "",
data: print6FilesReq
) => {
const queryStr = convertObjToURLSearchParams(data)
return serverFetchJson(
`${BASE_API_URL}/plastic/get-printer6?${queryStr}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
},
);
});

export const releaseProdScheduleLine = cache(async (data: ReleaseProdScheduleInputs) => {
const response = serverFetchJson<ReleaseProdScheduleResponse>(
`${BASE_API_URL}/productionSchedule/detail/detailed/releaseLine`,


+ 2
- 0
src/app/api/scheduling/index.ts Zobrazit soubor

@@ -9,6 +9,7 @@ export type ScheduleType = "all" | "rough" | "detailed" | "manual";
export interface RoughProdScheduleResult {
id: number;
scheduleAt: number[];
produceAt: number[];
schedulePeriod: number[];
schedulePeriodTo: number[];
totalEstProdCount: number;
@@ -80,6 +81,7 @@ export interface RoughProdScheduleLineResultByBomByDate {
// Detailed
export interface DetailedProdScheduleResult {
id: number;
produceAt: number[];
scheduleAt: number[];
totalEstProdCount: number;
totalFGType: number;


+ 33
- 0
src/app/api/settings/equipment/client.ts Zobrazit soubor

@@ -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 };
};

+ 33
- 0
src/app/api/settings/equipmentDetail/client.ts Zobrazit soubor

@@ -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 };
};

+ 32
- 0
src/app/api/settings/equipmentDetail/index.ts Zobrazit soubor

@@ -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"] },
},
);
});

+ 76
- 5
src/app/api/shop/actions.ts Zobrazit soubor

@@ -46,6 +46,8 @@ export interface Truck{
districtReference: Number;
storeId: Number | String;
remark?: String | null;
shopName?: String | null;
shopCode?: String | null;
}

export interface SaveTruckLane {
@@ -62,9 +64,13 @@ export interface DeleteTruckLane {
id: number;
}

export interface UpdateLoadingSequenceRequest {
export interface UpdateTruckShopDetailsRequest {
id: number;
shopId?: number | null;
shopName: string | null;
shopCode: string | null;
loadingSequence: number;
remark?: string | null;
}

export interface SaveTruckRequest {
@@ -80,6 +86,15 @@ export interface SaveTruckRequest {
remark?: string | null;
}

export interface CreateTruckWithoutShopRequest {
store_id: string;
truckLanceCode: string;
departureTime: string;
loadingSequence?: number;
districtReference?: number | null;
remark?: string | null;
}

export interface MessageResponse {
id: number | null;
name: string | null;
@@ -137,7 +152,7 @@ export const deleteTruckLaneAction = async (data: DeleteTruckLane) => {
};

export const createTruckAction = async (data: SaveTruckRequest) => {
const endpoint = `${BASE_API_URL}/truck/create`;
const endpoint = `${BASE_API_URL}/truck/createTruckInShop`;
return serverFetchJson<MessageResponse>(endpoint, {
method: "POST",
@@ -175,12 +190,68 @@ export const findAllShopsByTruckLanceCodeAction = cache(async (truckLanceCode: s
});
});

export const updateLoadingSequenceAction = async (data: UpdateLoadingSequenceRequest) => {
const endpoint = `${BASE_API_URL}/truck/updateLoadingSequence`;
export const findAllByTruckLanceCodeAndDeletedFalseAction = cache(async (truckLanceCode: string) => {
const endpoint = `${BASE_API_URL}/truck/findAllByTruckLanceCodeAndDeletedFalse`;
const url = `${endpoint}?truckLanceCode=${encodeURIComponent(truckLanceCode)}`;
return serverFetchJson<Truck[]>(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
});

export const updateTruckShopDetailsAction = async (data: UpdateTruckShopDetailsRequest) => {
const endpoint = `${BASE_API_URL}/truck/updateTruckShopDetails`;
return serverFetchJson<MessageResponse>(endpoint, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
};

export const createTruckWithoutShopAction = async (data: CreateTruckWithoutShopRequest) => {
const endpoint = `${BASE_API_URL}/truck/createTruckWithoutShop`;
return serverFetchJson<MessageResponse>(endpoint, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
};
};

export const findAllUniqueShopNamesAndCodesFromTrucksAction = cache(async () => {
const endpoint = `${BASE_API_URL}/truck/findAllUniqueShopNamesAndCodesFromTrucks`;
return serverFetchJson<Array<{ name: string; code: string }>>(endpoint, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
});

export const findAllUniqueRemarksFromTrucksAction = cache(async () => {
const endpoint = `${BASE_API_URL}/truck/findAllUniqueRemarksFromTrucks`;
return serverFetchJson<string[]>(endpoint, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
});

export const findAllUniqueShopCodesFromTrucksAction = cache(async () => {
const endpoint = `${BASE_API_URL}/truck/findAllUniqueShopCodesFromTrucks`;
return serverFetchJson<string[]>(endpoint, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
});

export const findAllUniqueShopNamesFromTrucksAction = cache(async () => {
const endpoint = `${BASE_API_URL}/truck/findAllUniqueShopNamesFromTrucks`;
return serverFetchJson<string[]>(endpoint, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
});

+ 35
- 4
src/app/api/shop/client.ts Zobrazit soubor

@@ -9,11 +9,18 @@ import {
findAllUniqueTruckLaneCombinationsAction,
findAllShopsByTruckLanceCodeAndRemarkAction,
findAllShopsByTruckLanceCodeAction,
updateLoadingSequenceAction,
createTruckWithoutShopAction,
updateTruckShopDetailsAction,
findAllUniqueShopNamesAndCodesFromTrucksAction,
findAllUniqueRemarksFromTrucksAction,
findAllUniqueShopCodesFromTrucksAction,
findAllUniqueShopNamesFromTrucksAction,
findAllByTruckLanceCodeAndDeletedFalseAction,
type SaveTruckLane,
type DeleteTruckLane,
type SaveTruckRequest,
type UpdateLoadingSequenceRequest,
type UpdateTruckShopDetailsRequest,
type CreateTruckWithoutShopRequest,
type MessageResponse
} from "./actions";

@@ -49,8 +56,32 @@ export const findAllShopsByTruckLanceCodeClient = async (truckLanceCode: string)
return await findAllShopsByTruckLanceCodeAction(truckLanceCode);
};

export const updateLoadingSequenceClient = async (data: UpdateLoadingSequenceRequest): Promise<MessageResponse> => {
return await updateLoadingSequenceAction(data);
export const findAllByTruckLanceCodeAndDeletedFalseClient = async (truckLanceCode: string) => {
return await findAllByTruckLanceCodeAndDeletedFalseAction(truckLanceCode);
};

export const updateTruckShopDetailsClient = async (data: UpdateTruckShopDetailsRequest): Promise<MessageResponse> => {
return await updateTruckShopDetailsAction(data);
};

export const createTruckWithoutShopClient = async (data: CreateTruckWithoutShopRequest): Promise<MessageResponse> => {
return await createTruckWithoutShopAction(data);
};

export const findAllUniqueShopNamesAndCodesFromTrucksClient = async () => {
return await findAllUniqueShopNamesAndCodesFromTrucksAction();
};

export const findAllUniqueRemarksFromTrucksClient = async () => {
return await findAllUniqueRemarksFromTrucksAction();
};

export const findAllUniqueShopCodesFromTrucksClient = async () => {
return await findAllUniqueShopCodesFromTrucksAction();
};

export const findAllUniqueShopNamesFromTrucksClient = async () => {
return await findAllUniqueShopNamesFromTrucksAction();
};

export default fetchAllShopsClient;

+ 57
- 1
src/app/api/warehouse/actions.ts Zobrazit soubor

@@ -1,7 +1,63 @@
"use server";

import { serverFetchString } from "@/app/utils/fetchUtil";
import { serverFetchString, serverFetchWithNoContent, serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { revalidateTag } from "next/cache";
import { WarehouseResult } from "./index";
import { cache } from "react";

export interface WarehouseInputs {
code?: string;
name?: string;
description?: string;
capacity?: number;
store_id?: string;
warehouse?: string;
area?: string;
slot?: string;
stockTakeSection?: string;
}

export const fetchWarehouseDetail = cache(async (id: number) => {
return serverFetchJson<WarehouseResult>(`${BASE_API_URL}/warehouse/${id}`, {
next: { tags: ["warehouse"] },
});
});

export const createWarehouse = async (data: WarehouseInputs) => {
const newWarehouse = await serverFetchWithNoContent(`${BASE_API_URL}/warehouse/save`, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
revalidateTag("warehouse");
return newWarehouse;
};

export const editWarehouse = async (id: number, data: WarehouseInputs) => {
const updatedWarehouse = await serverFetchWithNoContent(`${BASE_API_URL}/warehouse/${id}`, {
method: "PUT",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
revalidateTag("warehouse");
return updatedWarehouse;
};

export const deleteWarehouse = async (id: number) => {
try {
const result = await serverFetchJson<WarehouseResult[]>(`${BASE_API_URL}/warehouse/${id}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
});
revalidateTag("warehouse");
return result;
} catch (error) {
console.error("Error deleting warehouse:", error);
revalidateTag("warehouse");
throw error;
}
};

export const importWarehouse = async (data: FormData) => {
const importWarehouse = await serverFetchString<string>(


+ 7
- 0
src/app/api/warehouse/index.ts Zobrazit soubor

@@ -4,10 +4,17 @@ import { serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";

export interface WarehouseResult {
action: any;
id: number;
code: string;
name: string;
description: string;
store_id?: string;
warehouse?: string;
area?: string;
slot?: string;
order?: number;
stockTakeSection?: string;
}

export interface WarehouseCombo {


+ 42
- 0
src/app/utils/formatUtil.ts Zobrazit soubor

@@ -151,3 +151,45 @@ export const calculateWeight = (qty: number, uom: Uom) => {
export const returnWeightUnit = (uom: Uom) => {
return uom.unit4 || uom.unit3 || uom.unit2 || uom.unit1;
};

/**
* Formats departure time to HH:mm format
* Handles array format [hours, minutes] from API and string formats
*/
export const formatDepartureTime = (time: string | number[] | String | Number | null | undefined): string => {
if (!time) return "-";
// Handle array format [hours, minutes] from API
if (Array.isArray(time) && time.length >= 2) {
const hours = time[0];
const minutes = time[1];
if (typeof hours === 'number' && typeof minutes === 'number' &&
hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
}
}
const timeStr = String(time).trim();
if (!timeStr || timeStr === "-") return "-";
// If already in HH:mm format, return as is
if (/^\d{1,2}:\d{2}$/.test(timeStr)) {
const [hours, minutes] = timeStr.split(":");
return `${hours.padStart(2, "0")}:${minutes.padStart(2, "0")}`;
}
return timeStr;
};

/**
* Normalizes store ID to display format (2F or 4F)
*/
export const normalizeStoreId = (storeId: string | number | String | Number | null | undefined): string => {
if (!storeId) return "-";
const storeIdStr = typeof storeId === 'string' || storeId instanceof String
? String(storeId)
: String(storeId);
if (storeIdStr === "2" || storeIdStr === "2F") return "2F";
if (storeIdStr === "4" || storeIdStr === "4F") return "4F";
return storeIdStr;
};

+ 14
- 0
src/authorities.ts Zobrazit soubor

@@ -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",
];

+ 0
- 6
src/authorties.ts Zobrazit soubor

@@ -1,6 +0,0 @@
export const [VIEW_USER, MAINTAIN_USER, VIEW_GROUP, MAINTAIN_GROUP] = [
"VIEW_USER",
"MAINTAIN_USER",
"VIEW_GROUP",
"MAINTAIN_GROUP",
];

+ 1
- 0
src/components/Breadcrumb/Breadcrumb.tsx Zobrazit soubor

@@ -17,6 +17,7 @@ const pathToLabelMap: { [path: string]: string } = {
"/settings/qrCodeHandle": "QR Code Handle",
"/settings/rss": "Demand Forecast Setting",
"/settings/equipment": "Equipment",
"/settings/equipment/MaintenanceEdit": "MaintenanceEdit",
"/settings/shop": "ShopAndTruck",
"/settings/shop/detail": "Shop Detail",
"/settings/shop/truckdetail": "Truck Lane Detail",


+ 148
- 0
src/components/CreateWarehouse/CreateWarehouse.tsx Zobrazit soubor

@@ -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;

+ 29
- 0
src/components/CreateWarehouse/CreateWarehouseLoading.tsx Zobrazit soubor

@@ -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;

+ 15
- 0
src/components/CreateWarehouse/CreateWarehouseWrapper.tsx Zobrazit soubor

@@ -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;

+ 139
- 0
src/components/CreateWarehouse/WarehouseDetail.tsx Zobrazit soubor

@@ -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;

+ 1
- 0
src/components/CreateWarehouse/index.ts Zobrazit soubor

@@ -0,0 +1 @@
export { default } from "./CreateWarehouseWrapper";

+ 66
- 0
src/components/DetailedSchedule/DetailedScheduleSearchView.tsx Zobrazit soubor

@@ -7,6 +7,7 @@ import { EditNote } from "@mui/icons-material";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
import { ScheduleType } from "@/app/api/scheduling";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import {
ProdScheduleResult,
SearchProdSchedule,
@@ -14,6 +15,7 @@ import {
fetchProdSchedules,
exportProdSchedule,
testDetailedSchedule,
getFile6,
} from "@/app/api/scheduling/actions";
import { defaultPagingController } from "../SearchResults/SearchResults";
import { arrayToDateString, arrayToDayjs, decimalFormatter } from "@/app/utils/formatUtil";
@@ -23,6 +25,9 @@ import { Button, Stack } from "@mui/material";
import isToday from 'dayjs/plugin/isToday';
import useUploadContext from "../UploadProvider/useUploadContext";
import { FileDownload, CalendarMonth } from "@mui/icons-material";
import { useSession } from "next-auth/react";
import { VIEW_USER } from "@/authorities";

dayjs.extend(isToday);

// may need move to "index" or "actions"
@@ -52,6 +57,10 @@ const DSOverview: React.FC<Props> = ({ type, defaultInputs }) => {
const { setIsUploading } = useUploadContext();
const today = dayjs().format("YYYY-MM-DD");

const { data: session } = useSession();
// Extract abilities (safe fallback to empty array if not logged in / no abilities)
const abilities = session?.user?.abilities ?? [];

const router = useRouter();
// const [filterObj, setFilterObj] = useState({});
// const [tempSelectedValue, setTempSelectedValue] = useState({});
@@ -226,6 +235,48 @@ const DSOverview: React.FC<Props> = ({ type, defaultInputs }) => {
refetchData(resetWithToday, "reset"); // Fetch data
}, [defaultInputs, refetchData]);

const handleDownloadPrintJob = async () => {
const token = localStorage.getItem("accessToken");

const params = {
itemCode: 'TT173',
lotNo: 'LOT342989',
expiryDate: '2026-02-28',
productName: 'Name2342'
};

try {
// 1. Direct fetch call to avoid Next.js trying to parse JSON
const query = new URLSearchParams(params).toString();
const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/get-printer6?${query}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
});

if (!response.ok) throw new Error('Network response was not ok');

// 2. GET THE DATA AS BLOB (This is the fix)
const blob = await response.blob();

// 3. Create a download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `${params.lotNo}.zip`);
document.body.appendChild(link);
link.click();
// Cleanup
link.parentNode?.removeChild(link);
window.URL.revokeObjectURL(url);

} catch (error) {
console.error("Download failed", error);
}
};

const testDetailedScheduleClick = useCallback(async () => {
try {
setIsUploading(true)
@@ -332,6 +383,21 @@ const DSOverview: React.FC<Props> = ({ type, defaultInputs }) => {
>
{t("Export Schedule")}
</Button>

{false && abilities.includes(VIEW_USER) && (
<Button
variant="contained" // Solid button for the "Export" action
color="success" // Green color often signifies a successful action/download
startIcon={<FileDownload />}
onClick={handleDownloadPrintJob}
sx={{
boxShadow: 2,
'&:hover': { backgroundColor: 'success.dark', boxShadow: 4 }
}}
>
Get Printer File API
</Button>
)}
</Stack>
<SearchBox
criteria={searchCriteria}


+ 747
- 58
src/components/EquipmentSearch/EquipmentSearch.tsx Zobrazit soubor

@@ -1,20 +1,36 @@
"use client";

import { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import SearchBox, { Criterion } from "../SearchBox";
import { EquipmentResult } from "@/app/api/settings/equipment";
import { useTranslation } from "react-i18next";
import EquipmentSearchResults, { Column } from "./EquipmentSearchResults";
import { EditNote } from "@mui/icons-material";
import { useRouter, useSearchParams } from "next/navigation";
import { GridDeleteIcon } from "@mui/x-data-grid";
import { TypeEnum } from "@/app/utils/typeEnum";
import axios from "axios";
import { useRouter } from "next/navigation";
import { BASE_API_URL, NEXT_PUBLIC_API_URL } from "@/config/api";
import axiosInstance from "@/app/(main)/axios/axiosInstance";
import { arrayToDateTimeString } from "@/app/utils/formatUtil";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import CircularProgress from "@mui/material/CircularProgress";
import TableRow from "@mui/material/TableRow";
import TableCell from "@mui/material/TableCell";
import Collapse from "@mui/material/Collapse";
import Grid from "@mui/material/Grid";
import DeleteIcon from "@mui/icons-material/Delete";
import AddIcon from "@mui/icons-material/Add";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogActions from "@mui/material/DialogActions";
import TextField from "@mui/material/TextField";
import Autocomplete from "@mui/material/Autocomplete";
import InputAdornment from "@mui/material/InputAdornment";

type Props = {
equipments: EquipmentResult[];
@@ -28,14 +44,39 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
useState<EquipmentResult[]>([]);
const { t } = useTranslation("common");
const router = useRouter();
const [filterObj, setFilterObj] = useState({});
const [pagingController, setPagingController] = useState({
pageNum: 1,
pageSize: 10,
const [filterObjByTab, setFilterObjByTab] = useState<Record<number, SearchQuery>>({
0: {},
1: {},
});
const [pagingControllerByTab, setPagingControllerByTab] = useState<Record<number, { pageNum: number; pageSize: number }>>({
0: { pageNum: 1, pageSize: 10 },
1: { pageNum: 1, pageSize: 10 },
});
const [totalCount, setTotalCount] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [isReady, setIsReady] = useState(false);

const filterObj = filterObjByTab[tabIndex] || {};
const pagingController = pagingControllerByTab[tabIndex] || { pageNum: 1, pageSize: 10 };
const [expandedRows, setExpandedRows] = useState<Set<string | number>>(new Set());
const [equipmentDetailsMap, setEquipmentDetailsMap] = useState<Map<string | number, EquipmentResult[]>>(new Map());
const [loadingDetailsMap, setLoadingDetailsMap] = useState<Map<string | number, boolean>>(new Map());
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<{ id: string | number; equipmentId: string | number } | null>(null);
const [deleting, setDeleting] = useState(false);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [equipmentList, setEquipmentList] = useState<EquipmentResult[]>([]);
const [selectedDescription, setSelectedDescription] = useState<string>("");
const [selectedName, setSelectedName] = useState<string>("");
const [selectedEquipmentCode, setSelectedEquipmentCode] = useState<string>("");
const [equipmentCodePrefix, setEquipmentCodePrefix] = useState<string>("");
const [equipmentCodeNumber, setEquipmentCodeNumber] = useState<string>("");
const [isExistingCombination, setIsExistingCombination] = useState(false);
const [loadingEquipments, setLoadingEquipments] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
const checkReady = () => {
@@ -90,20 +131,12 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
},
];
}
return [
{ label: t("Code"), paramName: "code", type: "text" },
{ label: t("Description"), paramName: "description", type: "text" },
{ label: "設備編號", paramName: "code", type: "text" },
];
}, [t, tabIndex]);

const onDetailClick = useCallback(
(equipment: EquipmentResult) => {
router.push(`/settings/equipment/edit?id=${equipment.id}`);
},
[router],
);

const onMaintenanceEditClick = useCallback(
(equipment: EquipmentResult) => {
router.push(`/settings/equipment/MaintenanceEdit?id=${equipment.id}`);
@@ -116,34 +149,292 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
[router],
);

const fetchEquipmentDetailsByEquipmentId = useCallback(async (equipmentId: string | number) => {
setLoadingDetailsMap(prev => new Map(prev).set(equipmentId, true));
try {
const response = await axiosInstance.get<{
records: EquipmentResult[];
total: number;
}>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/byEquipmentId/${equipmentId}`);
if (response.status === 200) {
setEquipmentDetailsMap(prev => new Map(prev).set(equipmentId, response.data.records || []));
}
} catch (error) {
console.error("Error fetching equipment details:", error);
setEquipmentDetailsMap(prev => new Map(prev).set(equipmentId, []));
} finally {
setLoadingDetailsMap(prev => new Map(prev).set(equipmentId, false));
}
}, []);

const handleDeleteClick = useCallback((detailId: string | number, equipmentId: string | number) => {
setItemToDelete({ id: detailId, equipmentId });
setDeleteDialogOpen(true);
}, []);

const handleDeleteConfirm = useCallback(async () => {
if (!itemToDelete) return;

setDeleting(true);
try {
const response = await axiosInstance.delete(
`${NEXT_PUBLIC_API_URL}/EquipmentDetail/delete/${itemToDelete.id}`
);

if (response.status === 200 || response.status === 204) {
setEquipmentDetailsMap(prev => {
const newMap = new Map(prev);
const currentDetails = newMap.get(itemToDelete.equipmentId) || [];
const updatedDetails = currentDetails.filter(detail => detail.id !== itemToDelete.id);
newMap.set(itemToDelete.equipmentId, updatedDetails);
return newMap;
});
}
} catch (error) {
console.error("Error deleting equipment detail:", error);
alert("刪除失敗,請稍後再試");
} finally {
setDeleting(false);
setDeleteDialogOpen(false);
setItemToDelete(null);
}
}, [itemToDelete]);

const handleDeleteCancel = useCallback(() => {
setDeleteDialogOpen(false);
setItemToDelete(null);
}, []);

const fetchEquipmentList = useCallback(async () => {
setLoadingEquipments(true);
try {
const response = await axiosInstance.get<{
records: EquipmentResult[];
total: number;
}>(`${NEXT_PUBLIC_API_URL}/Equipment/getRecordByPage`, {
params: {
pageNum: 1,
pageSize: 1000,
},
});
if (response.status === 200) {
setEquipmentList(response.data.records || []);
}
} catch (error) {
console.error("Error fetching equipment list:", error);
setEquipmentList([]);
} finally {
setLoadingEquipments(false);
}
}, []);

const handleAddClick = useCallback(() => {
setAddDialogOpen(true);
fetchEquipmentList();
}, [fetchEquipmentList]);

const handleAddDialogClose = useCallback(() => {
setAddDialogOpen(false);
setSelectedDescription("");
setSelectedName("");
setSelectedEquipmentCode("");
setEquipmentCodePrefix("");
setEquipmentCodeNumber("");
setIsExistingCombination(false);
}, []);

const availableDescriptions = useMemo(() => {
const descriptions = equipmentList
.map((eq) => eq.description)
.filter((desc): desc is string => Boolean(desc));
return Array.from(new Set(descriptions));
}, [equipmentList]);

const availableNames = useMemo(() => {
const names = equipmentList
.map((eq) => eq.name)
.filter((name): name is string => Boolean(name));
return Array.from(new Set(names));
}, [equipmentList]);

useEffect(() => {
const checkAndGenerateEquipmentCode = async () => {
if (!selectedDescription || !selectedName) {
setIsExistingCombination(false);
setSelectedEquipmentCode("");
return;
}

const equipmentCode = `${selectedDescription}-${selectedName}`;
const existingEquipment = equipmentList.find((eq) => eq.code === equipmentCode);
if (existingEquipment) {
setIsExistingCombination(true);
try {
const existingDetailsResponse = await axiosInstance.get<{
records: EquipmentResult[];
total: number;
}>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/byDescriptionIncludingDeleted/${encodeURIComponent(equipmentCode)}`);

let newEquipmentCode = "";
if (existingDetailsResponse.data.records && existingDetailsResponse.data.records.length > 0) {
const equipmentCodePatterns = existingDetailsResponse.data.records
.map((detail) => {
if (!detail.equipmentCode) return null;
const match = detail.equipmentCode.match(/^([A-Za-z]+)(\d+)$/);
if (match) {
const originalNumber = match[2];
return {
prefix: match[1],
number: parseInt(match[2], 10),
paddingLength: originalNumber.length
};
}
return null;
})
.filter((pattern): pattern is { prefix: string; number: number; paddingLength: number } => pattern !== null);

if (equipmentCodePatterns.length > 0) {
const prefix = equipmentCodePatterns[0].prefix;
const maxEquipmentCodeNumber = Math.max(...equipmentCodePatterns.map(p => p.number));
const maxPaddingLength = Math.max(...equipmentCodePatterns.map(p => p.paddingLength));
const nextNumber = maxEquipmentCodeNumber + 1;
newEquipmentCode = `${prefix}${String(nextNumber).padStart(maxPaddingLength, '0')}`;
} else {
newEquipmentCode = `LSS${String(1).padStart(2, '0')}`;
}
} else {
newEquipmentCode = `LSS${String(1).padStart(2, '0')}`;
}
setSelectedEquipmentCode(newEquipmentCode);
} catch (error) {
console.error("Error checking existing equipment details:", error);
setIsExistingCombination(false);
setSelectedEquipmentCode("");
}
} else {
setIsExistingCombination(false);
setSelectedEquipmentCode("");
setEquipmentCodePrefix("");
setEquipmentCodeNumber("");
}
};

checkAndGenerateEquipmentCode();
}, [selectedDescription, selectedName, equipmentList]);

useEffect(() => {
const generateNumberForPrefix = async () => {
if (isExistingCombination || !equipmentCodePrefix) {
return;
}

if (equipmentCodePrefix.length !== 3 || !/^[A-Z]{3}$/.test(equipmentCodePrefix)) {
setEquipmentCodeNumber("");
setSelectedEquipmentCode(equipmentCodePrefix);
return;
}

try {
const response = await axiosInstance.get<{
records: EquipmentResult[];
total: number;
}>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/getRecordByPage`, {
params: {
pageNum: 1,
pageSize: 1000,
},
});

let maxNumber = 0;
let maxPaddingLength = 2;

if (response.data.records && response.data.records.length > 0) {
const matchingCodes = response.data.records
.map((detail) => {
if (!detail.equipmentCode) return null;
const match = detail.equipmentCode.match(new RegExp(`^${equipmentCodePrefix}(\\d+)$`));
if (match) {
const numberStr = match[1];
return {
number: parseInt(numberStr, 10),
paddingLength: numberStr.length
};
}
return null;
})
.filter((item): item is { number: number; paddingLength: number } => item !== null);

if (matchingCodes.length > 0) {
maxNumber = Math.max(...matchingCodes.map(c => c.number));
maxPaddingLength = Math.max(...matchingCodes.map(c => c.paddingLength));
}
}

const nextNumber = maxNumber + 1;
const numberStr = String(nextNumber).padStart(maxPaddingLength, '0');
setEquipmentCodeNumber(numberStr);
setSelectedEquipmentCode(`${equipmentCodePrefix}${numberStr}`);
} catch (error) {
console.error("Error generating equipment code number:", error);
setEquipmentCodeNumber("");
setSelectedEquipmentCode(equipmentCodePrefix);
}
};

generateNumberForPrefix();
}, [equipmentCodePrefix, isExistingCombination]);

const handleToggleExpand = useCallback(
(id: string | number, code: string) => {
setExpandedRows(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
if (!equipmentDetailsMap.has(id)) {
fetchEquipmentDetailsByEquipmentId(id);
}
}
return newSet;
});
},
[equipmentDetailsMap, fetchEquipmentDetailsByEquipmentId]
);

const generalDataColumns = useMemo<Column<EquipmentResult>[]>(
() => [
{
name: "id",
label: t("Details"),
onClick: onDetailClick,
buttonIcon: <EditNote />,
},
{
name: "code",
label: t("Code"),
},
{
name: "description",
label: t("Description"),
},
{
name: "equipmentTypeId",
label: t("Equipment Type"),
},
{
name: "action",
label: t(""),
buttonIcon: <GridDeleteIcon />,
onClick: onDeleteClick,
label: "設備編號",
renderCell: (item) => (
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleToggleExpand(item.id, item.code);
}}
sx={{ padding: 0.5 }}
>
{expandedRows.has(item.id) ? (
<KeyboardArrowUpIcon fontSize="small" />
) : (
<KeyboardArrowDownIcon fontSize="small" />
)}
</IconButton>
<Typography>{item.code}</Typography>
</Box>
),
},
],
[onDetailClick, onDeleteClick, t],
[t, handleToggleExpand, expandedRows],
);

const repairMaintenanceColumns = useMemo<Column<EquipmentResult>[]>(
@@ -250,8 +541,6 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
const transformedFilter: any = { ...filterObj };
// For maintenance tab (tabIndex === 1), if equipmentCode is provided,
// also search by code (equipment name) with the same value
if (tabIndex === 1 && transformedFilter.equipmentCode) {
transformedFilter.code = transformedFilter.equipmentCode;
}
@@ -308,24 +597,263 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
}, [filterObj, pagingController.pageNum, pagingController.pageSize, tabIndex, isReady, refetchData]);

const onReset = useCallback(() => {
setFilterObj({});
setPagingController({
pageNum: 1,
pageSize: pagingController.pageSize,
});
}, [pagingController.pageSize]);
setFilterObjByTab(prev => ({
...prev,
[tabIndex]: {},
}));
setPagingControllerByTab(prev => ({
...prev,
[tabIndex]: {
pageNum: 1,
pageSize: prev[tabIndex]?.pageSize || 10,
},
}));
}, [tabIndex]);

const handleSaveEquipmentDetail = useCallback(async () => {
if (!selectedName || !selectedDescription) {
return;
}

if (!isExistingCombination) {
if (equipmentCodePrefix.length !== 3 || !/^[A-Z]{3}$/.test(equipmentCodePrefix)) {
alert("請輸入3個大寫英文字母作為設備編號前綴");
return;
}
if (!equipmentCodeNumber) {
alert("設備編號生成中,請稍候");
return;
}
}
setSaving(true);
try {
const equipmentCode = `${selectedDescription}-${selectedName}`;
let equipment = equipmentList.find((eq) => eq.code === equipmentCode);
let equipmentId: string | number;
if (!equipment) {
const equipmentResponse = await axiosInstance.post<EquipmentResult>(
`${NEXT_PUBLIC_API_URL}/Equipment/save`,
{
code: equipmentCode,
name: selectedName,
description: selectedDescription,
id: null,
}
);
equipment = equipmentResponse.data;
equipmentId = equipment.id;
} else {
equipmentId = equipment.id;
}
const existingDetailsResponse = await axiosInstance.get<{
records: EquipmentResult[];
total: number;
}>(`${NEXT_PUBLIC_API_URL}/EquipmentDetail/byDescriptionIncludingDeleted/${encodeURIComponent(equipmentCode)}`);

let newName = "1號";
let newEquipmentCode = "";
if (existingDetailsResponse.data.records && existingDetailsResponse.data.records.length > 0) {
const numbers = existingDetailsResponse.data.records
.map((detail) => {
const match = detail.name?.match(/(\d+)號/);
return match ? parseInt(match[1], 10) : 0;
})
.filter((num) => num > 0);
if (numbers.length > 0) {
const maxNumber = Math.max(...numbers);
newName = `${maxNumber + 1}號`;
}

if (isExistingCombination) {
const equipmentCodePatterns = existingDetailsResponse.data.records
.map((detail) => {
if (!detail.equipmentCode) return null;
const match = detail.equipmentCode.match(/^([A-Za-z]+)(\d+)$/);
if (match) {
const originalNumber = match[2];
return {
prefix: match[1],
number: parseInt(match[2], 10),
paddingLength: originalNumber.length
};
}
return null;
})
.filter((pattern): pattern is { prefix: string; number: number; paddingLength: number } => pattern !== null);

if (equipmentCodePatterns.length > 0) {
const prefix = equipmentCodePatterns[0].prefix;
const maxEquipmentCodeNumber = Math.max(...equipmentCodePatterns.map(p => p.number));
const maxPaddingLength = Math.max(...equipmentCodePatterns.map(p => p.paddingLength));
const nextNumber = maxEquipmentCodeNumber + 1;
newEquipmentCode = `${prefix}${String(nextNumber).padStart(maxPaddingLength, '0')}`;
} else {
newEquipmentCode = `LSS${String(1).padStart(2, '0')}`;
}
} else {
if (isExistingCombination) {
newEquipmentCode = selectedEquipmentCode;
} else {
newEquipmentCode = `${equipmentCodePrefix}${equipmentCodeNumber}`;
}
}
} else {
if (isExistingCombination) {
newEquipmentCode = `LSS${String(1).padStart(2, '0')}`;
} else {
newEquipmentCode = `${equipmentCodePrefix}${equipmentCodeNumber}`;
}
}

const detailCode = `${equipmentCode}-${newName}`;
await axiosInstance.post<EquipmentResult>(
`${NEXT_PUBLIC_API_URL}/EquipmentDetail/save`,
{
code: detailCode,
name: newName,
description: equipmentCode,
equipmentCode: newEquipmentCode,
id: null,
equipmentTypeId: equipmentId,
repairAndMaintenanceStatus: false,
}
);
handleAddDialogClose();
if (tabIndex === 0) {
await refetchData(filterObj);
if (equipmentDetailsMap.has(equipmentId)) {
await fetchEquipmentDetailsByEquipmentId(equipmentId);
}
}
alert("新增成功");
} catch (error: any) {
console.error("Error saving equipment detail:", error);
const errorMessage = error.response?.data?.message || error.message || "保存失敗,請稍後再試";
alert(errorMessage);
} finally {
setSaving(false);
}
}, [selectedName, selectedDescription, selectedEquipmentCode, equipmentCodePrefix, equipmentCodeNumber, isExistingCombination, equipmentList, refetchData, filterObj, handleAddDialogClose, tabIndex, equipmentDetailsMap, fetchEquipmentDetailsByEquipmentId]);

const renderExpandedRow = useCallback((item: EquipmentResult): React.ReactNode => {
if (tabIndex !== 0) {
return null;
}
const details = equipmentDetailsMap.get(item.id) || [];
const isLoading = loadingDetailsMap.get(item.id) || false;

return (
<TableRow key={`expanded-${item.id}`}>
<TableCell colSpan={columns.length} sx={{ py: 0, border: 0 }}>
<Collapse in={expandedRows.has(item.id)} timeout="auto" unmountOnExit>
<Box sx={{ margin: 2 }}>
{isLoading ? (
<Box sx={{ display: "flex", alignItems: "center", gap: 2, p: 2 }}>
<CircularProgress size={20} />
<Typography>載入中...</Typography>
</Box>
) : details.length === 0 ? (
<Typography sx={{ p: 2 }}>無相關設備詳細資料</Typography>
) : (
<Box>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: "bold" }}>
設備詳細資料 (設備編號: {item.code})
</Typography>
<Grid container spacing={2}>
{details.map((detail) => (
<Grid item xs={6} key={detail.id}>
<Box
sx={{
p: 2,
border: "1px solid",
borderColor: "divider",
borderRadius: 1,
height: "100%",
position: "relative",
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
編號: {detail.code || "-"}
</Typography>
<IconButton
size="small"
color="error"
onClick={() => handleDeleteClick(detail.id, item.id)}
sx={{ ml: 1 }}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
{detail.name && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
名稱: {detail.name}
</Typography>
)}
{detail.description && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
描述: {detail.description}
</Typography>
)}
{detail.equipmentCode && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
設備編號: {detail.equipmentCode}
</Typography>
)}
</Box>
</Grid>
))}
</Grid>
</Box>
)}
</Box>
</Collapse>
</TableCell>
</TableRow>
);
}, [columns.length, equipmentDetailsMap, loadingDetailsMap, expandedRows, tabIndex, handleDeleteClick]);

return (
<>
<SearchBox
criteria={searchCriteria}
onSearch={(query) => {
setFilterObj({
...query,
setFilterObjByTab(prev => {
const newState = { ...prev };
newState[tabIndex] = query as unknown as SearchQuery;
return newState;
});
}}
onReset={onReset}
/>
{tabIndex === 0 && (
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 2 }}>
<Typography variant="h6" component="h2">
設備編號
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleAddClick}
color="primary"
>
新增
</Button>
</Box>
)}
<Box sx={{
"& .MuiTableContainer-root": {
overflowY: "auto",
@@ -337,14 +865,175 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
<EquipmentSearchResults<EquipmentResult>
items={filteredEquipments}
columns={columns}
setPagingController={setPagingController}
setPagingController={(newController) => {
setPagingControllerByTab(prev => {
const newState = { ...prev };
newState[tabIndex] = typeof newController === 'function'
? newController(prev[tabIndex] || { pageNum: 1, pageSize: 10 })
: newController;
return newState;
});
}}
pagingController={pagingController}
totalCount={totalCount}
isAutoPaging={false}
/>
</Box>
</>
);
};
renderExpandedRow={renderExpandedRow}
hideHeader={tabIndex === 0}
/>
</Box>
{/* Delete Confirmation Dialog */}
{deleteDialogOpen && (
<Dialog
open={deleteDialogOpen}
onClose={handleDeleteCancel}
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-description"
>
<DialogTitle id="delete-dialog-title">
確認刪除
</DialogTitle>
<DialogContent>
<DialogContentText id="delete-dialog-description">
您確定要刪除此設備詳細資料嗎?此操作無法復原。
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteCancel} disabled={deleting}>
取消
</Button>
<Button onClick={handleDeleteConfirm} color="error" disabled={deleting} autoFocus>
{deleting ? "刪除中..." : "刪除"}
</Button>
</DialogActions>
</Dialog>
)}

export default EquipmentSearch;
{/* Add Equipment Detail Dialog */}
<Dialog
open={addDialogOpen}
onClose={handleAddDialogClose}
aria-labelledby="add-dialog-title"
maxWidth="sm"
fullWidth
>
<DialogTitle id="add-dialog-title">
新增設備詳細資料
</DialogTitle>
<DialogContent>
<Box sx={{ pt: 2 }}>
<Autocomplete
freeSolo
options={availableDescriptions}
value={selectedDescription || null}
onChange={(event, newValue) => {
setSelectedDescription(newValue || '');
}}
onInputChange={(event, newInputValue) => {
setSelectedDescription(newInputValue);
}}
loading={loadingEquipments}
disabled={loadingEquipments || saving}
renderInput={(params) => (
<TextField
{...params}
label="種類"
placeholder="選擇或輸入種類"
/>
)}
sx={{ mb: 2 }}
/>
<Autocomplete
freeSolo
options={availableNames}
value={selectedName || null}
onChange={(event, newValue) => {
setSelectedName(newValue || '');
}}
onInputChange={(event, newInputValue) => {
setSelectedName(newInputValue);
}}
loading={loadingEquipments}
disabled={loadingEquipments || saving}
componentsProps={{
popper: {
placement: 'bottom-start',
modifiers: [
{
name: 'flip',
enabled: false,
},
{
name: 'preventOverflow',
enabled: true,
},
],
},
}}
renderInput={(params) => (
<TextField
{...params}
label="名稱"
placeholder="選擇或輸入名稱"
/>
)}
/>
<Box sx={{ mt: 2 }}>
<TextField
fullWidth
label="設備編號"
value={isExistingCombination ? selectedEquipmentCode : equipmentCodePrefix}
onChange={(e) => {
if (!isExistingCombination) {
const input = e.target.value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 3);
setEquipmentCodePrefix(input);
}
}}
disabled={isExistingCombination || loadingEquipments || saving}
placeholder={isExistingCombination ? "自動生成" : "輸入3個大寫英文字母"}
required={!isExistingCombination}
InputProps={{
endAdornment: !isExistingCombination && equipmentCodeNumber ? (
<InputAdornment position="end">
<Typography
sx={{
color: 'text.secondary',
fontSize: '1rem',
fontWeight: 500,
minWidth: '30px',
textAlign: 'right',
}}
>
{equipmentCodeNumber}
</Typography>
</InputAdornment>
) : null,
}}
helperText={!isExistingCombination && equipmentCodePrefix.length > 0 && equipmentCodePrefix.length !== 3
? "必須輸入3個大寫英文字母"
: !isExistingCombination && equipmentCodePrefix.length === 3 && !/^[A-Z]{3}$/.test(equipmentCodePrefix)
? "必須是大寫英文字母"
: ""}
error={!isExistingCombination && equipmentCodePrefix.length > 0 && (equipmentCodePrefix.length !== 3 || !/^[A-Z]{3}$/.test(equipmentCodePrefix))}
/>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleAddDialogClose} disabled={saving}>
取消
</Button>
<Button
onClick={handleSaveEquipmentDetail}
variant="contained"
disabled={!selectedName || !selectedDescription || (!isExistingCombination && !selectedEquipmentCode) || loadingEquipments || saving}
>
{saving ? "保存中..." : "新增"}
</Button>
</DialogActions>
</Dialog>
</>
);
};
export default EquipmentSearch;

+ 2
- 0
src/components/EquipmentSearch/EquipmentSearchLoading.tsx Zobrazit soubor

@@ -1,3 +1,5 @@
"use client";

import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Skeleton from "@mui/material/Skeleton";


+ 81
- 65
src/components/EquipmentSearch/EquipmentSearchResults.tsx Zobrazit soubor

@@ -48,6 +48,7 @@ interface BaseColumn<T extends ResultWithId> {
style?: Partial<HTMLElement["style"]> & { [propName: string]: string };
type?: ColumnType;
renderCell?: (params: T) => React.ReactNode;
renderHeader?: () => React.ReactNode;
}

interface IconColumn<T extends ResultWithId> extends BaseColumn<T> {
@@ -104,6 +105,8 @@ interface Props<T extends ResultWithId> {
checkboxIds?: (string | number)[];
setCheckboxIds?: Dispatch<SetStateAction<(string | number)[]>>;
onRowClick?: (item: T) => void;
renderExpandedRow?: (item: T) => React.ReactNode;
hideHeader?: boolean;
}

function isActionColumn<T extends ResultWithId>(
@@ -197,6 +200,8 @@ function EquipmentSearchResults<T extends ResultWithId>({
checkboxIds = [],
setCheckboxIds = undefined,
onRowClick = undefined,
renderExpandedRow = undefined,
hideHeader = false,
}: Props<T>) {
const { t } = useTranslation("common");
const [page, setPage] = React.useState(0);
@@ -303,35 +308,41 @@ function EquipmentSearchResults<T extends ResultWithId>({
const table = (
<>
<TableContainer sx={{ maxHeight: 440 }}>
<Table stickyHeader>
<TableHead>
<TableRow>
{columns.map((column, idx) => (
isCheckboxColumn(column) ?
<TableCell
align={column.headerAlign}
sx={column.sx}
key={`${column.name.toString()}${idx}`}
>
<Checkbox
color="primary"
indeterminate={currItemsWithChecked.length > 0 && currItemsWithChecked.length < currItems.length}
checked={currItems.length > 0 && currItemsWithChecked.length >= currItems.length}
onChange={handleSelectAllClick}
/>
</TableCell>
: <TableCell
align={column.headerAlign}
sx={column.sx}
key={`${column.name.toString()}${idx}`}
>
{column.label.split('\n').map((line, index) => (
<div key={index}>{line}</div> // Render each line in a div
))}
</TableCell>
))}
</TableRow>
</TableHead>
<Table stickyHeader={!hideHeader}>
{!hideHeader && (
<TableHead>
<TableRow>
{columns.map((column, idx) => (
isCheckboxColumn(column) ?
<TableCell
align={column.headerAlign}
sx={column.sx}
key={`${column.name.toString()}${idx}`}
>
<Checkbox
color="primary"
indeterminate={currItemsWithChecked.length > 0 && currItemsWithChecked.length < currItems.length}
checked={currItems.length > 0 && currItemsWithChecked.length >= currItems.length}
onChange={handleSelectAllClick}
/>
</TableCell>
: <TableCell
align={column.headerAlign}
sx={column.sx}
key={`${column.name.toString()}${idx}`}
>
{column.renderHeader ? (
column.renderHeader()
) : (
column.label.split('\n').map((line, index) => (
<div key={index}>{line}</div> // Render each line in a div
))
)}
</TableCell>
))}
</TableRow>
</TableHead>
)}
<TableBody>
{isAutoPaging
? items
@@ -339,10 +350,45 @@ function EquipmentSearchResults<T extends ResultWithId>({
(pagingController?.pageNum ? pagingController?.pageNum - 1 :page) * (pagingController?.pageSize ?? rowsPerPage) + (pagingController?.pageSize ?? rowsPerPage))
.map((item) => {
return (
<TableRow
hover
tabIndex={-1}
key={item.id}
<React.Fragment key={item.id}>
<TableRow
hover
tabIndex={-1}
onClick={(event) => {
setCheckboxIds
? handleRowClick(event, item, columns)
: undefined

if (onRowClick) {
onRowClick(item)
}
}
}
role={setCheckboxIds ? "checkbox" : undefined}
>
{columns.map((column, idx) => {
const columnName = column.name;

return (
<TabelCells
key={`${columnName.toString()}-${idx}`}
column={column}
columnName={columnName}
idx={idx}
item={item}
checkboxIds={checkboxIds}
/>
);
})}
</TableRow>
{renderExpandedRow && renderExpandedRow(item)}
</React.Fragment>
);
})
: items.map((item) => {
return (
<React.Fragment key={item.id}>
<TableRow hover tabIndex={-1}
onClick={(event) => {
setCheckboxIds
? handleRowClick(event, item, columns)
@@ -370,38 +416,8 @@ function EquipmentSearchResults<T extends ResultWithId>({
);
})}
</TableRow>
);
})
: items.map((item) => {
return (
<TableRow hover tabIndex={-1} key={item.id}
onClick={(event) => {
setCheckboxIds
? handleRowClick(event, item, columns)
: undefined

if (onRowClick) {
onRowClick(item)
}
}
}
role={setCheckboxIds ? "checkbox" : undefined}
>
{columns.map((column, idx) => {
const columnName = column.name;

return (
<TabelCells
key={`${columnName.toString()}-${idx}`}
column={column}
columnName={columnName}
idx={idx}
item={item}
checkboxIds={checkboxIds}
/>
);
})}
</TableRow>
{renderExpandedRow && renderExpandedRow(item)}
</React.Fragment>
);
})}
</TableBody>


+ 57
- 43
src/components/NavigationContent/NavigationContent.tsx Zobrazit soubor

@@ -1,3 +1,4 @@
import { useSession } from "next-auth/react";
import Divider from "@mui/material/Divider";
import Box from "@mui/material/Box";
import React, { useEffect } from "react";
@@ -24,16 +25,38 @@ import { usePathname } from "next/navigation";
import Link from "next/link";
import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig";
import Logo from "../Logo";
import BugReportIcon from "@mui/icons-material/BugReport";
import {
VIEW_USER,
MAINTAIN_USER,
VIEW_GROUP,
MAINTAIN_GROUP,
// Add more authorities as needed, e.g.:
TESTING, PROD, PACK, ADMIN, STOCK, Driver
} from "../../authorities";

interface NavigationItem {
icon: React.ReactNode;
label: string;
path: string;
children?: NavigationItem[];
isHidden?: true | undefined;
isHidden?: boolean | undefined;
requiredAbility?: string | string[];
}

const NavigationContent: React.FC = () => {
const { data: session, status } = useSession();
const abilities = session?.user?.abilities ?? [];

// Helper: check if user has required permission
const hasAbility = (required?: string | string[]): boolean => {
if (!required) return true; // no requirement → always show
if (Array.isArray(required)) {
return required.some(ability => abilities.includes(ability));
}
return abilities.includes(required);
};

const navigationItems: NavigationItem[] = [
{
icon: <Dashboard />,
@@ -113,49 +136,11 @@ const NavigationContent: React.FC = () => {
},
],
},
// {
// icon: <RequestQuote />,
// label: "Production",
// path: "",
// children: [
// {
// icon: <RequestQuote />,
// label: "Job Order",
// path: "",
// },
// {
// icon: <RequestQuote />,
// label: "Job Order Traceablity ",
// path: "",
// },
// {
// icon: <RequestQuote />,
// label: "Work Order",
// path: "",
// },
// {
// icon: <RequestQuote />,
// label: "Work Order Traceablity ",
// path: "",
// },
// ],
// },
// {
// icon: <RequestQuote />,
// label: "Quality Control Log",
// path: "",
// children: [
// {
// icon: <RequestQuote />,
// label: "Quality Control Log",
// path: "",
// },
// ],
// },
{
icon: <RequestQuote />,
label: "Delivery",
path: "",
//requiredAbility: VIEW_DO,
children: [
{
icon: <RequestQuote />,
@@ -249,20 +234,37 @@ const NavigationContent: React.FC = () => {
},
],
},
{
icon: <BugReportIcon />,
label: "PS",
path: "/ps",
requiredAbility: TESTING,
isHidden: false,
},
{
icon: <BugReportIcon />,
label: "Printer Testing",
path: "/testing",
requiredAbility: TESTING,
isHidden: false,
},
{
icon: <RequestQuote />,
label: "Settings",
path: "",
requiredAbility: [VIEW_USER, VIEW_GROUP],
children: [
{
icon: <RequestQuote />,
label: "User",
path: "/settings/user",
requiredAbility: VIEW_USER,
},
{
icon: <RequestQuote />,
label: "User Group",
path: "/settings/user",
requiredAbility: VIEW_GROUP,
},
// {
// icon: <RequestQuote />,
@@ -302,7 +304,7 @@ const NavigationContent: React.FC = () => {
{
icon: <RequestQuote />,
label: "Warehouse",
path: "/settings/user",
path: "/settings/warehouse",
},
{
icon: <RequestQuote />,
@@ -365,7 +367,12 @@ const NavigationContent: React.FC = () => {
};

const renderNavigationItem = (item: NavigationItem) => {
if (!hasAbility(item.requiredAbility)) {
return null;
}

const isOpen = openItems.includes(item.label);
const hasVisibleChildren = item.children?.some(child => hasAbility(child.requiredAbility));

return (
<Box
@@ -381,7 +388,7 @@ const NavigationContent: React.FC = () => {
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={t(item.label)} />
</ListItemButton>
{item.children && isOpen && (
{item.children && isOpen && hasVisibleChildren && (
<List sx={{ pl: 2 }}>
{item.children.map(
(child) => !child.isHidden && renderNavigationItem(child),
@@ -392,6 +399,10 @@ const NavigationContent: React.FC = () => {
);
};

if (status === "loading") {
return <Box sx={{ width: NAVIGATION_CONTENT_WIDTH, p: 3 }}>Loading...</Box>;
}

return (
<Box sx={{ width: NAVIGATION_CONTENT_WIDTH }}>
<Box sx={{ p: 3, display: "flex" }}>
@@ -402,7 +413,10 @@ const NavigationContent: React.FC = () => {
</Box>
<Divider />
<List component="nav">
{navigationItems.map((item) => renderNavigationItem(item))}
{navigationItems
.filter(item => !item.isHidden)
.map(renderNavigationItem)
.filter(Boolean)}
{/* {navigationItems.map(({ icon, label, path }, index) => {
return (
<Box


+ 4
- 2
src/components/SearchBox/SearchBox.tsx Zobrazit soubor

@@ -52,6 +52,7 @@ interface OptionWithLabel<T extends string> {

interface TextCriterion<T extends string> extends BaseCriterion<T> {
type: "text";
placeholder?: string;
}

interface SelectCriterion<T extends string> extends BaseCriterion<T> {
@@ -286,6 +287,7 @@ function SearchBox<T extends string>({
<TextField
label={t(c.label)}
fullWidth
placeholder={c.placeholder}
onChange={makeInputChangeHandler(c.paramName)}
value={inputs[c.paramName]}
/>
@@ -306,7 +308,7 @@ function SearchBox<T extends string>({
<Select
label={t(c.label)}
onChange={makeSelectChangeHandler(c.paramName)}
value={inputs[c.paramName]}
value={inputs[c.paramName] ?? "All"}
>
<MenuItem value={"All"}>{t("All")}</MenuItem>
{c.options.map((option) => (
@@ -323,7 +325,7 @@ function SearchBox<T extends string>({
<Select
label={t(c.label)}
onChange={makeSelectChangeHandler(c.paramName)}
value={inputs[c.paramName]}
value={inputs[c.paramName] ?? "All"}
>
<MenuItem value={"All"}>{t("All")}</MenuItem>
{c.options.map((option) => (


+ 107
- 69
src/components/Shop/Shop.tsx Zobrazit soubor

@@ -18,7 +18,7 @@ import {
InputLabel,
} from "@mui/material";
import { useState, useMemo, useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
import SearchBox, { Criterion } from "../SearchBox";
import SearchResults, { Column } from "../SearchResults";
@@ -43,6 +43,7 @@ type SearchParamNames = keyof SearchQuery;
const Shop: React.FC = () => {
const { t } = useTranslation("common");
const router = useRouter();
const searchParams = useSearchParams();
const [activeTab, setActiveTab] = useState<number>(0);
const [rows, setRows] = useState<ShopRow[]>([]);
const [loading, setLoading] = useState<boolean>(false);
@@ -235,26 +236,33 @@ const Shop: React.FC = () => {
name: "id",
label: t("id"),
type: "integer",
sx: { width: "100px", minWidth: "100px", maxWidth: "100px" },
renderCell: (item) => String(item.id ?? ""),
},
{
name: "code",
label: t("Code"),
sx: { width: "150px", minWidth: "150px", maxWidth: "150px" },
renderCell: (item) => String(item.code ?? ""),
},
{
name: "name",
label: t("Name"),
sx: { width: "200px", minWidth: "200px", maxWidth: "200px" },
renderCell: (item) => String(item.name ?? ""),
},
{
name: "addr3",
label: t("Addr3"),
sx: { width: "200px", minWidth: "200px", maxWidth: "200px" },
renderCell: (item) => String((item as any).addr3 ?? ""),
},
{
name: "truckLanceStatus",
label: t("TruckLance Status"),
align: "center",
headerAlign: "center",
sx: { width: "150px", minWidth: "150px", maxWidth: "150px" },
renderCell: (item) => {
const status = item.truckLanceStatus;
if (status === "complete") {
@@ -269,7 +277,9 @@ const Shop: React.FC = () => {
{
name: "actions",
label: t("Actions"),
align: "right",
headerAlign: "right",
sx: { width: "150px", minWidth: "150px", maxWidth: "150px" },
renderCell: (item) => (
<Button
size="small"
@@ -282,6 +292,17 @@ const Shop: React.FC = () => {
},
];

// Initialize activeTab from URL parameter
useEffect(() => {
const tabParam = searchParams.get("tab");
if (tabParam !== null) {
const tabIndex = parseInt(tabParam, 10);
if (!isNaN(tabIndex) && (tabIndex === 0 || tabIndex === 1)) {
setActiveTab(tabIndex);
}
}
}, [searchParams]);

useEffect(() => {
if (activeTab === 0) {
fetchAllShops();
@@ -290,82 +311,99 @@ const Shop: React.FC = () => {

const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setActiveTab(newValue);
// Update URL to reflect the selected tab
const url = new URL(window.location.href);
url.searchParams.set("tab", String(newValue));
router.push(url.pathname + url.search);
};

return (
<Box>
<Card sx={{ mb: 2 }}>
<CardContent>
<Tabs
value={activeTab}
onChange={handleTabChange}
sx={{
mb: 3,
borderBottom: 1,
borderColor: 'divider'
}}
>
<Tab label={t("Shop")} />
<Tab label={t("Truck Lane")} />
</Tabs>
{/* Header section with title */}
<Box sx={{
p: 2,
borderBottom: '1px solid #e0e0e0'
}}>
<Typography variant="h4">
店鋪路線管理
</Typography>
</Box>

{/* Tabs section */}
<Box sx={{
borderBottom: '1px solid #e0e0e0'
}}>
<Tabs
value={activeTab}
onChange={handleTabChange}
>
<Tab label={t("Shop")} />
<Tab label={t("Truck Lane")} />
</Tabs>
</Box>

{activeTab === 0 && (
<SearchBox
criteria={criteria as Criterion<string>[]}
onSearch={handleSearch}
onReset={() => {
setRows([]);
setFilters({});
}}
/>
)}
</CardContent>
</Card>
{/* Content section */}
<Box sx={{ p: 2 }}>
{activeTab === 0 && (
<>
<Card sx={{ mb: 2 }}>
<CardContent>
<SearchBox
criteria={criteria as Criterion<string>[]}
onSearch={handleSearch}
onReset={() => {
setRows([]);
setFilters({});
}}
/>
</CardContent>
</Card>

{activeTab === 0 && (
<Card>
<CardContent>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h6">{t("Shop")}</Typography>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>{t("Filter by Status")}</InputLabel>
<Select
value={statusFilter}
label={t("Filter by Status")}
onChange={(e) => setStatusFilter(e.target.value)}
>
<MenuItem value="all">{t("All")}</MenuItem>
<MenuItem value="complete">{t("Complete")}</MenuItem>
<MenuItem value="missing">{t("Missing Data")}</MenuItem>
<MenuItem value="no-truck">{t("No TruckLance")}</MenuItem>
</Select>
</FormControl>
</Stack>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Card>
<CardContent>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h6">{t("Shop")}</Typography>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>{t("Filter by Status")}</InputLabel>
<Select
value={statusFilter}
label={t("Filter by Status")}
onChange={(e) => setStatusFilter(e.target.value)}
>
<MenuItem value="all">{t("All")}</MenuItem>
<MenuItem value="complete">{t("Complete")}</MenuItem>
<MenuItem value="missing">{t("Missing Data")}</MenuItem>
<MenuItem value="no-truck">{t("No TruckLance")}</MenuItem>
</Select>
</FormControl>
</Stack>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}

{loading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
) : (
<SearchResults
items={filteredRows}
columns={columns}
pagingController={pagingController}
setPagingController={setPagingController}
/>
)}
</CardContent>
</Card>
)}
{loading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
) : (
<SearchResults
items={filteredRows}
columns={columns}
pagingController={pagingController}
setPagingController={setPagingController}
/>
)}
</CardContent>
</Card>
</>
)}

{activeTab === 1 && (
<TruckLane />
)}
{activeTab === 1 && (
<TruckLane />
)}
</Box>
</Box>
);
};


+ 7
- 70
src/components/Shop/ShopDetail.tsx Zobrazit soubor

@@ -48,6 +48,7 @@ import {
createTruckClient
} from "@/app/api/shop/client";
import type { SessionWithTokens } from "@/config/authConfig";
import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil";

type ShopDetailData = {
id: number;
@@ -62,61 +63,6 @@ type ShopDetailData = {
contactName: String;
};

// Utility function to format departureTime to HH:mm format
const formatDepartureTime = (time: string | number[] | null | undefined): string => {
if (!time) return "-";
// Handle array format [hours, minutes] from API
if (Array.isArray(time) && time.length >= 2) {
const hours = time[0];
const minutes = time[1];
if (typeof hours === 'number' && typeof minutes === 'number' &&
hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
}
}
const timeStr = String(time).trim();
if (!timeStr || timeStr === "-") return "-";
// If already in HH:mm format, return as is
if (/^\d{1,2}:\d{2}$/.test(timeStr)) {
const [hours, minutes] = timeStr.split(":");
return `${hours.padStart(2, "0")}:${minutes.padStart(2, "0")}`;
}
// Handle decimal format (e.g., "17,0" or "17.0" representing hours)
const decimalMatch = timeStr.match(/^(\d+)[,.](\d+)$/);
if (decimalMatch) {
const hours = parseInt(decimalMatch[1], 10);
const minutes = Math.round(parseFloat(`0.${decimalMatch[2]}`) * 60);
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
}
// Handle single number as hours (e.g., "17" -> "17:00")
const hoursOnly = parseInt(timeStr, 10);
if (!isNaN(hoursOnly) && hoursOnly >= 0 && hoursOnly <= 23) {
return `${hoursOnly.toString().padStart(2, "0")}:00`;
}
// Try to parse as ISO time string or other formats
try {
// If it's already a valid time string, try to extract hours and minutes
const parts = timeStr.split(/[:,\s]/);
if (parts.length >= 2) {
const h = parseInt(parts[0], 10);
const m = parseInt(parts[1], 10);
if (!isNaN(h) && !isNaN(m) && h >= 0 && h <= 23 && m >= 0 && m <= 59) {
return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}`;
}
}
} catch (e) {
// If parsing fails, return original string
}
return timeStr;
};

// Utility function to convert HH:mm format to the format expected by backend
const parseDepartureTimeForBackend = (time: string): string => {
if (!time) return "";
@@ -299,7 +245,7 @@ const ShopDetail: React.FC = () => {
}
// Convert storeId to string format (2F or 4F)
const storeIdStr = truck.storeId ? (typeof truck.storeId === 'string' ? truck.storeId : String(truck.storeId) === "2" ? "2F" : String(truck.storeId) === "4" ? "4F" : String(truck.storeId)) : "2F";
const storeIdStr = normalizeStoreId(truck.storeId) || "2F";
// Get remark value - use the remark from editedTruckData (user input)
// Only send remark if storeId is "4F", otherwise send null
@@ -482,7 +428,7 @@ const ShopDetail: React.FC = () => {
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
<Button onClick={() => router.back()}>{t("Back")}</Button>
<Button onClick={() => router.push("/settings/shop?tab=0")}>{t("Back")}</Button>
</Box>
);
}
@@ -493,7 +439,7 @@ const ShopDetail: React.FC = () => {
<Alert severity="warning" sx={{ mb: 2 }}>
{t("Shop not found")}
</Alert>
<Button onClick={() => router.back()}>{t("Back")}</Button>
<Button onClick={() => router.push("/settings/shop?tab=0")}>{t("Back")}</Button>
</Box>
);
}
@@ -504,7 +450,7 @@ const ShopDetail: React.FC = () => {
<CardContent>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
<Typography variant="h6">{t("Shop Information")}</Typography>
<Button onClick={() => router.back()}>{t("Back")}</Button>
<Button onClick={() => router.push("/settings/shop?tab=0")}>{t("Back")}</Button>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
@@ -682,22 +628,13 @@ const ShopDetail: React.FC = () => {
</Select>
</FormControl>
) : (
(() => {
const storeId = truck.storeId;
if (storeId === null || storeId === undefined) return "-";
const storeIdStr = typeof storeId === 'string' ? storeId : String(storeId);
// Convert numeric values to display format
if (storeIdStr === "2" || storeIdStr === "2F") return "2F";
if (storeIdStr === "4" || storeIdStr === "4F") return "4F";
return storeIdStr;
})()
normalizeStoreId(truck.storeId)
)}
</TableCell>
<TableCell>
{isEditing ? (
(() => {
const storeId = displayTruck?.storeId;
const storeIdStr = storeId ? (typeof storeId === 'string' ? storeId : String(storeId) === "2" ? "2F" : String(storeId) === "4" ? "4F" : String(storeId)) : "2F";
const storeIdStr = normalizeStoreId(displayTruck?.storeId) || "2F";
const isEditable = storeIdStr === "4F";
return (


+ 264
- 89
src/components/Shop/TruckLane.tsx Zobrazit soubor

@@ -16,39 +16,27 @@ import {
Button,
CircularProgress,
Alert,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
Snackbar,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import SaveIcon from "@mui/icons-material/Save";
import { useState, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { findAllUniqueTruckLaneCombinationsClient } from "@/app/api/shop/client";
import { findAllUniqueTruckLaneCombinationsClient, createTruckWithoutShopClient } from "@/app/api/shop/client";
import type { Truck } from "@/app/api/shop/actions";
import SearchBox, { Criterion } from "../SearchBox";

// Utility function to format departureTime to HH:mm format
const formatDepartureTime = (time: string | number[] | null | undefined): string => {
if (!time) return "-";
// Handle array format [hours, minutes] from API
if (Array.isArray(time) && time.length >= 2) {
const hours = time[0];
const minutes = time[1];
if (typeof hours === 'number' && typeof minutes === 'number' &&
hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
}
}
const timeStr = String(time).trim();
if (!timeStr || timeStr === "-") return "-";
// If already in HH:mm format, return as is
if (/^\d{1,2}:\d{2}$/.test(timeStr)) {
const [hours, minutes] = timeStr.split(":");
return `${hours.padStart(2, "0")}:${minutes.padStart(2, "0")}`;
}
return timeStr;
};
import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil";

type SearchQuery = {
truckLanceCode: string;
@@ -67,6 +55,15 @@ const TruckLane: React.FC = () => {
const [filters, setFilters] = useState<Record<string, string>>({});
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [addDialogOpen, setAddDialogOpen] = useState<boolean>(false);
const [newTruck, setNewTruck] = useState({
truckLanceCode: "",
departureTime: "",
storeId: "2F",
});
const [saving, setSaving] = useState<boolean>(false);
const [snackbarOpen, setSnackbarOpen] = useState<boolean>(false);
const [snackbarMessage, setSnackbarMessage] = useState<string>("");

useEffect(() => {
const fetchTruckLanes = async () => {
@@ -92,39 +89,34 @@ const TruckLane: React.FC = () => {
};

fetchTruckLanes();
}, []);
}, [t]);

// Client-side filtered rows (contains-matching)
const filteredRows = useMemo(() => {
const fKeys = Object.keys(filters || {}).filter((k) => String((filters as any)[k]).trim() !== "");
const normalized = (truckData || []).filter((r) => {
// Apply contains matching for each active filter
for (const k of fKeys) {
const v = String((filters as any)[k] ?? "").trim();
const fKeys = Object.keys(filters).filter((k) => String(filters[k] ?? "").trim() !== "");
if (fKeys.length === 0) return truckData;
return truckData.filter((truck) => {
for (const key of fKeys) {
const filterValue = String(filters[key] ?? "").trim().toLowerCase();
if (k === "truckLanceCode") {
const rv = String((r as any).truckLanceCode ?? "").trim();
if (!rv.toLowerCase().includes(v.toLowerCase())) return false;
} else if (k === "departureTime") {
if (key === "truckLanceCode") {
const truckCode = String(truck.truckLanceCode ?? "").trim().toLowerCase();
if (!truckCode.includes(filterValue)) return false;
} else if (key === "departureTime") {
const formattedTime = formatDepartureTime(
Array.isArray(r.departureTime)
? r.departureTime
: (r.departureTime ? String(r.departureTime) : null)
Array.isArray(truck.departureTime)
? truck.departureTime
: (truck.departureTime ? String(truck.departureTime) : null)
);
if (!formattedTime.toLowerCase().includes(v.toLowerCase())) return false;
} else if (k === "storeId") {
const rv = String((r as any).storeId ?? "").trim();
const storeIdStr = typeof rv === 'string' ? rv : String(rv);
// Convert numeric values to display format for comparison
let displayStoreId = storeIdStr;
if (storeIdStr === "2" || storeIdStr === "2F") displayStoreId = "2F";
if (storeIdStr === "4" || storeIdStr === "4F") displayStoreId = "4F";
if (!displayStoreId.toLowerCase().includes(v.toLowerCase())) return false;
if (!formattedTime.toLowerCase().includes(filterValue)) return false;
} else if (key === "storeId") {
const displayStoreId = normalizeStoreId(truck.storeId);
if (!displayStoreId.toLowerCase().includes(filterValue)) return false;
}
}
return true;
});
return normalized;
}, [truckData, filters]);

// Paginated rows
@@ -158,6 +150,89 @@ const TruckLane: React.FC = () => {
}
};

const handleOpenAddDialog = () => {
setNewTruck({
truckLanceCode: "",
departureTime: "",
storeId: "2F",
});
setAddDialogOpen(true);
setError(null);
};

const handleCloseAddDialog = () => {
setAddDialogOpen(false);
setNewTruck({
truckLanceCode: "",
departureTime: "",
storeId: "2F",
});
};

const handleCreateTruck = async () => {
// Validate all required fields
const missingFields: string[] = [];

if (!newTruck.truckLanceCode.trim()) {
missingFields.push(t("TruckLance Code"));
}

if (!newTruck.departureTime) {
missingFields.push(t("Departure Time"));
}

if (missingFields.length > 0) {
const message = `${t("Please fill in the following required fields:")} ${missingFields.join(", ")}`;
setSnackbarMessage(message);
setSnackbarOpen(true);
return;
}

// Check if truckLanceCode already exists
const trimmedCode = newTruck.truckLanceCode.trim();
const existingTruck = truckData.find(
(truck) => String(truck.truckLanceCode || "").trim().toLowerCase() === trimmedCode.toLowerCase()
);

if (existingTruck) {
setSnackbarMessage(t("Truck lane code already exists. Please use a different code."));
setSnackbarOpen(true);
return;
}

setSaving(true);
setError(null);
try {
await createTruckWithoutShopClient({
store_id: newTruck.storeId,
truckLanceCode: newTruck.truckLanceCode.trim(),
departureTime: newTruck.departureTime.trim(),
loadingSequence: 0,
districtReference: null,
remark: null,
});
// Refresh truck data after create
const data = await findAllUniqueTruckLaneCombinationsClient() as Truck[];
const uniqueCodes = new Map<string, Truck>();
data.forEach((truck) => {
const code = String(truck.truckLanceCode ?? "").trim();
if (code && !uniqueCodes.has(code)) {
uniqueCodes.set(code, truck);
}
});
setTruckData(Array.from(uniqueCodes.values()));
handleCloseAddDialog();
} catch (err: unknown) {
console.error("Failed to create truck:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
setError(errorMessage || t("Failed to create truck"));
} finally {
setSaving(false);
}
};

if (loading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
@@ -198,16 +273,34 @@ const TruckLane: React.FC = () => {

<Card>
<CardContent>
<Typography variant="h6" sx={{ mb: 2 }}>{t("Truck Lane")}</Typography>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
<Typography variant="h6">{t("Truck Lane")}</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleOpenAddDialog}
disabled={saving}
>
{t("Add Truck Lane")}
</Button>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("TruckLance Code")}</TableCell>
<TableCell>{t("Departure Time")}</TableCell>
<TableCell>{t("Store ID")}</TableCell>
<TableCell align="right">{t("Actions")}</TableCell>
<TableCell sx={{ width: "250px", minWidth: "250px", maxWidth: "250px" }}>
{t("TruckLance Code")}
</TableCell>
<TableCell sx={{ width: "200px", minWidth: "200px", maxWidth: "200px" }}>
{t("Departure Time")}
</TableCell>
<TableCell sx={{ width: "150px", minWidth: "150px", maxWidth: "150px" }}>
{t("Store ID")}
</TableCell>
<TableCell align="right" sx={{ width: "150px", minWidth: "150px", maxWidth: "150px" }}>
{t("Actions")}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -220,40 +313,36 @@ const TruckLane: React.FC = () => {
</TableCell>
</TableRow>
) : (
paginatedRows.map((truck, index) => {
const storeId = truck.storeId;
const storeIdStr = storeId ? (typeof storeId === 'string' ? storeId : String(storeId)) : "-";
const displayStoreId = storeIdStr === "2" || storeIdStr === "2F" ? "2F"
: storeIdStr === "4" || storeIdStr === "4F" ? "4F"
: storeIdStr;
return (
<TableRow key={truck.id ?? `truck-${index}`}>
<TableCell>
{String(truck.truckLanceCode || "-")}
</TableCell>
<TableCell>
{formatDepartureTime(
Array.isArray(truck.departureTime)
? truck.departureTime
: (truck.departureTime ? String(truck.departureTime) : null)
)}
</TableCell>
<TableCell>
{displayStoreId}
</TableCell>
<TableCell align="right">
<Button
size="small"
variant="outlined"
onClick={() => handleViewDetail(truck)}
>
{t("View Detail")}
</Button>
</TableCell>
</TableRow>
);
})
paginatedRows.map((truck) => (
<TableRow key={truck.id ?? `truck-${truck.truckLanceCode}`}>
<TableCell sx={{ width: "250px", minWidth: "250px", maxWidth: "250px" }}>
{String(truck.truckLanceCode ?? "-")}
</TableCell>
<TableCell sx={{ width: "200px", minWidth: "200px", maxWidth: "200px" }}>
{formatDepartureTime(
Array.isArray(truck.departureTime)
? truck.departureTime
: (truck.departureTime ? String(truck.departureTime) : null)
)}
</TableCell>
<TableCell sx={{ width: "150px", minWidth: "150px", maxWidth: "150px" }}>
{normalizeStoreId(
truck.storeId ? (typeof truck.storeId === 'string' || truck.storeId instanceof String
? String(truck.storeId)
: String(truck.storeId)) : null
)}
</TableCell>
<TableCell align="right" sx={{ width: "150px", minWidth: "150px", maxWidth: "150px" }}>
<Button
size="small"
variant="outlined"
onClick={() => handleViewDetail(truck)}
>
{t("View Detail")}
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
@@ -269,6 +358,92 @@ const TruckLane: React.FC = () => {
</TableContainer>
</CardContent>
</Card>

{/* Add Truck Dialog */}
<Dialog open={addDialogOpen} onClose={handleCloseAddDialog} maxWidth="sm" fullWidth>
<DialogTitle>{t("Add New Truck Lane")}</DialogTitle>
<DialogContent>
<Box sx={{ pt: 2 }}>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
label={t("TruckLance Code")}
fullWidth
required
value={newTruck.truckLanceCode}
onChange={(e) => setNewTruck({ ...newTruck, truckLanceCode: e.target.value })}
disabled={saving}
/>
</Grid>
<Grid item xs={12}>
<TextField
label={t("Departure Time")}
type="time"
fullWidth
required
value={newTruck.departureTime}
onChange={(e) => setNewTruck({ ...newTruck, departureTime: e.target.value })}
disabled={saving}
InputLabelProps={{
shrink: true,
}}
inputProps={{
step: 300, // 5 minutes
}}
/>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth>
<InputLabel>{t("Store ID")}</InputLabel>
<Select
value={newTruck.storeId}
label={t("Store ID")}
onChange={(e) => {
setNewTruck({
...newTruck,
storeId: e.target.value
});
}}
disabled={saving}
>
<MenuItem value="2F">2F</MenuItem>
<MenuItem value="4F">4F</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseAddDialog} disabled={saving}>
{t("Cancel")}
</Button>
<Button
onClick={handleCreateTruck}
variant="contained"
startIcon={<SaveIcon />}
disabled={saving}
>
{saving ? t("Submitting...") : t("Save")}
</Button>
</DialogActions>
</Dialog>

{/* Snackbar for notifications */}
<Snackbar
open={snackbarOpen}
autoHideDuration={6000}
onClose={() => setSnackbarOpen(false)}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
>
<Alert
onClose={() => setSnackbarOpen(false)}
severity="warning"
sx={{ width: '100%' }}
>
{snackbarMessage}
</Alert>
</Snackbar>
</Box>
);
};


+ 618
- 53
src/components/Shop/TruckLaneDetail.tsx Zobrazit soubor

@@ -19,42 +19,35 @@ import {
IconButton,
Snackbar,
TextField,
Autocomplete,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit";
import SaveIcon from "@mui/icons-material/Save";
import CancelIcon from "@mui/icons-material/Cancel";
import AddIcon from "@mui/icons-material/Add";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
import { findAllUniqueTruckLaneCombinationsClient, findAllShopsByTruckLanceCodeClient, deleteTruckLaneClient, updateLoadingSequenceClient } from "@/app/api/shop/client";
import type { Truck, ShopAndTruck } from "@/app/api/shop/actions";

// Utility function to format departureTime to HH:mm format
const formatDepartureTime = (time: string | number[] | null | undefined): string => {
if (!time) return "-";
// Handle array format [hours, minutes] from API
if (Array.isArray(time) && time.length >= 2) {
const hours = time[0];
const minutes = time[1];
if (typeof hours === 'number' && typeof minutes === 'number' &&
hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
}
}
const timeStr = String(time).trim();
if (!timeStr || timeStr === "-") return "-";
// If already in HH:mm format, return as is
if (/^\d{1,2}:\d{2}$/.test(timeStr)) {
const [hours, minutes] = timeStr.split(":");
return `${hours.padStart(2, "0")}:${minutes.padStart(2, "0")}`;
}
return timeStr;
};
import {
findAllUniqueTruckLaneCombinationsClient,
findAllShopsByTruckLanceCodeClient,
deleteTruckLaneClient,
updateTruckShopDetailsClient,
fetchAllShopsClient,
findAllUniqueShopNamesAndCodesFromTrucksClient,
findAllUniqueRemarksFromTrucksClient,
findAllUniqueShopCodesFromTrucksClient,
findAllUniqueShopNamesFromTrucksClient,
createTruckClient,
findAllByTruckLanceCodeAndDeletedFalseClient,
} from "@/app/api/shop/client";
import type { Truck, ShopAndTruck, Shop } from "@/app/api/shop/actions";
import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil";

const TruckLaneDetail: React.FC = () => {
const { t } = useTranslation("common");
@@ -72,12 +65,54 @@ const TruckLaneDetail: React.FC = () => {
const [shopsLoading, setShopsLoading] = useState<boolean>(false);
const [saving, setSaving] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [allShops, setAllShops] = useState<Shop[]>([]);
const [uniqueRemarks, setUniqueRemarks] = useState<string[]>([]);
const [uniqueShopCodes, setUniqueShopCodes] = useState<string[]>([]);
const [uniqueShopNames, setUniqueShopNames] = useState<string[]>([]);
const [addShopDialogOpen, setAddShopDialogOpen] = useState<boolean>(false);
const [newShop, setNewShop] = useState({
shopName: "",
shopCode: "",
loadingSequence: 0,
remark: "",
});
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: "success" | "error" }>({
open: false,
message: "",
severity: "success",
});

// Fetch autocomplete data on mount
useEffect(() => {
const fetchAutocompleteData = async () => {
try {
const [shopData, remarks, codes, names] = await Promise.all([
findAllUniqueShopNamesAndCodesFromTrucksClient() as Promise<Array<{ name: string; code: string }>>,
findAllUniqueRemarksFromTrucksClient() as Promise<string[]>,
findAllUniqueShopCodesFromTrucksClient() as Promise<string[]>,
findAllUniqueShopNamesFromTrucksClient() as Promise<string[]>,
]);

// Convert to Shop format (id will be 0 since we don't have shop IDs from truck table)
const shopList: Shop[] = shopData.map((shop) => ({
id: 0,
name: shop.name || "",
code: shop.code || "",
addr3: "",
}));
setAllShops(shopList);
setUniqueRemarks(remarks || []);
setUniqueShopCodes(codes || []);
setUniqueShopNames(names || []);
} catch (err) {
console.error("Failed to load autocomplete data:", err);
}
};

fetchAutocompleteData();
}, []);

useEffect(() => {
// Wait a bit to ensure searchParams are fully available
if (!truckLanceCodeParam) {
@@ -183,28 +218,55 @@ const TruckLaneDetail: React.FC = () => {
setSaving(true);
setError(null);
try {
// Get LoadingSequence from edited data - handle both PascalCase and camelCase
// Get values from edited data
const editedShop = editedShopsData[index];
const loadingSeq = (editedShop as any)?.LoadingSequence ?? (editedShop as any)?.loadingSequence;
const loadingSequenceValue = (loadingSeq !== null && loadingSeq !== undefined) ? Number(loadingSeq) : 0;
// Get shopName and shopCode from edited data
const shopNameValue = editedShop.name ? String(editedShop.name).trim() : null;
const shopCodeValue = editedShop.code ? String(editedShop.code).trim() : null;
const remarkValue = editedShop.remark ? String(editedShop.remark).trim() : null;
// Get shopId from editedShop.id (which was set when shopName or shopCode was selected)
// If not found, try to find it from shop table by shopCode
let shopIdValue: number | null = null;
if (editedShop.id && editedShop.id > 0) {
shopIdValue = editedShop.id;
} else if (shopCodeValue) {
// If shopId is 0 (from truck table), try to find it from shop table
try {
const allShopsFromShopTable = await fetchAllShopsClient() as ShopAndTruck[];
const foundShop = allShopsFromShopTable.find(s => String(s.code).trim() === shopCodeValue);
if (foundShop) {
shopIdValue = foundShop.id;
}
} catch (err) {
console.error("Failed to lookup shopId:", err);
}
}

if (!shop.truckId) {
setSnackbar({
open: true,
message: "Truck ID is required",
message: t("Truck ID is required"),
severity: "error",
});
return;
}

await updateLoadingSequenceClient({
await updateTruckShopDetailsClient({
id: shop.truckId,
shopId: shopIdValue,
shopName: shopNameValue,
shopCode: shopCodeValue,
loadingSequence: loadingSequenceValue,
remark: remarkValue || null,
});

setSnackbar({
open: true,
message: t("Loading sequence updated successfully"),
message: t("Truck shop details updated successfully"),
severity: "success",
});

@@ -214,10 +276,10 @@ const TruckLaneDetail: React.FC = () => {
}
setEditingRowIndex(null);
} catch (err: any) {
console.error("Failed to save loading sequence:", err);
console.error("Failed to save truck shop details:", err);
setSnackbar({
open: true,
message: err?.message ?? String(err) ?? t("Failed to save loading sequence"),
message: err?.message ?? String(err) ?? t("Failed to save truck shop details"),
severity: "error",
});
} finally {
@@ -235,6 +297,53 @@ const TruckLaneDetail: React.FC = () => {
setEditedShopsData(updated);
};

const handleShopNameChange = (index: number, shop: Shop | null) => {
const updated = [...editedShopsData];
if (shop) {
updated[index] = {
...updated[index],
name: shop.name,
code: shop.code,
id: shop.id, // Store shopId for later use
};
} else {
updated[index] = {
...updated[index],
name: "",
code: "",
};
}
setEditedShopsData(updated);
};

const handleShopCodeChange = (index: number, shop: Shop | null) => {
const updated = [...editedShopsData];
if (shop) {
updated[index] = {
...updated[index],
name: shop.name,
code: shop.code,
id: shop.id, // Store shopId for later use
};
} else {
updated[index] = {
...updated[index],
name: "",
code: "",
};
}
setEditedShopsData(updated);
};

const handleRemarkChange = (index: number, value: string) => {
const updated = [...editedShopsData];
updated[index] = {
...updated[index],
remark: value,
};
setEditedShopsData(updated);
};

const handleDelete = async (truckIdToDelete: number) => {
if (!window.confirm(t("Are you sure you want to delete this truck lane?"))) {
return;
@@ -263,7 +372,213 @@ const TruckLaneDetail: React.FC = () => {
};

const handleBack = () => {
router.push("/settings/shop");
router.push("/settings/shop?tab=1");
};

const handleOpenAddShopDialog = () => {
setNewShop({
shopName: "",
shopCode: "",
loadingSequence: 0,
remark: "",
});
setAddShopDialogOpen(true);
setError(null);
};

const handleCloseAddShopDialog = () => {
setAddShopDialogOpen(false);
setNewShop({
shopName: "",
shopCode: "",
loadingSequence: 0,
remark: "",
});
};

const handleNewShopNameChange = (newValue: string | null) => {
if (newValue && typeof newValue === 'string') {
// When a name is selected, try to find matching shop code
const matchingShop = allShops.find(s => String(s.name) === newValue);
if (matchingShop) {
setNewShop({
...newShop,
shopName: newValue,
shopCode: String(matchingShop.code || ""),
});
} else {
// If no matching shop found, allow free text input for shop name
setNewShop({
...newShop,
shopName: newValue,
});
}
} else {
// Clear shop name when selection is cleared (but keep shop code if it exists)
setNewShop({
...newShop,
shopName: "",
});
}
};

const handleNewShopCodeChange = (newValue: string | null) => {
if (newValue && typeof newValue === 'string') {
// When a code is selected, try to find matching shop name
const matchingShop = allShops.find(s => String(s.code) === newValue);
if (matchingShop) {
setNewShop({
...newShop,
shopCode: newValue,
shopName: String(matchingShop.name || ""),
});
} else {
// If no matching shop found, still set the code (shouldn't happen with restricted selection)
setNewShop({
...newShop,
shopCode: newValue,
});
}
} else {
// Clear both fields when selection is cleared
setNewShop({
...newShop,
shopName: "",
shopCode: "",
});
}
};

const handleCreateShop = async () => {
// Validate required fields
const missingFields: string[] = [];

if (!newShop.shopName.trim()) {
missingFields.push(t("Shop Name"));
}

if (!newShop.shopCode.trim()) {
missingFields.push(t("Shop Code"));
}

if (missingFields.length > 0) {
setSnackbar({
open: true,
message: `${t("Please fill in the following required fields:")} ${missingFields.join(", ")}`,
severity: "error",
});
return;
}

if (!truckData || !truckLanceCode) {
setSnackbar({
open: true,
message: t("Truck lane information is required"),
severity: "error",
});
return;
}

setSaving(true);
setError(null);
try {
// Get storeId from truckData
const storeIdValue = truckData.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String
? String(truckData.storeId)
: String(truckData.storeId)) : "2F";
const displayStoreId = normalizeStoreId(storeIdValue) || "2F";

// Get departureTime from truckData
let departureTimeStr = "";
if (truckData.departureTime) {
if (Array.isArray(truckData.departureTime)) {
const [hours, minutes] = truckData.departureTime;
departureTimeStr = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
} else {
departureTimeStr = String(truckData.departureTime);
}
}

// Look up shopId from shop table by shopCode
let shopIdValue: number | null = null;
try {
const allShopsFromShopTable = await fetchAllShopsClient() as ShopAndTruck[];
const foundShop = allShopsFromShopTable.find(s => String(s.code).trim() === newShop.shopCode.trim());
if (foundShop) {
shopIdValue = foundShop.id;
}
} catch (err) {
console.error("Failed to lookup shopId:", err);
}

// Get remark - only if storeId is "4F"
const remarkValue = displayStoreId === "4F" ? (newShop.remark?.trim() || null) : null;

// Check if there's an "Unassign" row for this truck lane that should be replaced
let unassignTruck: Truck | null = null;
try {
const allTrucks = await findAllByTruckLanceCodeAndDeletedFalseClient(String(truckData.truckLanceCode || "")) as Truck[];
unassignTruck = allTrucks.find(t =>
String(t.shopName || "").trim() === "Unassign" &&
String(t.shopCode || "").trim() === "Unassign"
) || null;
} catch (err) {
console.error("Failed to check for Unassign truck:", err);
}

if (unassignTruck && unassignTruck.id) {
// Update the existing "Unassign" row instead of creating a new one
await updateTruckShopDetailsClient({
id: unassignTruck.id,
shopId: shopIdValue || null,
shopName: newShop.shopName.trim(),
shopCode: newShop.shopCode.trim(),
loadingSequence: newShop.loadingSequence,
remark: remarkValue,
});

setSnackbar({
open: true,
message: t("Shop added to truck lane successfully"),
severity: "success",
});
} else {
// No "Unassign" row found, create a new one
await createTruckClient({
store_id: displayStoreId,
truckLanceCode: String(truckData.truckLanceCode || ""),
departureTime: departureTimeStr,
shopId: shopIdValue || 0,
shopName: newShop.shopName.trim(),
shopCode: newShop.shopCode.trim(),
loadingSequence: newShop.loadingSequence,
remark: remarkValue,
districtReference: null,
});

setSnackbar({
open: true,
message: t("Shop added to truck lane successfully"),
severity: "success",
});
}

// Refresh the shops list
if (truckLanceCode) {
await fetchShopsByTruckLane(truckLanceCode);
}
handleCloseAddShopDialog();
} catch (err: any) {
console.error("Failed to create shop in truck lane:", err);
setSnackbar({
open: true,
message: err?.message ?? String(err) ?? t("Failed to create shop in truck lane"),
severity: "error",
});
} finally {
setSaving(false);
}
};

if (loading) {
@@ -300,11 +615,11 @@ const TruckLaneDetail: React.FC = () => {
);
}

const storeId = truckData.storeId;
const storeIdStr = storeId ? (typeof storeId === 'string' ? storeId : String(storeId)) : "-";
const displayStoreId = storeIdStr === "2" || storeIdStr === "2F" ? "2F"
: storeIdStr === "4" || storeIdStr === "4F" ? "4F"
: storeIdStr;
const displayStoreId = normalizeStoreId(
truckData.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String
? String(truckData.storeId)
: String(truckData.storeId)) : null
);

return (
<Box>
@@ -323,7 +638,7 @@ const TruckLaneDetail: React.FC = () => {
<CardContent>
<Paper sx={{ p: 3 }}>
<Grid container spacing={3}>
<Grid item xs={12} sm={6}>
<Grid item xs={12} sm={4}>
<Typography variant="subtitle2" color="text.secondary">
{t("TruckLance Code")}
</Typography>
@@ -332,7 +647,7 @@ const TruckLaneDetail: React.FC = () => {
</Typography>
</Grid>

<Grid item xs={12} sm={6}>
<Grid item xs={12} sm={4}>
<Typography variant="subtitle2" color="text.secondary">
{t("Departure Time")}
</Typography>
@@ -345,7 +660,7 @@ const TruckLaneDetail: React.FC = () => {
</Typography>
</Grid>

<Grid item xs={12} sm={6}>
<Grid item xs={12} sm={4}>
<Typography variant="subtitle2" color="text.secondary">
{t("Store ID")}
</Typography>
@@ -361,9 +676,19 @@ const TruckLaneDetail: React.FC = () => {

<Card sx={{ mt: 2 }}>
<CardContent>
<Typography variant="h6" sx={{ mb: 2 }}>
{t("Shops Using This Truck Lane")}
</Typography>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
<Typography variant="h6">
{t("Shops Using This Truck Lane")}
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleOpenAddShopDialog}
disabled={saving || editingRowIndex !== null}
>
{t("Add Shop")}
</Button>
</Box>
{shopsLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
@@ -394,13 +719,144 @@ const TruckLaneDetail: React.FC = () => {
shopsData.map((shop, index) => (
<TableRow key={shop.id ?? `shop-${index}`}>
<TableCell>
{String(shop.name || "-")}
{editingRowIndex === index ? (
<Autocomplete
freeSolo
size="small"
options={uniqueShopNames}
value={String(editedShopsData[index]?.name || "")}
onChange={(event, newValue) => {
if (newValue && typeof newValue === 'string') {
// When a name is selected, try to find matching shop code
const matchingShop = allShops.find(s => String(s.name) === newValue);
if (matchingShop) {
handleShopNameChange(index, matchingShop);
} else {
// If no matching shop found, just update the name
const updated = [...editedShopsData];
updated[index] = {
...updated[index],
name: newValue,
};
setEditedShopsData(updated);
}
} else if (newValue === null) {
handleShopNameChange(index, null);
}
}}
onInputChange={(event, newInputValue, reason) => {
if (reason === 'input') {
// Allow free text input
const updated = [...editedShopsData];
updated[index] = {
...updated[index],
name: newInputValue,
};
setEditedShopsData(updated);
}
}}
renderInput={(params) => (
<TextField
{...params}
fullWidth
disabled={saving}
placeholder={t("Search or select shop name")}
/>
)}
/>
) : (
String(shop.name || "-")
)}
</TableCell>
<TableCell>
{String(shop.code || "-")}
{editingRowIndex === index ? (
<Autocomplete
freeSolo
size="small"
options={uniqueShopCodes}
value={String(editedShopsData[index]?.code || "")}
onChange={(event, newValue) => {
if (newValue && typeof newValue === 'string') {
// When a code is selected, try to find matching shop name
const matchingShop = allShops.find(s => String(s.code) === newValue);
if (matchingShop) {
handleShopCodeChange(index, matchingShop);
} else {
// If no matching shop found, just update the code
const updated = [...editedShopsData];
updated[index] = {
...updated[index],
code: newValue,
};
setEditedShopsData(updated);
}
} else if (newValue === null) {
handleShopCodeChange(index, null);
}
}}
onInputChange={(event, newInputValue, reason) => {
if (reason === 'input') {
// Allow free text input
const updated = [...editedShopsData];
updated[index] = {
...updated[index],
code: newInputValue,
};
setEditedShopsData(updated);
}
}}
renderInput={(params) => (
<TextField
{...params}
fullWidth
disabled={saving}
placeholder={t("Search or select shop code")}
/>
)}
/>
) : (
String(shop.code || "-")
)}
</TableCell>
<TableCell>
{String(shop.remark || "-")}
{editingRowIndex === index ? (
(() => {
const storeIdValue = truckData.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String
? String(truckData.storeId)
: String(truckData.storeId)) : null;
const isEditable = normalizeStoreId(storeIdValue) === "4F";
return (
<Autocomplete
freeSolo
size="small"
options={uniqueRemarks}
value={String(editedShopsData[index]?.remark || "")}
onChange={(event, newValue) => {
if (isEditable) {
const remarkValue = typeof newValue === 'string' ? newValue : (newValue || "");
handleRemarkChange(index, remarkValue);
}
}}
onInputChange={(event, newInputValue, reason) => {
if (isEditable && reason === 'input') {
handleRemarkChange(index, newInputValue);
}
}}
disabled={saving || !isEditable}
renderInput={(params) => (
<TextField
{...params}
fullWidth
placeholder={isEditable ? t("Search or select remark") : t("Not editable for this Store ID")}
disabled={saving || !isEditable}
/>
)}
/>
);
})()
) : (
String(shop.remark || "-")
)}
</TableCell>
<TableCell>
{editingRowIndex === index ? (
@@ -454,7 +910,7 @@ const TruckLaneDetail: React.FC = () => {
size="small"
color="primary"
onClick={() => handleEdit(index)}
title={t("Edit loading sequence")}
title={t("Edit shop details")}
>
<EditIcon />
</IconButton>
@@ -482,6 +938,117 @@ const TruckLaneDetail: React.FC = () => {
</CardContent>
</Card>

{/* Add Shop Dialog */}
<Dialog open={addShopDialogOpen} onClose={handleCloseAddShopDialog} maxWidth="sm" fullWidth>
<DialogTitle>{t("Add Shop to Truck Lane")}</DialogTitle>
<DialogContent>
<Box sx={{ pt: 2 }}>
<Grid container spacing={2}>
<Grid item xs={12}>
<Autocomplete
freeSolo
options={uniqueShopNames}
value={newShop.shopName || null}
onChange={(event, newValue) => {
handleNewShopNameChange(newValue);
}}
onInputChange={(event, newInputValue, reason) => {
if (reason === 'input') {
// Allow free text input for shop name
setNewShop({ ...newShop, shopName: newInputValue });
}
}}
renderInput={(params) => (
<TextField
{...params}
label={t("Shop Name")}
fullWidth
required
disabled={saving}
placeholder={t("Search or select shop name")}
/>
)}
/>
</Grid>
<Grid item xs={12}>
<Autocomplete
options={uniqueShopCodes}
value={newShop.shopCode || null}
onChange={(event, newValue) => {
handleNewShopCodeChange(newValue);
}}
renderInput={(params) => (
<TextField
{...params}
label={t("Shop Code")}
fullWidth
required
disabled={saving}
placeholder={t("Search or select shop code")}
/>
)}
/>
</Grid>
<Grid item xs={12}>
<TextField
label={t("Loading Sequence")}
type="number"
fullWidth
value={newShop.loadingSequence}
onChange={(e) => setNewShop({ ...newShop, loadingSequence: parseInt(e.target.value) || 0 })}
disabled={saving}
/>
</Grid>
{(() => {
const storeIdValue = truckData?.storeId ? (typeof truckData.storeId === 'string' || truckData.storeId instanceof String
? String(truckData.storeId)
: String(truckData.storeId)) : null;
const isEditable = normalizeStoreId(storeIdValue) === "4F";
return isEditable ? (
<Grid item xs={12}>
<Autocomplete
freeSolo
options={uniqueRemarks}
value={newShop.remark || ""}
onChange={(event, newValue) => {
setNewShop({ ...newShop, remark: typeof newValue === 'string' ? newValue : (newValue || "") });
}}
onInputChange={(event, newInputValue, reason) => {
if (reason === 'input') {
setNewShop({ ...newShop, remark: newInputValue });
}
}}
renderInput={(params) => (
<TextField
{...params}
label={t("Remark")}
fullWidth
disabled={saving}
placeholder={t("Search or select remark")}
/>
)}
/>
</Grid>
) : null;
})()}
</Grid>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseAddShopDialog} disabled={saving}>
{t("Cancel")}
</Button>
<Button
onClick={handleCreateShop}
variant="contained"
startIcon={<SaveIcon />}
disabled={saving}
>
{saving ? t("Submitting...") : t("Save")}
</Button>
</DialogActions>
</Dialog>

<Snackbar
open={snackbar.open}
autoHideDuration={6000}
@@ -492,6 +1059,4 @@ const TruckLaneDetail: React.FC = () => {
);
};

export default TruckLaneDetail;


export default TruckLaneDetail;

+ 364
- 0
src/components/WarehouseHandle/WarehouseHandle.tsx Zobrazit soubor

@@ -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;

+ 40
- 0
src/components/WarehouseHandle/WarehouseHandleLoading.tsx Zobrazit soubor

@@ -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;

+ 19
- 0
src/components/WarehouseHandle/WarehouseHandleWrapper.tsx Zobrazit soubor

@@ -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;

+ 1
- 0
src/components/WarehouseHandle/index.ts Zobrazit soubor

@@ -0,0 +1 @@
export { default } from "./WarehouseHandleWrapper";

+ 520
- 62
src/components/qrCodeHandles/qrCodeHandleEquipmentSearch.tsx Zobrazit soubor

@@ -1,84 +1,102 @@
"use client";

import { useCallback, useEffect, useMemo, useState } from "react";
import SearchBox, { Criterion } from "../SearchBox";
import { EquipmentResult } from "@/app/api/settings/equipment";
import { useCallback, useMemo, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import SearchResults, { Column } from "../SearchResults";
import { EditNote } from "@mui/icons-material";
import { useRouter } from "next/navigation";
import { GridDeleteIcon } from "@mui/x-data-grid";
import { successDialog } from "../Swal/CustomAlerts";
import useUploadContext from "../UploadProvider/useUploadContext";
import { downloadFile } from "@/app/utils/commonUtil";
import { EquipmentDetailResult } from "@/app/api/settings/equipmentDetail";
import { exportEquipmentQrCode } from "@/app/api/settings/equipmentDetail/client";
import {
Checkbox,
Box,
Button,
TextField,
Stack,
Autocomplete,
Modal,
Card,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Typography
} from "@mui/material";
import DownloadIcon from "@mui/icons-material/Download";
import PrintIcon from "@mui/icons-material/Print";
import CloseIcon from "@mui/icons-material/Close";
import { PrinterCombo } from "@/app/api/settings/printer";
import axiosInstance from "@/app/(main)/axios/axiosInstance";
import { NEXT_PUBLIC_API_URL } from "@/config/api";

type Props = {
equipments: EquipmentResult[];
};
interface Props {
equipmentDetails: EquipmentDetailResult[];
printerCombo: PrinterCombo[];
}

type SearchQuery = Partial<Omit<EquipmentResult, "id">>;
type SearchQuery = Partial<Omit<EquipmentDetailResult, "id">>;
type SearchParamNames = keyof SearchQuery;

const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => {
const [filteredEquipments, setFilteredEquipments] =
useState<EquipmentResult[]>([]);
const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printerCombo }) => {
const { t } = useTranslation("common");
const [filteredEquipmentDetails, setFilteredEquipmentDetails] = useState<EquipmentDetailResult[]>([]);
const router = useRouter();
const [filterObj, setFilterObj] = useState({});
const { setIsUploading } = useUploadContext();
const [pagingController, setPagingController] = useState({
pageNum: 1,
pageSize: 10,
});
const [filterObj, setFilterObj] = useState({});
const [totalCount, setTotalCount] = useState(0);
const searchCriteria: Criterion<SearchParamNames>[] = useMemo(() => {
const searchCriteria: Criterion<SearchParamNames>[] = [
{ label: t("Code"), paramName: "code", type: "text" },
{ label: t("Description"), paramName: "description", type: "text" },
];
return searchCriteria;
}, [t, equipments]);

const onDetailClick = useCallback(
(equipment: EquipmentResult) => {
router.push(`/settings/equipment/edit?id=${equipment.id}`);
},
[router],
);

const onDeleteClick = useCallback(
(equipment: EquipmentResult) => {},
[router],
const [checkboxIds, setCheckboxIds] = useState<(string | number)[]>([]);
const [selectedEquipmentDetailsMap, setSelectedEquipmentDetailsMap] = useState<Map<string | number, EquipmentDetailResult>>(new Map());
const [selectAll, setSelectAll] = useState(false);
const [printQty, setPrintQty] = useState(1);
const [isSearching, setIsSearching] = useState(false);

const [previewOpen, setPreviewOpen] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);

const [selectedEquipmentDetailsModalOpen, setSelectedEquipmentDetailsModalOpen] = useState(false);

const filteredPrinters = useMemo(() => {
return printerCombo.filter((printer) => {
return printer.type === "A4";
});
}, [printerCombo]);

const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | undefined>(
filteredPrinters.length > 0 ? filteredPrinters[0] : undefined
);

const columns = useMemo<Column<EquipmentResult>[]>(
useEffect(() => {
if (!selectedPrinter || !filteredPrinters.find(p => p.id === selectedPrinter.id)) {
setSelectedPrinter(filteredPrinters.length > 0 ? filteredPrinters[0] : undefined);
}
}, [filteredPrinters, selectedPrinter]);

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{
name: "id",
label: t("Details"),
onClick: onDetailClick,
buttonIcon: <EditNote />,
},
{
name: "code",
label: t("Code"),
},
{
name: "equipmentTypeId",
label: t("Equipment Type"),
sx: {minWidth: 180},
},
{
name: "description",
label: t("Description"),
label: "設備名稱",
paramName: "code",
type: "text",
},
{
name: "action",
label: t(""),
buttonIcon: <GridDeleteIcon />,
onClick: onDeleteClick,
label: "設備編號",
paramName: "equipmentCode",
type: "text",
},
],
[filteredEquipments],
[],
);

interface ApiResponse<T> {
@@ -101,20 +119,19 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => {
...filterObj,
};
try {
const response = await axiosInstance.get<ApiResponse<EquipmentResult>>(
`${NEXT_PUBLIC_API_URL}/Equipment/getRecordByPage`,
const response = await axiosInstance.get<ApiResponse<EquipmentDetailResult>>(
`${NEXT_PUBLIC_API_URL}/EquipmentDetail/getRecordByPage`,
{ params },
);
console.log(response);
if (response.status == 200) {
setFilteredEquipments(response.data.records);
setFilteredEquipmentDetails(response.data.records);
setTotalCount(response.data.total);
return response;
} else {
throw "400";
}
} catch (error) {
console.error("Error fetching equipment types:", error);
console.error("Error fetching equipment details:", error);
throw error;
}
},
@@ -125,6 +142,228 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => {
refetchData(filterObj, pagingController.pageNum, pagingController.pageSize);
}, [filterObj, pagingController.pageNum, pagingController.pageSize]);

useEffect(() => {
if (filteredEquipmentDetails.length > 0) {
const allCurrentPageSelected = filteredEquipmentDetails.every(ed => checkboxIds.includes(ed.id));
setSelectAll(allCurrentPageSelected);
} else {
setSelectAll(false);
}
}, [filteredEquipmentDetails, checkboxIds]);

const handleSelectEquipmentDetail = useCallback((equipmentDetailId: string | number, checked: boolean) => {
if (checked) {
const equipmentDetail = filteredEquipmentDetails.find(ed => ed.id === equipmentDetailId);
if (equipmentDetail) {
setCheckboxIds(prev => [...prev, equipmentDetailId]);
setSelectedEquipmentDetailsMap(prev => {
const newMap = new Map(prev);
newMap.set(equipmentDetailId, equipmentDetail);
return newMap;
});
}
} else {
setCheckboxIds(prev => prev.filter(id => id !== equipmentDetailId));
setSelectedEquipmentDetailsMap(prev => {
const newMap = new Map(prev);
newMap.delete(equipmentDetailId);
return newMap;
});
setSelectAll(false);
}
}, [filteredEquipmentDetails]);

const fetchAllMatchingEquipmentDetails = useCallback(async (): Promise<EquipmentDetailResult[]> => {
const authHeader = axiosInstance.defaults.headers["Authorization"];
if (!authHeader) {
return [];
}
if (totalCount === 0) {
return [];
}
const params = {
pageNum: 1,
pageSize: totalCount > 0 ? totalCount : 10000,
...filterObj,
};
try {
const response = await axiosInstance.get<ApiResponse<EquipmentDetailResult>>(
`${NEXT_PUBLIC_API_URL}/EquipmentDetail/getRecordByPage`,
{ params },
);
if (response.status == 200) {
return response.data.records;
}
return [];
} catch (error) {
console.error("Error fetching all equipment details:", error);
return [];
}
}, [filterObj, totalCount]);

const handleSelectAll = useCallback(async (checked: boolean) => {
if (checked) {
try {
const allEquipmentDetails = await fetchAllMatchingEquipmentDetails();
const allIds = allEquipmentDetails.map(equipmentDetail => equipmentDetail.id);
setCheckboxIds(allIds);
setSelectedEquipmentDetailsMap(prev => {
const newMap = new Map(prev);
allEquipmentDetails.forEach(equipmentDetail => {
newMap.set(equipmentDetail.id, equipmentDetail);
});
return newMap;
});
setSelectAll(true);
} catch (error) {
console.error("Error selecting all equipment:", error);
}
} else {
setCheckboxIds([]);
setSelectedEquipmentDetailsMap(new Map());
setSelectAll(false);
}
}, [fetchAllMatchingEquipmentDetails]);

const showPdfPreview = useCallback(async (equipmentDetailIds: (string | number)[]) => {
if (equipmentDetailIds.length === 0) {
return;
}
try {
setIsUploading(true);
const numericIds = equipmentDetailIds.map(id => typeof id === 'string' ? parseInt(id) : id);
const response = await exportEquipmentQrCode(numericIds);
const blob = new Blob([new Uint8Array(response.blobValue)], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
setPreviewUrl(`${url}#toolbar=0`);
setPreviewOpen(true);
} catch (error) {
console.error("Error exporting QR code:", error);
} finally {
setIsUploading(false);
}
}, [setIsUploading]);

const handleClosePreview = useCallback(() => {
setPreviewOpen(false);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
}
}, [previewUrl]);

const handleDownloadQrCode = useCallback(async (equipmentDetailIds: (string | number)[]) => {
if (equipmentDetailIds.length === 0) {
return;
}
try {
setIsUploading(true);
const numericIds = equipmentDetailIds.map(id => typeof id === 'string' ? parseInt(id) : id);
const response = await exportEquipmentQrCode(numericIds);
downloadFile(response.blobValue, response.filename);
setSelectedEquipmentDetailsModalOpen(false);
successDialog("二維碼已下載", t);
} catch (error) {
console.error("Error exporting QR code:", error);
} finally {
setIsUploading(false);
}
}, [setIsUploading, t]);

const handlePrint = useCallback(async () => {
if (checkboxIds.length === 0) {
return;
}
try {
setIsUploading(true);
const numericIds = checkboxIds.map(id => typeof id === 'string' ? parseInt(id) : id);
const response = await exportEquipmentQrCode(numericIds);
const blob = new Blob([new Uint8Array(response.blobValue)], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const printWindow = window.open(url, '_blank');
if (printWindow) {
printWindow.onload = () => {
for (let i = 0; i < printQty; i++) {
setTimeout(() => {
printWindow.print();
}, i * 500);
}
};
}
setTimeout(() => {
URL.revokeObjectURL(url);
}, 1000);
setSelectedEquipmentDetailsModalOpen(false);
successDialog("二維碼已列印", t);
} catch (error) {
console.error("Error printing QR code:", error);
} finally {
setIsUploading(false);
}
}, [checkboxIds, printQty, setIsUploading, t]);

const handleViewSelectedQrCodes = useCallback(() => {
if (checkboxIds.length === 0) {
return;
}
setSelectedEquipmentDetailsModalOpen(true);
}, [checkboxIds]);

const selectedEquipmentDetails = useMemo(() => {
return Array.from(selectedEquipmentDetailsMap.values());
}, [selectedEquipmentDetailsMap]);

const handleCloseSelectedEquipmentDetailsModal = useCallback(() => {
setSelectedEquipmentDetailsModalOpen(false);
}, []);

const columns = useMemo<Column<EquipmentDetailResult>[]>(
() => [
{
name: "id",
label: "",
sx: { width: "50px", minWidth: "50px" },
renderCell: (params) => (
<Checkbox
checked={checkboxIds.includes(params.id)}
onChange={(e) => handleSelectEquipmentDetail(params.id, e.target.checked)}
onClick={(e) => e.stopPropagation()}
/>
),
},
{
name: "code",
label: "設備名稱",
align: "left",
headerAlign: "left",
sx: { width: "150px", minWidth: "150px" },
},
{
name: "description",
label: "設備描述",
align: "left",
headerAlign: "left",
sx: { width: "200px", minWidth: "200px" },
},
{
name: "equipmentCode",
label: "設備編號",
align: "left",
headerAlign: "left",
sx: { width: "150px", minWidth: "150px" },
},
],
[t, checkboxIds, handleSelectEquipmentDetail],
);

const onReset = useCallback(() => {
setFilterObj({});
setPagingController({ pageNum: 1, pageSize: 10 });
@@ -138,19 +377,238 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipments }) => {
setFilterObj({
...query,
});
setPagingController({ pageNum: 1, pageSize: 10 });
}}
onReset={onReset}
/>
<SearchResults<EquipmentResult>
items={filteredEquipments}
<SearchResults<EquipmentDetailResult>
items={filteredEquipmentDetails}
columns={columns}
setPagingController={setPagingController}
pagingController={pagingController}
setPagingController={setPagingController}
totalCount={totalCount}
isAutoPaging={false}
/>
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant="outlined"
onClick={() => handleSelectAll(!selectAll)}
startIcon={<Checkbox checked={selectAll} />}
disabled={isSearching || totalCount === 0}
>
選擇全部設備 ({checkboxIds.length} / {totalCount})
</Button>
<Button
variant="contained"
onClick={handleViewSelectedQrCodes}
disabled={checkboxIds.length === 0}
color="primary"
>
查看已選擇設備二維碼 ({checkboxIds.length})
</Button>
</Box>

<Modal
open={selectedEquipmentDetailsModalOpen}
onClose={handleCloseSelectedEquipmentDetailsModal}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Card
sx={{
position: 'relative',
width: '90%',
maxWidth: '800px',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
outline: 'none',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: 2,
borderBottom: 1,
borderColor: 'divider',
}}
>
<Typography variant="h6" component="h2">
已選擇設備 ({selectedEquipmentDetails.length})
</Typography>
<IconButton onClick={handleCloseSelectedEquipmentDetailsModal}>
<CloseIcon />
</IconButton>
</Box>

<Box
sx={{
flex: 1,
overflow: 'auto',
p: 2,
}}
>
<TableContainer component={Paper} variant="outlined">
<Table>
<TableHead>
<TableRow>
<TableCell>
<strong>設備名稱</strong>
</TableCell>
<TableCell>
<strong>設備描述</strong>
</TableCell>
<TableCell>
<strong>設備編號</strong>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{selectedEquipmentDetails.length === 0 ? (
<TableRow>
<TableCell colSpan={3} align="center">
沒有選擇的設備
</TableCell>
</TableRow>
) : (
selectedEquipmentDetails.map((equipmentDetail) => (
<TableRow key={equipmentDetail.id}>
<TableCell>{equipmentDetail.code || '-'}</TableCell>
<TableCell>{equipmentDetail.description || '-'}</TableCell>
<TableCell>{equipmentDetail.equipmentCode || '-'}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Box>

<Box
sx={{
p: 2,
borderTop: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
}}
>
<Stack direction="row" justifyContent="flex-end" alignItems="center" gap={2}>
<Autocomplete<PrinterCombo>
options={filteredPrinters}
value={selectedPrinter ?? null}
onChange={(event, value) => {
setSelectedPrinter(value ?? undefined);
}}
getOptionLabel={(option) => option.name || option.label || option.code || String(option.id)}
renderInput={(params) => (
<TextField
{...params}
variant="outlined"
label="列印機"
sx={{ width: 300 }}
/>
)}
/>
<TextField
variant="outlined"
label="列印數量"
type="number"
value={printQty}
onChange={(e) => {
const value = parseInt(e.target.value) || 1;
setPrintQty(Math.max(1, value));
}}
inputProps={{ min: 1 }}
sx={{ width: 120 }}
/>
<Button
variant="contained"
startIcon={<PrintIcon />}
onClick={handlePrint}
disabled={checkboxIds.length === 0 || filteredPrinters.length === 0}
color="primary"
>
列印
</Button>
<Button
variant="contained"
startIcon={<DownloadIcon />}
onClick={() => handleDownloadQrCode(checkboxIds)}
disabled={checkboxIds.length === 0}
color="primary"
>
下載二維碼
</Button>
</Stack>
</Box>
</Card>
</Modal>

<Modal
open={previewOpen}
onClose={handleClosePreview}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Card
sx={{
position: 'relative',
width: '90%',
maxWidth: '900px',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
outline: 'none',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
p: 2,
borderBottom: 1,
borderColor: 'divider',
}}
>
<IconButton
onClick={handleClosePreview}
>
<CloseIcon />
</IconButton>
</Box>

<Box
sx={{
flex: 1,
overflow: 'auto',
p: 2,
}}
>
{previewUrl && (
<iframe
src={previewUrl}
width="100%"
height="600px"
style={{
border: 'none',
}}
title="PDF Preview"
/>
)}
</Box>
</Card>
</Modal>
</>
);
};

export default QrCodeHandleEquipmentSearch;
export default QrCodeHandleEquipmentSearch;

+ 7
- 3
src/components/qrCodeHandles/qrCodeHandleEquipmentSearchWrapper.tsx Zobrazit soubor

@@ -1,15 +1,19 @@
import React from "react";
import QrCodeHandleEquipmentSearch from "./qrCodeHandleEquipmentSearch";
import EquipmentSearchLoading from "../EquipmentSearch/EquipmentSearchLoading";
import { fetchAllEquipments } from "@/app/api/settings/equipment";
import { fetchAllEquipmentDetails } from "@/app/api/settings/equipmentDetail";
import { fetchPrinterCombo } from "@/app/api/settings/printer";

interface SubComponents {
Loading: typeof EquipmentSearchLoading;
}

const QrCodeHandleEquipmentSearchWrapper: React.FC & SubComponents = async () => {
const equipments = await fetchAllEquipments();
return <QrCodeHandleEquipmentSearch equipments={equipments} />;
const [equipmentDetails, printerCombo] = await Promise.all([
fetchAllEquipmentDetails(),
fetchPrinterCombo(),
]);
return <QrCodeHandleEquipmentSearch equipmentDetails={equipmentDetails} printerCombo={printerCombo} />;
};

QrCodeHandleEquipmentSearchWrapper.Loading = EquipmentSearchLoading;


+ 26
- 2
src/components/qrCodeHandles/qrCodeHandleTabs.tsx Zobrazit soubor

@@ -1,8 +1,9 @@
"use client";

import { useState, ReactNode } from "react";
import { useState, ReactNode, useEffect } from "react";
import { Box, Tabs, Tab } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useSearchParams, useRouter } from "next/navigation";

interface TabPanelProps {
children?: ReactNode;
@@ -37,10 +38,33 @@ const QrCodeHandleTabs: React.FC<QrCodeHandleTabsProps> = ({
}) => {
const { t } = useTranslation("common");
const { t: tUser } = useTranslation("user");
const [currentTab, setCurrentTab] = useState(0);
const searchParams = useSearchParams();
const router = useRouter();
const getInitialTab = () => {
const tab = searchParams.get("tab");
if (tab === "equipment") return 1;
if (tab === "user") return 0;
return 0;
};

const [currentTab, setCurrentTab] = useState(getInitialTab);

useEffect(() => {
const tab = searchParams.get("tab");
if (tab === "equipment") {
setCurrentTab(1);
} else if (tab === "user") {
setCurrentTab(0);
}
}, [searchParams]);

const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setCurrentTab(newValue);
const tabName = newValue === 1 ? "equipment" : "user";
const params = new URLSearchParams(searchParams.toString());
params.set("tab", tabName);
router.push(`?${params.toString()}`, { scroll: false });
};

return (


+ 65
- 26
src/config/authConfig.ts Zobrazit soubor

@@ -1,12 +1,32 @@
import { AuthOptions, Session } from "next-auth";
// config/authConfig.ts (or wherever your authOptions live)
import { AuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { LOGIN_API_PATH } from "./api";

export interface SessionWithTokens extends Session {
accessToken: string | null;
refreshToken?: string;
abilities: string[];
id?: string | null;
// Extend the built-in types
declare module "next-auth" {
interface Session {
accessToken: string | null;
refreshToken?: string;
abilities: string[];
id?: string;
}

interface User {
id?: string;
accessToken: string | null;
refreshToken?: string;
abilities: string[];
}
}

declare module "next-auth/jwt" {
interface JWT {
id?: string;
accessToken: string | null;
refreshToken?: string;
abilities: string[];
}
}

export const authOptions: AuthOptions = {
@@ -19,18 +39,26 @@ export const authOptions: AuthOptions = {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials, req) {
async authorize(credentials) {
if (!credentials?.username || !credentials?.password) return null;

const res = await fetch(LOGIN_API_PATH, {
method: "POST",
body: JSON.stringify(credentials),
headers: { "Content-Type": "application/json" },
});

if (!res.ok) return null;

const user = await res.json();

if (res.ok && user) {
return user;
// Important: next-auth expects the user object returned here
// to be serializable and contain the fields you want in token/session
// Ensure your backend returns: { id, accessToken, abilities, ...other fields }
if (user && user.abilities) {
return user; // this will be passed to jwt callback as `user`
}

return null;
},
}),
@@ -39,25 +67,36 @@ export const authOptions: AuthOptions = {
signIn: "/login",
},
callbacks: {
jwt(params) {
// Add the data from user to the token
const { token, user } = params;
const newToken = { ...token, ...user };
return newToken;
// Persist custom fields into the JWT token
async jwt({ token, user }) {
// First sign-in: `user` is available
if (user) {
token.id = user.id ?? token.sub; // fallback to sub if no id
token.accessToken = user.accessToken;
token.refreshToken = user.refreshToken;
token.abilities = user.abilities ?? [];
}

// On subsequent calls (token refresh, session access), user is not present
// so we just return the existing token with custom fields preserved
return token;
},
session({ session, token }) {
const sessionWithToken: SessionWithTokens = {
...session,
// Add the data from the token to the session
id: token.id as string | undefined,
accessToken: token.accessToken as string | null,
refreshToken: token.refreshToken as string | undefined,
abilities: token.abilities as string[],
};
if (sessionWithToken.user) {
sessionWithToken.user.abilities = token.abilities as string[];
// Expose custom fields to the client session
async session({ session, token }) {
session.id = token.id as string | undefined;
session.accessToken = token.accessToken as string | null;
session.refreshToken = token.refreshToken as string | undefined;
session.abilities = token.abilities as string[];
// Also add abilities to session.user for easier client-side access
if (session.user) {
session.user.abilities = token.abilities as string[];
}
return sessionWithToken;

return session;
},
},
};

export default authOptions;

+ 15
- 3
src/i18n/en/common.json Zobrazit soubor

@@ -12,12 +12,24 @@
"Equipment not found": "Equipment not found",
"Error saving data": "Error saving data",
"Cancel": "Cancel",
"Do you want to delete?": "Do you want to delete?",
"Save": "Save",
"Yes": "Yes",
"No": "No",
"Equipment Name": "Equipment Name",
"Equipment Code": "Equipment Code",

"ShopAndTruck": "ShopAndTruck"

"ShopAndTruck": "ShopAndTruck",
"TruckLance Code is required": "TruckLance Code is required",
"Truck shop details updated successfully": "Truck shop details updated successfully",
"Failed to save truck shop details": "Failed to save truck shop details",
"Truck lane information is required": "Truck lane information is required",
"Shop added to truck lane successfully": "Shop added to truck lane successfully",
"Failed to create shop in truck lane": "Failed to create shop in truck lane",
"Add Shop": "Add Shop",
"Search or select shop name": "Search or select shop name",
"Search or select shop code": "Search or select shop code",
"Search or select remark": "Search or select remark",
"Edit shop details": "Edit shop details",
"Add Shop to Truck Lane": "Add Shop to Truck Lane",
"Truck lane code already exists. Please use a different code.": "Truck lane code already exists. Please use a different code."
}

+ 3
- 1
src/i18n/en/user.json Zobrazit soubor

@@ -14,5 +14,7 @@
"User ID": "用戶ID",
"User Name": "用戶名稱",
"User Group": "用戶群組",
"Authority": "權限"
"Authority": "權限",
"Delete Success": "Delete Success",
"Do you want to delete?": "Do you want to delete?"
}

+ 27
- 0
src/i18n/en/warehouse.json Zobrazit soubor

@@ -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"
}

+ 23
- 7
src/i18n/zh/common.json Zobrazit soubor

@@ -77,6 +77,7 @@
"Setup Time": "生產前預備時間",
"Changeover Time": "生產後轉換時間",
"Warehouse": "倉庫",
"warehouse": "倉庫",
"Supplier": "供應商",
"Purchase Order": "採購單",
"Demand Forecast": "需求預測",
@@ -268,6 +269,7 @@
"Seq No Remark": "序號明細",
"Stock Available": "庫存可用",
"Confirm": "確認",
"Do you want to delete?": "您確定要刪除嗎?",
"Stock Status": "庫存狀態",
"Target Production Date": "目標生產日期",
"id": "ID",
@@ -383,19 +385,33 @@
"Filter by Status": "按狀態篩選",
"All": "全部",
"General Data": "基本資料",
"Repair and Maintenance": "維和保養",
"Repair and Maintenance Status": "維和保養狀態",
"Latest Repair and Maintenance Date": "最新維和保養日期",
"Last Repair and Maintenance Date": "上次維和保養日期",
"Repair and Maintenance Remarks": "維和保養備註",
"Repair and Maintenance": "維和保養",
"Repair and Maintenance Status": "維和保養狀態",
"Latest Repair and Maintenance Date": "最新維和保養日期",
"Last Repair and Maintenance Date": "上次維和保養日期",
"Repair and Maintenance Remarks": "維和保養備註",
"Rows per page": "每頁行數",
"Equipment Name": "設備名稱",
"Equipment Code": "設備編號",
"Yes": "是",
"No": "否",
"Update Equipment Maintenance and Repair": "更新設備的維和保養",
"Update Equipment Maintenance and Repair": "更新設備的維和保養",
"Equipment Information": "設備資訊",
"Loading": "載入中...",
"Equipment not found": "找不到設備",
"Error saving data": "保存數據時出錯"
"Error saving data": "保存數據時出錯",
"TruckLance Code is required": "需要卡車路線編號",
"Truck shop details updated successfully": "卡車店鋪詳情更新成功",
"Failed to save truck shop details": "儲存卡車店鋪詳情失敗",
"Truck lane information is required": "需要卡車路線資訊",
"Shop added to truck lane successfully": "店鋪已成功新增至卡車路線",
"Failed to create shop in truck lane": "新增店鋪至卡車路線失敗",
"Add Shop": "新增店鋪",
"Search or select shop name": "搜尋或選擇店鋪名稱",
"Search or select shop code": "搜尋或選擇店鋪編號",
"Search or select remark": "搜尋或選擇備註",
"Edit shop details": "編輯店鋪詳情",
"Add Shop to Truck Lane": "新增店鋪至卡車路線",
"Truck lane code already exists. Please use a different code.": "卡車路線編號已存在,請使用其他編號。",
"MaintenanceEdit": "編輯維護和保養"
}

+ 17
- 0
src/i18n/zh/inventory.json Zobrazit soubor

@@ -13,14 +13,20 @@
"Stock take record status updated to not match": "盤點記錄狀態更新為數值不符",
"available": "可用",
"Item-lotNo-ExpiryDate": "貨品-批號-到期日",
"Item-lotNo-ExpiryDate": "貨品-批號-到期日",
"not available": "不可用",
"Batch Submit All": "批量提交所有",
"Batch Save All": "批量保存所有",
"Batch Submit All": "批量提交所有",
"Batch Save All": "批量保存所有",
"not match": "數值不符",
"Stock Take Qty(include Bad Qty)= Available Qty": "盤點數(含壞品)= 可用數",
"Stock Take Qty(include Bad Qty)= Available Qty": "盤點數(含壞品)= 可用數",
"View ReStockTake": "查看重新盤點",
"Stock Take Qty": "盤點數",
"Stock Take Qty": "盤點數",
"ReStockTake": "重新盤點",
"Stock Taker": "盤點員",
"Total Item Number": "貨品數量",
@@ -31,6 +37,16 @@
"book qty": "帳面庫存",
"start time": "開始時間",
"end time": "結束時間",
"Control Time": "操作時間",
"Stock Taker": "盤點員",
"Total Item Number": "貨品數量",
"Start Time": "開始時間",
"Difference": "差異",
"stockTaking": "盤點中",
"selected stock take qty": "已選擇盤點數量",
"book qty": "帳面庫存",
"start time": "開始時間",
"end time": "結束時間",
"Only Variance": "僅差異",
"Control Time": "操作時間",
"pass": "通過",
@@ -41,6 +57,7 @@
"Last Stock Take Date": "上次盤點日期",
"Remark": "備註",
"notMatch": "數值不符",
"notMatch": "數值不符",
"Stock take record saved successfully": "盤點記錄保存成功",
"View Details": "查看詳細",
"Input": "輸入",


+ 3
- 1
src/i18n/zh/user.json Zobrazit soubor

@@ -28,5 +28,7 @@
"user": "用戶",
"qrcode": "二維碼",
"staffNo": "員工編號",
"Rows per page": "每頁行數"
"Rows per page": "每頁行數",
"Delete Success": "刪除成功",
"Do you want to delete?": "您確定要刪除嗎?"
}

+ 27
- 0
src/i18n/zh/warehouse.json Zobrazit soubor

@@ -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": "搜尋"
}

Načítá se…
Zrušit
Uložit