diff --git a/src/app/(main)/ps/page.tsx b/src/app/(main)/ps/page.tsx new file mode 100644 index 0000000..fc2a73e --- /dev/null +++ b/src/app/(main)/ps/page.tsx @@ -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([]); + const [selectedLines, setSelectedLines] = useState([]); + const [isDetailOpen, setIsDetailOpen] = useState(false); + const [selectedPs, setSelectedPs] = useState(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 ( + + + {/* Top Header Buttons */} + + + + Production Planning + + + + + + + + + {/* Query Bar */} + + setSearchDate(e.target.value)} + /> + + + + {/* Main Grid Table */} + + + + + Action + ID + Production Date + Est. Prod Count + Total FG Types + + + + {schedules.map((ps) => ( + + + handleViewDetail(ps)}> + + + + #{ps.id} + {formatBackendDate(ps.produceAt)} + {formatNum(ps.totalEstProdCount)} + {formatNum(ps.totalFGType)} + + ))} + +
+
+ + {/* Detailed Lines Dialog */} + setIsDetailOpen(false)} maxWidth="lg" fullWidth> + + + + Schedule Details: {selectedPs?.id} ({formatBackendDate(selectedPs?.produceAt)}) + + + + + + + + Job Order + Item Code + Item Name + Avg Last Month + Stock + Days Left + Batch Need + Prod Qty + Priority + + + + {selectedLines.map((line: any) => ( + + {line.joCode || '-'} + {line.itemCode} + {line.itemName} + {formatNum(line.avgQtyLastMonth)} + {formatNum(line.stockQty)} + + {line.daysLeft} + + {formatNum(line.batchNeed)} + {formatNum(line.prodQty)} + {line.itemPriority} + + ))} + +
+
+
+ + {/* Footer Actions */} + + + + + + + + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/(main)/settings/equipment/page.tsx b/src/app/(main)/settings/equipment/page.tsx index f55631c..3ef292d 100644 --- a/src/app/(main)/settings/equipment/page.tsx +++ b/src/app/(main)/settings/equipment/page.tsx @@ -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")} - }> + }> diff --git a/src/app/(main)/settings/warehouse/create/page.tsx b/src/app/(main)/settings/warehouse/create/page.tsx new file mode 100644 index 0000000..0ee2965 --- /dev/null +++ b/src/app/(main)/settings/warehouse/create/page.tsx @@ -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 ( + <> + {t("Create Warehouse")} + + }> + + + + + ); +}; + +export default CreateWarehousePage; diff --git a/src/app/(main)/settings/warehouse/page.tsx b/src/app/(main)/settings/warehouse/page.tsx new file mode 100644 index 0000000..e008d12 --- /dev/null +++ b/src/app/(main)/settings/warehouse/page.tsx @@ -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 ( + <> + + + {t("Warehouse")} + + + + + }> + + + + + ); +}; +export default Warehouse; diff --git a/src/app/(main)/testing/page.tsx b/src/app/(main)/testing/page.tsx new file mode 100644 index 0000000..4c64f92 --- /dev/null +++ b/src/app/(main)/testing/page.tsx @@ -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 }) => ( + + + + {title} + + {children || Waiting for implementation...} + + + ); + + return ( + + Printer Testing Dashboard + + + {/* 1. TSC Section */} +
+ + setTscConfig({...tscConfig, ip: e.target.value})} /> + setTscConfig({...tscConfig, port: e.target.value})} /> + + + + + + + Code + Name + Lot + Expiry + Action + + + + {tscItems.map(row => ( + + handleItemChange(setTscItems, row.id, 'itemCode', e.target.value)} /> + handleItemChange(setTscItems, row.id, 'itemName', e.target.value)} /> + handleItemChange(setTscItems, row.id, 'lotNo', e.target.value)} /> + handleItemChange(setTscItems, row.id, 'expiryDate', e.target.value)} /> + + + ))} + +
+
+
+ + {/* 2. DataFlex Section */} +
+ + setDfConfig({...dfConfig, ip: e.target.value})} /> + setDfConfig({...dfConfig, port: e.target.value})} /> + + + + + + + Code + Name + Lot + Expiry + Action + + + + {dfItems.map(row => ( + + handleItemChange(setDfItems, row.id, 'itemCode', e.target.value)} /> + handleItemChange(setDfItems, row.id, 'itemName', e.target.value)} /> + handleItemChange(setDfItems, row.id, 'lotNo', e.target.value)} /> + handleItemChange(setDfItems, row.id, 'expiryDate', e.target.value)} /> + + + ))} + +
+
+
+ + {/* 3. OnPack Section */} +
+ + + Calls /plastic/get-printer6 to generate CoLOS .job bundle. + + + +
+ + {/* 4. Laser Section (HANS600S-M) */} +
+ + setLaserConfig({...laserConfig, ip: e.target.value})} /> + setLaserConfig({...laserConfig, port: e.target.value})} /> + + + + + + + + Template + Lot + Exp + Pwr% + Action + + + + {laserItems.map(row => ( + + handleItemChange(setLaserItems, row.id, 'templateId', e.target.value)} /> + handleItemChange(setLaserItems, row.id, 'lotNo', e.target.value)} /> + handleItemChange(setLaserItems, row.id, 'expiryDate', e.target.value)} /> + handleItemChange(setLaserItems, row.id, 'power', e.target.value)} /> + + + + + + + + ))} + +
+
+ + Note: HANS Laser requires pre-saved templates on the controller. + +
+
+ + {/* Dialog for OnPack */} + setIsPrinterModalOpen(false)} fullWidth maxWidth="sm"> + OnPack Printer Job Details + + + setPrinterFormData({ ...printerFormData, itemCode: e.target.value })} /> + setPrinterFormData({ ...printerFormData, lotNo: e.target.value })} /> + setPrinterFormData({ ...printerFormData, productName: e.target.value })} /> + setPrinterFormData({ ...printerFormData, expiryDate: e.target.value })} /> + + + + + + + +
+ ); +} \ No newline at end of file diff --git a/src/app/api/scheduling/actions.ts b/src/app/api/scheduling/actions.ts index 0ed01bb..ca5e48f 100644 --- a/src/app/api/scheduling/actions.ts +++ b/src/app/api/scheduling/actions.ts @@ -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( `${BASE_API_URL}/productionSchedule/detail/detailed/releaseLine`, diff --git a/src/app/api/scheduling/index.ts b/src/app/api/scheduling/index.ts index 4171c56..435bb1e 100644 --- a/src/app/api/scheduling/index.ts +++ b/src/app/api/scheduling/index.ts @@ -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; diff --git a/src/app/api/settings/equipment/client.ts b/src/app/api/settings/equipment/client.ts new file mode 100644 index 0000000..8ab3998 --- /dev/null +++ b/src/app/api/settings/equipment/client.ts @@ -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 }; +}; diff --git a/src/app/api/settings/equipmentDetail/client.ts b/src/app/api/settings/equipmentDetail/client.ts new file mode 100644 index 0000000..8627b52 --- /dev/null +++ b/src/app/api/settings/equipmentDetail/client.ts @@ -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 }; +}; diff --git a/src/app/api/settings/equipmentDetail/index.ts b/src/app/api/settings/equipmentDetail/index.ts new file mode 100644 index 0000000..393442c --- /dev/null +++ b/src/app/api/settings/equipmentDetail/index.ts @@ -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(`${BASE_API_URL}/EquipmentDetail`, { + next: { tags: ["equipmentDetails"] }, + }); +}); + +export const fetchEquipmentDetail = cache(async (id: number) => { + return serverFetchJson( + `${BASE_API_URL}/EquipmentDetail/details/${id}`, + { + next: { tags: ["equipmentDetails"] }, + }, + ); +}); diff --git a/src/app/api/shop/actions.ts b/src/app/api/shop/actions.ts index ab12a8d..a927342 100644 --- a/src/app/api/shop/actions.ts +++ b/src/app/api/shop/actions.ts @@ -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(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(url, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); +}); + +export const updateTruckShopDetailsAction = async (data: UpdateTruckShopDetailsRequest) => { + const endpoint = `${BASE_API_URL}/truck/updateTruckShopDetails`; + + return serverFetchJson(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(endpoint, { method: "POST", body: JSON.stringify(data), headers: { "Content-Type": "application/json" }, }); -}; \ No newline at end of file +}; + +export const findAllUniqueShopNamesAndCodesFromTrucksAction = cache(async () => { + const endpoint = `${BASE_API_URL}/truck/findAllUniqueShopNamesAndCodesFromTrucks`; + + return serverFetchJson>(endpoint, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); +}); + +export const findAllUniqueRemarksFromTrucksAction = cache(async () => { + const endpoint = `${BASE_API_URL}/truck/findAllUniqueRemarksFromTrucks`; + + return serverFetchJson(endpoint, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); +}); + +export const findAllUniqueShopCodesFromTrucksAction = cache(async () => { + const endpoint = `${BASE_API_URL}/truck/findAllUniqueShopCodesFromTrucks`; + + return serverFetchJson(endpoint, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); +}); + +export const findAllUniqueShopNamesFromTrucksAction = cache(async () => { + const endpoint = `${BASE_API_URL}/truck/findAllUniqueShopNamesFromTrucks`; + + return serverFetchJson(endpoint, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); +}); \ No newline at end of file diff --git a/src/app/api/shop/client.ts b/src/app/api/shop/client.ts index 4d046d6..5b9fa87 100644 --- a/src/app/api/shop/client.ts +++ b/src/app/api/shop/client.ts @@ -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 => { - return await updateLoadingSequenceAction(data); +export const findAllByTruckLanceCodeAndDeletedFalseClient = async (truckLanceCode: string) => { + return await findAllByTruckLanceCodeAndDeletedFalseAction(truckLanceCode); +}; + +export const updateTruckShopDetailsClient = async (data: UpdateTruckShopDetailsRequest): Promise => { + return await updateTruckShopDetailsAction(data); +}; + +export const createTruckWithoutShopClient = async (data: CreateTruckWithoutShopRequest): Promise => { + 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; diff --git a/src/app/api/warehouse/actions.ts b/src/app/api/warehouse/actions.ts index d1ea95a..2d7e6ba 100644 --- a/src/app/api/warehouse/actions.ts +++ b/src/app/api/warehouse/actions.ts @@ -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(`${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(`${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( diff --git a/src/app/api/warehouse/index.ts b/src/app/api/warehouse/index.ts index ce560fe..871693b 100644 --- a/src/app/api/warehouse/index.ts +++ b/src/app/api/warehouse/index.ts @@ -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 { diff --git a/src/app/utils/formatUtil.ts b/src/app/utils/formatUtil.ts index 2b3708c..60ecdc6 100644 --- a/src/app/utils/formatUtil.ts +++ b/src/app/utils/formatUtil.ts @@ -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; +}; \ No newline at end of file diff --git a/src/authorities.ts b/src/authorities.ts new file mode 100644 index 0000000..227b0a2 --- /dev/null +++ b/src/authorities.ts @@ -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", +]; diff --git a/src/authorties.ts b/src/authorties.ts deleted file mode 100644 index 0950252..0000000 --- a/src/authorties.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const [VIEW_USER, MAINTAIN_USER, VIEW_GROUP, MAINTAIN_GROUP] = [ - "VIEW_USER", - "MAINTAIN_USER", - "VIEW_GROUP", - "MAINTAIN_GROUP", -]; diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index 23d378d..114f98c 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -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", diff --git a/src/components/CreateWarehouse/CreateWarehouse.tsx b/src/components/CreateWarehouse/CreateWarehouse.tsx new file mode 100644 index 0000000..bbdd858 --- /dev/null +++ b/src/components/CreateWarehouse/CreateWarehouse.tsx @@ -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(); + const router = useRouter(); + const [serverError, setServerError] = useState(""); + + const resetForm = React.useCallback((e?: React.MouseEvent) => { + 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>( + 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>( + (errors) => { + console.log(errors); + }, + [], + ); + + return ( + <> + {serverError && ( + + {serverError} + + )} + + + + + + + + + + + + ); +}; +export default CreateWarehouse; diff --git a/src/components/CreateWarehouse/CreateWarehouseLoading.tsx b/src/components/CreateWarehouse/CreateWarehouseLoading.tsx new file mode 100644 index 0000000..4568c4a --- /dev/null +++ b/src/components/CreateWarehouse/CreateWarehouseLoading.tsx @@ -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 ( + <> + + + + + + + + + + + + ); +}; + +export default CreateWarehouseLoading; diff --git a/src/components/CreateWarehouse/CreateWarehouseWrapper.tsx b/src/components/CreateWarehouse/CreateWarehouseWrapper.tsx new file mode 100644 index 0000000..935b6f8 --- /dev/null +++ b/src/components/CreateWarehouse/CreateWarehouseWrapper.tsx @@ -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 ; +}; + +CreateWarehouseWrapper.Loading = CreateWarehouseLoading; + +export default CreateWarehouseWrapper; diff --git a/src/components/CreateWarehouse/WarehouseDetail.tsx b/src/components/CreateWarehouse/WarehouseDetail.tsx new file mode 100644 index 0000000..d8ba4f8 --- /dev/null +++ b/src/components/CreateWarehouse/WarehouseDetail.tsx @@ -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(); + + return ( + + + + {t("Warehouse Detail")} + + + {/* 樓層 field with F inside on the right - F is automatically generated */} + ( + F + ), + }} + 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} + /> + )} + /> + + - + + {/* 倉庫 field */} + ( + + )} + /> + + - + + {/* 區域 field */} + ( + + )} + /> + + - + + {/* 儲位 field */} + ( + + )} + /> + {/* stockTakeSection field in the same row */} + + + + + + + ); +}; + +export default WarehouseDetail; diff --git a/src/components/CreateWarehouse/index.ts b/src/components/CreateWarehouse/index.ts new file mode 100644 index 0000000..1f233cb --- /dev/null +++ b/src/components/CreateWarehouse/index.ts @@ -0,0 +1 @@ +export { default } from "./CreateWarehouseWrapper"; diff --git a/src/components/DetailedSchedule/DetailedScheduleSearchView.tsx b/src/components/DetailedSchedule/DetailedScheduleSearchView.tsx index 2b53dd9..ddccc5a 100644 --- a/src/components/DetailedSchedule/DetailedScheduleSearchView.tsx +++ b/src/components/DetailedSchedule/DetailedScheduleSearchView.tsx @@ -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 = ({ 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 = ({ 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 = ({ type, defaultInputs }) => { > {t("Export Schedule")} + + {false && abilities.includes(VIEW_USER) && ( + + )} = ({ equipments, tabIndex = 0 }) => { useState([]); const { t } = useTranslation("common"); const router = useRouter(); - const [filterObj, setFilterObj] = useState({}); - const [pagingController, setPagingController] = useState({ - pageNum: 1, - pageSize: 10, + const [filterObjByTab, setFilterObjByTab] = useState>({ + 0: {}, + 1: {}, + }); + const [pagingControllerByTab, setPagingControllerByTab] = useState>({ + 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>(new Set()); + const [equipmentDetailsMap, setEquipmentDetailsMap] = useState>(new Map()); + const [loadingDetailsMap, setLoadingDetailsMap] = useState>(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([]); + const [selectedDescription, setSelectedDescription] = useState(""); + const [selectedName, setSelectedName] = useState(""); + const [selectedEquipmentCode, setSelectedEquipmentCode] = useState(""); + const [equipmentCodePrefix, setEquipmentCodePrefix] = useState(""); + const [equipmentCodeNumber, setEquipmentCodeNumber] = useState(""); + 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 = ({ 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 = ({ 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[]>( () => [ - { - name: "id", - label: t("Details"), - onClick: onDetailClick, - buttonIcon: , - }, { name: "code", - label: t("Code"), - }, - { - name: "description", - label: t("Description"), - }, - { - name: "equipmentTypeId", - label: t("Equipment Type"), - }, - { - name: "action", - label: t(""), - buttonIcon: , - onClick: onDeleteClick, + label: "設備編號", + renderCell: (item) => ( + + { + e.stopPropagation(); + handleToggleExpand(item.id, item.code); + }} + sx={{ padding: 0.5 }} + > + {expandedRows.has(item.id) ? ( + + ) : ( + + )} + + {item.code} + + ), }, ], - [onDetailClick, onDeleteClick, t], + [t, handleToggleExpand, expandedRows], ); const repairMaintenanceColumns = useMemo[]>( @@ -250,8 +541,6 @@ const EquipmentSearch: React.FC = ({ 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 = ({ 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( + `${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( + `${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 ( + + + + + {isLoading ? ( + + + 載入中... + + ) : details.length === 0 ? ( + 無相關設備詳細資料 + ) : ( + + + 設備詳細資料 (設備編號: {item.code}) + + + {details.map((detail) => ( + + + + + 編號: {detail.code || "-"} + + handleDeleteClick(detail.id, item.id)} + sx={{ ml: 1 }} + > + + + + {detail.name && ( + + 名稱: {detail.name} + + )} + {detail.description && ( + + 描述: {detail.description} + + )} + {detail.equipmentCode && ( + + 設備編號: {detail.equipmentCode} + + )} + + + ))} + + + )} + + + + + ); + }, [columns.length, equipmentDetailsMap, loadingDetailsMap, expandedRows, tabIndex, handleDeleteClick]); return ( <> { - setFilterObj({ - ...query, + setFilterObjByTab(prev => { + const newState = { ...prev }; + newState[tabIndex] = query as unknown as SearchQuery; + return newState; }); }} onReset={onReset} /> + {tabIndex === 0 && ( + + + 設備編號 + + + + )} = ({ equipments, tabIndex = 0 }) => { 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} - /> - - - ); -}; + renderExpandedRow={renderExpandedRow} + hideHeader={tabIndex === 0} + /> + + + {/* Delete Confirmation Dialog */} + {deleteDialogOpen && ( + + + 確認刪除 + + + + 您確定要刪除此設備詳細資料嗎?此操作無法復原。 + + + + + + + + )} -export default EquipmentSearch; \ No newline at end of file + {/* Add Equipment Detail Dialog */} + + + 新增設備詳細資料 + + + + { + setSelectedDescription(newValue || ''); + }} + onInputChange={(event, newInputValue) => { + setSelectedDescription(newInputValue); + }} + loading={loadingEquipments} + disabled={loadingEquipments || saving} + renderInput={(params) => ( + + )} + sx={{ mb: 2 }} + /> + { + 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) => ( + + )} + /> + + { + 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 ? ( + + + {equipmentCodeNumber} + + + ) : 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))} + /> + + + + + + + + + + ); + }; + + export default EquipmentSearch; \ No newline at end of file diff --git a/src/components/EquipmentSearch/EquipmentSearchLoading.tsx b/src/components/EquipmentSearch/EquipmentSearchLoading.tsx index 838189b..100feb0 100644 --- a/src/components/EquipmentSearch/EquipmentSearchLoading.tsx +++ b/src/components/EquipmentSearch/EquipmentSearchLoading.tsx @@ -1,3 +1,5 @@ +"use client"; + import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; import Skeleton from "@mui/material/Skeleton"; diff --git a/src/components/EquipmentSearch/EquipmentSearchResults.tsx b/src/components/EquipmentSearch/EquipmentSearchResults.tsx index 7f84a41..d35b83b 100644 --- a/src/components/EquipmentSearch/EquipmentSearchResults.tsx +++ b/src/components/EquipmentSearch/EquipmentSearchResults.tsx @@ -48,6 +48,7 @@ interface BaseColumn { style?: Partial & { [propName: string]: string }; type?: ColumnType; renderCell?: (params: T) => React.ReactNode; + renderHeader?: () => React.ReactNode; } interface IconColumn extends BaseColumn { @@ -104,6 +105,8 @@ interface Props { checkboxIds?: (string | number)[]; setCheckboxIds?: Dispatch>; onRowClick?: (item: T) => void; + renderExpandedRow?: (item: T) => React.ReactNode; + hideHeader?: boolean; } function isActionColumn( @@ -197,6 +200,8 @@ function EquipmentSearchResults({ checkboxIds = [], setCheckboxIds = undefined, onRowClick = undefined, + renderExpandedRow = undefined, + hideHeader = false, }: Props) { const { t } = useTranslation("common"); const [page, setPage] = React.useState(0); @@ -303,35 +308,41 @@ function EquipmentSearchResults({ const table = ( <> - - - - {columns.map((column, idx) => ( - isCheckboxColumn(column) ? - - 0 && currItemsWithChecked.length < currItems.length} - checked={currItems.length > 0 && currItemsWithChecked.length >= currItems.length} - onChange={handleSelectAllClick} - /> - - : - {column.label.split('\n').map((line, index) => ( -
{line}
// Render each line in a div - ))} -
- ))} -
-
+
+ {!hideHeader && ( + + + {columns.map((column, idx) => ( + isCheckboxColumn(column) ? + + 0 && currItemsWithChecked.length < currItems.length} + checked={currItems.length > 0 && currItemsWithChecked.length >= currItems.length} + onChange={handleSelectAllClick} + /> + + : + {column.renderHeader ? ( + column.renderHeader() + ) : ( + column.label.split('\n').map((line, index) => ( +
{line}
// Render each line in a div + )) + )} +
+ ))} +
+
+ )} {isAutoPaging ? items @@ -339,10 +350,45 @@ function EquipmentSearchResults({ (pagingController?.pageNum ? pagingController?.pageNum - 1 :page) * (pagingController?.pageSize ?? rowsPerPage) + (pagingController?.pageSize ?? rowsPerPage)) .map((item) => { return ( - + { + setCheckboxIds + ? handleRowClick(event, item, columns) + : undefined + + if (onRowClick) { + onRowClick(item) + } + } + } + role={setCheckboxIds ? "checkbox" : undefined} + > + {columns.map((column, idx) => { + const columnName = column.name; + + return ( + + ); + })} + + {renderExpandedRow && renderExpandedRow(item)} + + ); + }) + : items.map((item) => { + return ( + + { setCheckboxIds ? handleRowClick(event, item, columns) @@ -370,38 +416,8 @@ function EquipmentSearchResults({ ); })} - ); - }) - : items.map((item) => { - return ( - { - setCheckboxIds - ? handleRowClick(event, item, columns) - : undefined - - if (onRowClick) { - onRowClick(item) - } - } - } - role={setCheckboxIds ? "checkbox" : undefined} - > - {columns.map((column, idx) => { - const columnName = column.name; - - return ( - - ); - })} - + {renderExpandedRow && renderExpandedRow(item)} + ); })} diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 861e922..252b803 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -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: , @@ -113,49 +136,11 @@ const NavigationContent: React.FC = () => { }, ], }, - // { - // icon: , - // label: "Production", - // path: "", - // children: [ - // { - // icon: , - // label: "Job Order", - // path: "", - // }, - // { - // icon: , - // label: "Job Order Traceablity ", - // path: "", - // }, - // { - // icon: , - // label: "Work Order", - // path: "", - // }, - // { - // icon: , - // label: "Work Order Traceablity ", - // path: "", - // }, - // ], - // }, - // { - // icon: , - // label: "Quality Control Log", - // path: "", - // children: [ - // { - // icon: , - // label: "Quality Control Log", - // path: "", - // }, - // ], - // }, { icon: , label: "Delivery", path: "", + //requiredAbility: VIEW_DO, children: [ { icon: , @@ -249,20 +234,37 @@ const NavigationContent: React.FC = () => { }, ], }, + { + icon: , + label: "PS", + path: "/ps", + requiredAbility: TESTING, + isHidden: false, + }, + { + icon: , + label: "Printer Testing", + path: "/testing", + requiredAbility: TESTING, + isHidden: false, + }, { icon: , label: "Settings", path: "", + requiredAbility: [VIEW_USER, VIEW_GROUP], children: [ { icon: , label: "User", path: "/settings/user", + requiredAbility: VIEW_USER, }, { icon: , label: "User Group", path: "/settings/user", + requiredAbility: VIEW_GROUP, }, // { // icon: , @@ -302,7 +304,7 @@ const NavigationContent: React.FC = () => { { icon: , label: "Warehouse", - path: "/settings/user", + path: "/settings/warehouse", }, { icon: , @@ -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 ( { {item.icon} - {item.children && isOpen && ( + {item.children && isOpen && hasVisibleChildren && ( {item.children.map( (child) => !child.isHidden && renderNavigationItem(child), @@ -392,6 +399,10 @@ const NavigationContent: React.FC = () => { ); }; + if (status === "loading") { + return Loading...; + } + return ( @@ -402,7 +413,10 @@ const NavigationContent: React.FC = () => { - {navigationItems.map((item) => renderNavigationItem(item))} + {navigationItems + .filter(item => !item.isHidden) + .map(renderNavigationItem) + .filter(Boolean)} {/* {navigationItems.map(({ icon, label, path }, index) => { return ( { interface TextCriterion extends BaseCriterion { type: "text"; + placeholder?: string; } interface SelectCriterion extends BaseCriterion { @@ -286,6 +287,7 @@ function SearchBox({ @@ -306,7 +308,7 @@ function SearchBox({ {t("All")} {c.options.map((option) => ( diff --git a/src/components/Shop/Shop.tsx b/src/components/Shop/Shop.tsx index 7c827ab..07b07cb 100644 --- a/src/components/Shop/Shop.tsx +++ b/src/components/Shop/Shop.tsx @@ -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(0); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(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) => ( + ); } @@ -493,7 +439,7 @@ const ShopDetail: React.FC = () => { {t("Shop not found")} - + ); } @@ -504,7 +450,7 @@ const ShopDetail: React.FC = () => { {t("Shop Information")} - + @@ -682,22 +628,13 @@ const ShopDetail: React.FC = () => { ) : ( - (() => { - 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) )} {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 ( diff --git a/src/components/Shop/TruckLane.tsx b/src/components/Shop/TruckLane.tsx index dd29e6a..efe0bc5 100644 --- a/src/components/Shop/TruckLane.tsx +++ b/src/components/Shop/TruckLane.tsx @@ -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>({}); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(10); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [newTruck, setNewTruck] = useState({ + truckLanceCode: "", + departureTime: "", + storeId: "2F", + }); + const [saving, setSaving] = useState(false); + const [snackbarOpen, setSnackbarOpen] = useState(false); + const [snackbarMessage, setSnackbarMessage] = useState(""); 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(); + 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 ( @@ -198,16 +273,34 @@ const TruckLane: React.FC = () => { - {t("Truck Lane")} + + {t("Truck Lane")} + +
- {t("TruckLance Code")} - {t("Departure Time")} - {t("Store ID")} - {t("Actions")} + + {t("TruckLance Code")} + + + {t("Departure Time")} + + + {t("Store ID")} + + + {t("Actions")} + @@ -220,40 +313,36 @@ const TruckLane: React.FC = () => { ) : ( - 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 ( - - - {String(truck.truckLanceCode || "-")} - - - {formatDepartureTime( - Array.isArray(truck.departureTime) - ? truck.departureTime - : (truck.departureTime ? String(truck.departureTime) : null) - )} - - - {displayStoreId} - - - - - - ); - }) + paginatedRows.map((truck) => ( + + + {String(truck.truckLanceCode ?? "-")} + + + {formatDepartureTime( + Array.isArray(truck.departureTime) + ? truck.departureTime + : (truck.departureTime ? String(truck.departureTime) : null) + )} + + + {normalizeStoreId( + truck.storeId ? (typeof truck.storeId === 'string' || truck.storeId instanceof String + ? String(truck.storeId) + : String(truck.storeId)) : null + )} + + + + + + )) )}
@@ -269,6 +358,92 @@ const TruckLane: React.FC = () => {
+ + {/* Add Truck Dialog */} + + {t("Add New Truck Lane")} + + + + + setNewTruck({ ...newTruck, truckLanceCode: e.target.value })} + disabled={saving} + /> + + + setNewTruck({ ...newTruck, departureTime: e.target.value })} + disabled={saving} + InputLabelProps={{ + shrink: true, + }} + inputProps={{ + step: 300, // 5 minutes + }} + /> + + + + {t("Store ID")} + + + + + + + + + + + + + {/* Snackbar for notifications */} + setSnackbarOpen(false)} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + > + setSnackbarOpen(false)} + severity="warning" + sx={{ width: '100%' }} + > + {snackbarMessage} + + ); }; diff --git a/src/components/Shop/TruckLaneDetail.tsx b/src/components/Shop/TruckLaneDetail.tsx index 7b37704..21b5536 100644 --- a/src/components/Shop/TruckLaneDetail.tsx +++ b/src/components/Shop/TruckLaneDetail.tsx @@ -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(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const [allShops, setAllShops] = useState([]); + const [uniqueRemarks, setUniqueRemarks] = useState([]); + const [uniqueShopCodes, setUniqueShopCodes] = useState([]); + const [uniqueShopNames, setUniqueShopNames] = useState([]); + const [addShopDialogOpen, setAddShopDialogOpen] = useState(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>, + findAllUniqueRemarksFromTrucksClient() as Promise, + findAllUniqueShopCodesFromTrucksClient() as Promise, + findAllUniqueShopNamesFromTrucksClient() as Promise, + ]); + + // 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 ( @@ -323,7 +638,7 @@ const TruckLaneDetail: React.FC = () => { - + {t("TruckLance Code")} @@ -332,7 +647,7 @@ const TruckLaneDetail: React.FC = () => { - + {t("Departure Time")} @@ -345,7 +660,7 @@ const TruckLaneDetail: React.FC = () => { - + {t("Store ID")} @@ -361,9 +676,19 @@ const TruckLaneDetail: React.FC = () => { - - {t("Shops Using This Truck Lane")} - + + + {t("Shops Using This Truck Lane")} + + + {shopsLoading ? ( @@ -394,13 +719,144 @@ const TruckLaneDetail: React.FC = () => { shopsData.map((shop, index) => ( - {String(shop.name || "-")} + {editingRowIndex === index ? ( + { + 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) => ( + + )} + /> + ) : ( + String(shop.name || "-") + )} - {String(shop.code || "-")} + {editingRowIndex === index ? ( + { + 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) => ( + + )} + /> + ) : ( + String(shop.code || "-") + )} - {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 ( + { + 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) => ( + + )} + /> + ); + })() + ) : ( + String(shop.remark || "-") + )} {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")} > @@ -482,6 +938,117 @@ const TruckLaneDetail: React.FC = () => { + {/* Add Shop Dialog */} + + {t("Add Shop to Truck Lane")} + + + + + { + handleNewShopNameChange(newValue); + }} + onInputChange={(event, newInputValue, reason) => { + if (reason === 'input') { + // Allow free text input for shop name + setNewShop({ ...newShop, shopName: newInputValue }); + } + }} + renderInput={(params) => ( + + )} + /> + + + { + handleNewShopCodeChange(newValue); + }} + renderInput={(params) => ( + + )} + /> + + + setNewShop({ ...newShop, loadingSequence: parseInt(e.target.value) || 0 })} + disabled={saving} + /> + + {(() => { + 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 ? ( + + { + setNewShop({ ...newShop, remark: typeof newValue === 'string' ? newValue : (newValue || "") }); + }} + onInputChange={(event, newInputValue, reason) => { + if (reason === 'input') { + setNewShop({ ...newShop, remark: newInputValue }); + } + }} + renderInput={(params) => ( + + )} + /> + + ) : null; + })()} + + + + + + + + + { ); }; -export default TruckLaneDetail; - - +export default TruckLaneDetail; \ No newline at end of file diff --git a/src/components/WarehouseHandle/WarehouseHandle.tsx b/src/components/WarehouseHandle/WarehouseHandle.tsx new file mode 100644 index 0000000..453de68 --- /dev/null +++ b/src/components/WarehouseHandle/WarehouseHandle.tsx @@ -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>; +type SearchParamNames = keyof SearchQuery; + +const WarehouseHandle: React.FC = ({ 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[]>( + () => [ + { + 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: , + color: "error", + sx: { width: "10%", minWidth: "80px" }, + }, + ], + [t, onDeleteClick], + ); + + return ( + <> + + + {t("Search Criteria")} + + {/* 樓層 field with F inside on the right */} + + setSearchInputs((prev) => ({ ...prev, store_id: e.target.value })) + } + size="small" + sx={{ width: "150px", minWidth: "120px" }} + InputProps={{ + endAdornment: ( + F + ), + }} + /> + + - + + {/* 倉庫 field */} + + setSearchInputs((prev) => ({ ...prev, warehouse: e.target.value })) + } + size="small" + sx={{ width: "150px", minWidth: "120px" }} + /> + + - + + {/* 區域 field */} + + setSearchInputs((prev) => ({ ...prev, area: e.target.value })) + } + size="small" + sx={{ width: "150px", minWidth: "120px" }} + /> + + - + + {/* 儲位 field */} + + setSearchInputs((prev) => ({ ...prev, slot: e.target.value })) + } + size="small" + sx={{ width: "150px", minWidth: "120px" }} + /> + {/* 盤點區域 field */} + + + setSearchInputs((prev) => ({ ...prev, stockTakeSection: e.target.value })) + } + size="small" + fullWidth + /> + + + + + + + + + + items={filteredWarehouse} + columns={columns} + pagingController={pagingController} + setPagingController={setPagingController} + /> + + ); +}; +export default WarehouseHandle; diff --git a/src/components/WarehouseHandle/WarehouseHandleLoading.tsx b/src/components/WarehouseHandle/WarehouseHandleLoading.tsx new file mode 100644 index 0000000..7111407 --- /dev/null +++ b/src/components/WarehouseHandle/WarehouseHandleLoading.tsx @@ -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 ( + <> + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default WarehouseHandleLoading; diff --git a/src/components/WarehouseHandle/WarehouseHandleWrapper.tsx b/src/components/WarehouseHandle/WarehouseHandleWrapper.tsx new file mode 100644 index 0000000..e33d47e --- /dev/null +++ b/src/components/WarehouseHandle/WarehouseHandleWrapper.tsx @@ -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 ; +}; + +WarehouseHandleWrapper.Loading = WarehouseHandleLoading; + +export default WarehouseHandleWrapper; diff --git a/src/components/WarehouseHandle/index.ts b/src/components/WarehouseHandle/index.ts new file mode 100644 index 0000000..ac4bf97 --- /dev/null +++ b/src/components/WarehouseHandle/index.ts @@ -0,0 +1 @@ +export { default } from "./WarehouseHandleWrapper"; diff --git a/src/components/qrCodeHandles/qrCodeHandleEquipmentSearch.tsx b/src/components/qrCodeHandles/qrCodeHandleEquipmentSearch.tsx index 87d5df1..212a28e 100644 --- a/src/components/qrCodeHandles/qrCodeHandleEquipmentSearch.tsx +++ b/src/components/qrCodeHandles/qrCodeHandleEquipmentSearch.tsx @@ -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>; +type SearchQuery = Partial>; type SearchParamNames = keyof SearchQuery; -const QrCodeHandleEquipmentSearch: React.FC = ({ equipments }) => { - const [filteredEquipments, setFilteredEquipments] = - useState([]); +const QrCodeHandleEquipmentSearch: React.FC = ({ equipmentDetails, printerCombo }) => { const { t } = useTranslation("common"); + const [filteredEquipmentDetails, setFilteredEquipmentDetails] = useState([]); 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[] = useMemo(() => { - const searchCriteria: Criterion[] = [ - { 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>(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(null); + + const [selectedEquipmentDetailsModalOpen, setSelectedEquipmentDetailsModalOpen] = useState(false); + + const filteredPrinters = useMemo(() => { + return printerCombo.filter((printer) => { + return printer.type === "A4"; + }); + }, [printerCombo]); + + const [selectedPrinter, setSelectedPrinter] = useState( + filteredPrinters.length > 0 ? filteredPrinters[0] : undefined ); - const columns = useMemo[]>( + useEffect(() => { + if (!selectedPrinter || !filteredPrinters.find(p => p.id === selectedPrinter.id)) { + setSelectedPrinter(filteredPrinters.length > 0 ? filteredPrinters[0] : undefined); + } + }, [filteredPrinters, selectedPrinter]); + + const searchCriteria: Criterion[] = useMemo( () => [ { - name: "id", - label: t("Details"), - onClick: onDetailClick, - buttonIcon: , - }, - { - 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: , - onClick: onDeleteClick, + label: "設備編號", + paramName: "equipmentCode", + type: "text", }, ], - [filteredEquipments], + [], ); interface ApiResponse { @@ -101,20 +119,19 @@ const QrCodeHandleEquipmentSearch: React.FC = ({ equipments }) => { ...filterObj, }; try { - const response = await axiosInstance.get>( - `${NEXT_PUBLIC_API_URL}/Equipment/getRecordByPage`, + const response = await axiosInstance.get>( + `${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 = ({ 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 => { + 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>( + `${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[]>( + () => [ + { + name: "id", + label: "", + sx: { width: "50px", minWidth: "50px" }, + renderCell: (params) => ( + 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 = ({ equipments }) => { setFilterObj({ ...query, }); + setPagingController({ pageNum: 1, pageSize: 10 }); }} onReset={onReset} /> - - items={filteredEquipments} + + items={filteredEquipmentDetails} columns={columns} - setPagingController={setPagingController} pagingController={pagingController} + setPagingController={setPagingController} totalCount={totalCount} isAutoPaging={false} /> + + + + + + + + + + 已選擇設備 ({selectedEquipmentDetails.length}) + + + + + + + + + + + + + 設備名稱 + + + 設備描述 + + + 設備編號 + + + + + {selectedEquipmentDetails.length === 0 ? ( + + + 沒有選擇的設備 + + + ) : ( + selectedEquipmentDetails.map((equipmentDetail) => ( + + {equipmentDetail.code || '-'} + {equipmentDetail.description || '-'} + {equipmentDetail.equipmentCode || '-'} + + )) + )} + +
+
+
+ + + + + options={filteredPrinters} + value={selectedPrinter ?? null} + onChange={(event, value) => { + setSelectedPrinter(value ?? undefined); + }} + getOptionLabel={(option) => option.name || option.label || option.code || String(option.id)} + renderInput={(params) => ( + + )} + /> + { + const value = parseInt(e.target.value) || 1; + setPrintQty(Math.max(1, value)); + }} + inputProps={{ min: 1 }} + sx={{ width: 120 }} + /> + + + + +
+
+ + + + + + + + + + + {previewUrl && ( +