diff --git a/server/index.js b/server/index.js index ea9aa72..4469bc0 100644 --- a/server/index.js +++ b/server/index.js @@ -128,8 +128,30 @@ const initTables = async () => { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `; await db.query(assetTable); + await db.query(assetTable); await db.query(maintenanceTable); + // Check/Add 'quantity' column to assets + const [assetCols] = await db.query("SHOW COLUMNS FROM assets LIKE 'quantity'"); + if (assetCols.length === 0) { + await db.query("ALTER TABLE assets ADD COLUMN quantity INT DEFAULT 1"); + console.log("✅ Added 'quantity' column to assets"); + } + + // Create maintenance_parts table + const maintenancePartsTable = ` + CREATE TABLE IF NOT EXISTS maintenance_parts ( + id INT AUTO_INCREMENT PRIMARY KEY, + maintenance_id INT NOT NULL, + part_id VARCHAR(20) NOT NULL, + quantity INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (maintenance_id) REFERENCES maintenance_history(id) ON DELETE CASCADE, + FOREIGN KEY (part_id) REFERENCES assets(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + `; + await db.query(maintenancePartsTable); + const [usersTableExists] = await db.query("SHOW TABLES LIKE 'users'"); if (usersTableExists.length === 0) { const usersTableSQL = ` @@ -302,8 +324,10 @@ app.use('/api', authRoutes); app.use('/api/cameras', cameraRoutes); app.use('/api/system', require('./routes/system')); +const { requireModule } = require('./middleware/licenseMiddleware'); + // Protect following routes -app.use(['/api/assets', '/api/maintenance', '/api/manuals', '/api/upload'], isAuthenticated); +app.use(['/api/assets', '/api/maintenance', '/api/manuals', '/api/upload'], isAuthenticated, requireModule('asset')); // 0. File Upload Endpoint app.post('/api/upload', upload.single('image'), (req, res) => { @@ -316,177 +340,21 @@ app.post('/api/upload', upload.single('image'), (req, res) => { res.json({ url: fileUrl }); }); -// 1. Get All Assets -app.get('/api/assets', async (req, res) => { - try { - const [rows] = await db.query('SELECT * FROM assets ORDER BY created_at DESC'); - res.json(rows); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Database error' }); - } -}); +// 1. Get All Assets (Moved to modules/asset/routes.js) +// 2. Get Asset by ID (Moved to modules/asset/routes.js) +// 3. Create Asset (Moved to modules/asset/routes.js) +// 4. Update Asset (Moved to modules/asset/routes.js) +// 5. Get All Maintenance Records for an Asset (Moved to modules/asset/routes.js) +// 6. Get Single Maintenance Record by ID (Moved to modules/asset/routes.js) +// 7. Create Maintenance Record (Moved to modules/asset/routes.js) +// 8. Update Maintenance Record (Moved to modules/asset/routes.js) +// 9. Delete Maintenance Record (Moved to modules/asset/routes.js) +// 10. Get Manuals for an Asset (Moved to modules/asset/routes.js) +// 11. Add Manual to Asset (Moved to modules/asset/routes.js) +// 12. Delete Manual (Moved to modules/asset/routes.js) -// 2. Get Asset by ID -app.get('/api/assets/:id', async (req, res) => { - try { - const [rows] = await db.query('SELECT * FROM assets WHERE id = ?', [req.params.id]); - if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' }); - res.json(rows[0]); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Database error' }); - } -}); - -// 3. Create Asset -app.post('/api/assets', async (req, res) => { - const { id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url } = req.body; - - try { - const sql = ` - INSERT INTO assets (id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `; - await db.query(sql, [id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url]); - res.status(201).json({ message: 'Asset created', id }); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Database error' }); - } -}); - -// 4. Update Asset -app.put('/api/assets/:id', async (req, res) => { - const { name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url } = req.body; - - try { - const sql = ` - UPDATE assets - SET name=?, category=?, model_name=?, serial_number=?, manufacturer=?, location=?, purchase_date=?, manager=?, status=?, specs=?, purchase_price=?, image_url=? - WHERE id=? - `; - const [result] = await db.query(sql, [name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, req.params.id]); - - if (result.affectedRows === 0) return res.status(404).json({ error: 'Asset not found' }); - res.json({ message: 'Asset updated' }); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Database error' }); - } -}); - -// 5. Get All Maintenance Records for an Asset -app.get('/api/assets/:asset_id/maintenance', async (req, res) => { - try { - const [rows] = await db.query('SELECT * FROM maintenance_history WHERE asset_id = ? ORDER BY maintenance_date DESC', [req.params.asset_id]); - res.json(rows); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Database error' }); - } -}); - -// 6. Get Single Maintenance Record by ID -app.get('/api/maintenance/:id', async (req, res) => { - try { - const [rows] = await db.query('SELECT * FROM maintenance_history WHERE id = ?', [req.params.id]); - if (rows.length === 0) return res.status(404).json({ error: 'Maintenance record not found' }); - res.json(rows[0]); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Database error' }); - } -}); - -// 7. Create Maintenance Record -app.post('/api/assets/:asset_id/maintenance', async (req, res) => { - const { maintenance_date, type, content, images } = req.body; - const { asset_id } = req.params; - - try { - const sql = ` - INSERT INTO maintenance_history (asset_id, maintenance_date, type, content, images) - VALUES (?, ?, ?, ?, ?) - `; - const [result] = await db.query(sql, [asset_id, maintenance_date, type, content, JSON.stringify(images || [])]); - res.status(201).json({ message: 'Maintenance record created', id: result.insertId }); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Database error' }); - } -}); - -// 8. Update Maintenance Record -app.put('/api/maintenance/:id', async (req, res) => { - const { maintenance_date, type, content, images } = req.body; - - try { - const sql = ` - UPDATE maintenance_history - SET maintenance_date=?, type=?, content=?, images=? - WHERE id=? - `; - const [result] = await db.query(sql, [maintenance_date, type, content, JSON.stringify(images || []), req.params.id]); - - if (result.affectedRows === 0) return res.status(404).json({ error: 'Maintenance record not found' }); - res.json({ message: 'Maintenance record updated' }); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Database error' }); - } -}); - -// 9. Delete Maintenance Record -app.delete('/api/maintenance/:id', async (req, res) => { - try { - const [result] = await db.query('DELETE FROM maintenance_history WHERE id = ?', [req.params.id]); - - if (result.affectedRows === 0) return res.status(404).json({ error: 'Maintenance record not found' }); - res.json({ message: 'Maintenance record deleted' }); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Database error' }); - } -}); - -// 10. Get Manuals for an Asset -app.get('/api/assets/:asset_id/manuals', async (req, res) => { - try { - const [rows] = await db.query('SELECT * FROM asset_manuals WHERE asset_id = ? ORDER BY created_at DESC', [req.params.asset_id]); - res.json(rows); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Database error' }); - } -}); - -// 11. Add Manual to Asset -app.post('/api/assets/:asset_id/manuals', async (req, res) => { - const { file_name, file_url } = req.body; - const { asset_id } = req.params; - - try { - const sql = `INSERT INTO asset_manuals (asset_id, file_name, file_url) VALUES (?, ?, ?)`; - const [result] = await db.query(sql, [asset_id, file_name, file_url]); - res.status(201).json({ message: 'Manual added', id: result.insertId }); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Database error' }); - } -}); - -// 12. Delete Manual -app.delete('/api/manuals/:id', async (req, res) => { - try { - const [result] = await db.query('DELETE FROM asset_manuals WHERE id = ?', [req.params.id]); - if (result.affectedRows === 0) return res.status(404).json({ error: 'Manual not found' }); - res.json({ message: 'Manual deleted' }); - } catch (err) { - console.error(err); - res.status(500).json({ error: 'Database error' }); - } -}); +// Mount Asset Module +app.use('/api', require('./modules/asset/routes')); // Serve Frontend Static Files (Production/Deployment) app.use(express.static(path.join(__dirname, '../dist'))); diff --git a/server/middleware/licenseMiddleware.js b/server/middleware/licenseMiddleware.js new file mode 100644 index 0000000..2f563cc --- /dev/null +++ b/server/middleware/licenseMiddleware.js @@ -0,0 +1,42 @@ +const db = require('../db'); + +/** + * Middleware to check if a module is active. + * @param {string} moduleCode - Code of the module to check (e.g., 'monitoring', 'asset', 'production') + */ +const requireModule = (moduleCode) => { + return async (req, res, next) => { + try { + const [rows] = await db.query( + 'SELECT is_active, expiry_date FROM system_modules WHERE code = ?', + [moduleCode] + ); + + if (rows.length === 0 || !rows[0].is_active) { + return res.status(403).json({ + error: 'Module Not Active', + message: `The '${moduleCode}' module is not active. Please activate a license first.` + }); + } + + const moduleData = rows[0]; + if (moduleData.expiry_date) { + const now = new Date(); + const expiry = new Date(moduleData.expiry_date); + if (now > expiry) { + return res.status(403).json({ + error: 'License Expired', + message: `The license for '${moduleCode}' has expired on ${moduleData.expiry_date}.` + }); + } + } + + next(); + } catch (err) { + console.error('[License Check Error]', err); + res.status(500).json({ error: 'Internal Server Error' }); + } + }; +}; + +module.exports = { requireModule }; diff --git a/server/modules/asset/routes.js b/server/modules/asset/routes.js new file mode 100644 index 0000000..5713ce6 --- /dev/null +++ b/server/modules/asset/routes.js @@ -0,0 +1,282 @@ +const express = require('express'); +const router = express.Router(); +const db = require('../../db'); +const { hasRole } = require('../../middleware/authMiddleware'); + +// ========================================== +// 1. Asset Management +// ========================================== + +// Get All Assets +router.get('/assets', async (req, res) => { + try { + const [rows] = await db.query('SELECT * FROM assets ORDER BY created_at DESC'); + res.json(rows); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// Get Asset by ID +router.get('/assets/:id', async (req, res) => { + try { + const [rows] = await db.query('SELECT * FROM assets WHERE id = ?', [req.params.id]); + if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' }); + res.json(rows[0]); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// Create Asset +router.post('/assets', async (req, res) => { + const { id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url } = req.body; + + try { + const sql = ` + INSERT INTO assets (id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `; + await db.query(sql, [id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url]); + res.status(201).json({ message: 'Asset created', id }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// Update Asset +router.put('/assets/:id', async (req, res) => { + const { name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url } = req.body; + + try { + const sql = ` + UPDATE assets + SET name=?, category=?, model_name=?, serial_number=?, manufacturer=?, location=?, purchase_date=?, manager=?, status=?, specs=?, purchase_price=?, image_url=? + WHERE id=? + `; + const [result] = await db.query(sql, [name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, req.params.id]); + + if (result.affectedRows === 0) return res.status(404).json({ error: 'Asset not found' }); + res.json({ message: 'Asset updated' }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// ========================================== +// 2. Maintenance History +// ========================================== + +// Get All Maintenance Records for an Asset +router.get('/assets/:asset_id/maintenance', async (req, res) => { + try { + const [rows] = await db.query('SELECT * FROM maintenance_history WHERE asset_id = ? ORDER BY maintenance_date DESC', [req.params.asset_id]); + res.json(rows); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// Get Single Maintenance Record by ID +router.get('/maintenance/:id', async (req, res) => { + try { + const [rows] = await db.query('SELECT * FROM maintenance_history WHERE id = ?', [req.params.id]); + if (rows.length === 0) return res.status(404).json({ error: 'Maintenance record not found' }); + + const record = rows[0]; + + // Fetch used parts + const [parts] = await db.query(` + SELECT mp.*, a.name as part_name, a.model_name + FROM maintenance_parts mp + JOIN assets a ON mp.part_id = a.id + WHERE mp.maintenance_id = ? + `, [record.id]); + + record.parts = parts; + res.json(record); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// Create Maintenance Record +router.post('/assets/:asset_id/maintenance', async (req, res) => { + const { maintenance_date, type, content, images, parts } = req.body; // parts: [{ part_id, quantity }] + const { asset_id } = req.params; + + const connection = await db.getConnection(); + + try { + await connection.beginTransaction(); + + const sql = ` + INSERT INTO maintenance_history (asset_id, maintenance_date, type, content, images) + VALUES (?, ?, ?, ?, ?) + `; + const [result] = await connection.query(sql, [asset_id, maintenance_date, type, content, JSON.stringify(images || [])]); + const maintenanceId = result.insertId; + + if (parts && Array.isArray(parts) && parts.length > 0) { + for (const part of parts) { + // 1. Deduct quantity from assets + await connection.query('UPDATE assets SET quantity = quantity - ? WHERE id = ?', [part.quantity, part.part_id]); + + // 2. Add to maintenance_parts + await connection.query( + 'INSERT INTO maintenance_parts (maintenance_id, part_id, quantity) VALUES (?, ?, ?)', + [maintenanceId, part.part_id, part.quantity] + ); + } + } + + await connection.commit(); + res.status(201).json({ message: 'Maintenance record created', id: maintenanceId }); + } catch (err) { + await connection.rollback(); + console.error(err); + res.status(500).json({ error: 'Database error' }); + } finally { + connection.release(); + } +}); + +// Update Maintenance Record +router.put('/maintenance/:id', async (req, res) => { + const { maintenance_date, type, content, images, parts } = req.body; + const maintenanceId = req.params.id; + + const connection = await db.getConnection(); + + try { + await connection.beginTransaction(); + + // 1. Update basic info + const sql = ` + UPDATE maintenance_history + SET maintenance_date=?, type=?, content=?, images=? + WHERE id=? + `; + const [result] = await connection.query(sql, [maintenance_date, type, content, JSON.stringify(images || []), maintenanceId]); + + if (result.affectedRows === 0) { + await connection.rollback(); + return res.status(404).json({ error: 'Maintenance record not found' }); + } + + // 2. Handle Parts Update (Restore old -> Apply new) + if (parts) { + // Get existing parts to restore stock + const [existingParts] = await connection.query('SELECT * FROM maintenance_parts WHERE maintenance_id = ?', [maintenanceId]); + + for (const part of existingParts) { + await connection.query('UPDATE assets SET quantity = quantity + ? WHERE id = ?', [part.quantity, part.part_id]); + } + + // Remove existing links + await connection.query('DELETE FROM maintenance_parts WHERE maintenance_id = ?', [maintenanceId]); + + // Add new parts and deduct stock + if (Array.isArray(parts) && parts.length > 0) { + for (const part of parts) { + await connection.query('UPDATE assets SET quantity = quantity - ? WHERE id = ?', [part.quantity, part.part_id]); + await connection.query( + 'INSERT INTO maintenance_parts (maintenance_id, part_id, quantity) VALUES (?, ?, ?)', + [maintenanceId, part.part_id, part.quantity] + ); + } + } + } + + await connection.commit(); + res.json({ message: 'Maintenance record updated' }); + } catch (err) { + await connection.rollback(); + console.error(err); + res.status(500).json({ error: 'Database error' }); + } finally { + connection.release(); + } +}); + +// Delete Maintenance Record +router.delete('/maintenance/:id', async (req, res) => { + const connection = await db.getConnection(); + + try { + await connection.beginTransaction(); + + // 1. Restore stock for used parts + const [usedParts] = await connection.query('SELECT * FROM maintenance_parts WHERE maintenance_id = ?', [req.params.id]); + for (const part of usedParts) { + await connection.query('UPDATE assets SET quantity = quantity + ? WHERE id = ?', [part.quantity, part.part_id]); + } + + // 2. Delete record (Cascading delete will remove maintenance_parts rows, but we manually restored stock first) + const [result] = await connection.query('DELETE FROM maintenance_history WHERE id = ?', [req.params.id]); + + if (result.affectedRows === 0) { + await connection.rollback(); + return res.status(404).json({ error: 'Maintenance record not found' }); + } + + await connection.commit(); + res.json({ message: 'Maintenance record deleted' }); + } catch (err) { + await connection.rollback(); + console.error(err); + res.status(500).json({ error: 'Database error' }); + } finally { + connection.release(); + } +}); + +// ========================================== +// 3. Manuals +// ========================================== + +// Get Manuals for an Asset +router.get('/assets/:asset_id/manuals', async (req, res) => { + try { + const [rows] = await db.query('SELECT * FROM asset_manuals WHERE asset_id = ? ORDER BY created_at DESC', [req.params.asset_id]); + res.json(rows); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// Add Manual to Asset +router.post('/assets/:asset_id/manuals', async (req, res) => { + const { file_name, file_url } = req.body; + const { asset_id } = req.params; + + try { + const sql = `INSERT INTO asset_manuals (asset_id, file_name, file_url) VALUES (?, ?, ?)`; + const [result] = await db.query(sql, [asset_id, file_name, file_url]); + res.status(201).json({ message: 'Manual added', id: result.insertId }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +// Delete Manual +router.delete('/manuals/:id', async (req, res) => { + try { + const [result] = await db.query('DELETE FROM asset_manuals WHERE id = ?', [req.params.id]); + if (result.affectedRows === 0) return res.status(404).json({ error: 'Manual not found' }); + res.json({ message: 'Manual deleted' }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'Database error' }); + } +}); + +module.exports = router; diff --git a/server/modules/cctv/routes.js b/server/modules/cctv/routes.js index 9ebe66b..a2167da 100644 --- a/server/modules/cctv/routes.js +++ b/server/modules/cctv/routes.js @@ -2,9 +2,10 @@ const express = require('express'); const router = express.Router(); const db = require('../../db'); const { isAuthenticated, hasRole } = require('../../middleware/authMiddleware'); +const { requireModule } = require('../../middleware/licenseMiddleware'); -// Get all cameras -router.get('/', async (req, res) => { +// Get all cameras - Protected by Module License +router.get('/', requireModule('monitoring'), async (req, res) => { try { const [rows] = await db.query('SELECT * FROM camera_settings ORDER BY display_order ASC, created_at DESC'); res.json(rows); diff --git a/src/app/App.tsx b/src/app/App.tsx index 4a7cef2..f4ae2bc 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,4 +1,5 @@ -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'; +import { ModuleGuard } from '../shared/auth/ModuleGuard'; import { MainLayout } from '../widgets/layout/MainLayout'; import { LoginPage } from '../pages/auth/LoginPage'; import { DashboardPage } from '../modules/asset/pages/DashboardPage'; @@ -30,26 +31,35 @@ function App() { }> - } /> - } /> - } /> - {/* Generic List Route */} - } /> + {/* Asset Management Routes */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + - {/* Specific Category Routes (Reuse List Page) */} - } /> - } /> - } /> - } /> + {/* Production Management Routes */} + }> + } /> + - } /> - } /> - } /> - } /> + {/* Monitoring Routes */} + }> + } /> + + + {/* Admin Routes */} } /> } /> - } /> - } /> Admin (Coming Soon)} /> diff --git a/src/modules/asset/pages/AssetDetailPage.tsx b/src/modules/asset/pages/AssetDetailPage.tsx index 5a1742a..bebcc9b 100644 --- a/src/modules/asset/pages/AssetDetailPage.tsx +++ b/src/modules/asset/pages/AssetDetailPage.tsx @@ -2,12 +2,12 @@ import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; import { Card } from '../../../shared/ui/Card'; import { Button } from '../../../shared/ui/Button'; -import { ArrowLeft, Save, Upload, X, FileText, Wrench, BookOpen, Trash2, ZoomIn, FileSpreadsheet, File, Download, Printer, Plus } from 'lucide-react'; +import { ArrowLeft, Save, Upload, X, FileText, Wrench, BookOpen, Trash2, ZoomIn, FileSpreadsheet, File, Download, Printer, Plus, Box, Edit2 } from 'lucide-react'; import { getMaintenanceTypes } from './AssetSettingsPage'; import './AssetDetailPage.css'; import { assetApi } from '../../../shared/api/assetApi'; -import type { Asset, MaintenanceRecord, Manual } from '../../../shared/api/assetApi'; +import type { Asset, MaintenanceRecord, Manual, PartUsage } from '../../../shared/api/assetApi'; import { SERVER_URL } from '../../../shared/api/client'; import { createPortal } from 'react-dom'; import DatePicker from 'react-datepicker'; @@ -179,7 +179,11 @@ function BasicInfoTab({ asset, onRefresh }: { asset: Asset & { image?: string, c {editData.image ? ( <> {asset.name}([]); + const [selectedParts, setSelectedParts] = useState([]); + const [partInput, setPartInput] = useState({ part_id: '', quantity: 1 }); + const [editingId, setEditingId] = useState(null); + useEffect(() => { loadHistory(); + loadAssets(); }, [assetId]); + const loadAssets = async () => { + try { + const data = await assetApi.getAll(); + setAvailableAssets(data.filter(a => a.id !== assetId)); + } catch (error) { + console.error("Failed to load assets:", error); + } + }; + const loadHistory = async () => { try { const data = await assetApi.getMaintenanceHistory(assetId); @@ -614,25 +633,70 @@ function HistoryTab({ assetId }: { assetId: string }) { })); }; + const handleAddPart = () => { + if (!partInput.part_id) return; + const part = availableAssets.find(a => a.id === partInput.part_id); + if (!part) return; + + // Check if already added + if (selectedParts.find(p => p.part_id === partInput.part_id)) { + alert("이미 추가된 부품입니다."); + return; + } + + setSelectedParts(prev => [...prev, { + part_id: part.id, + part_name: part.name, + model_name: part.model, + quantity: partInput.quantity + }]); + + setPartInput({ part_id: '', quantity: 1 }); + }; + + const handleRemovePart = (partId: string) => { + setSelectedParts(prev => prev.filter(p => p.part_id !== partId)); + }; + + + const handleSubmit = async () => { if (!formData.content) { alert("정비 내용을 입력해주세요."); return; } try { - await assetApi.addMaintenance(assetId, { - maintenance_date: formData.maintenance_date, - type: formData.type, - content: formData.content, - images: formData.images - }); + if (editingId) { + // Update existing record + await assetApi.updateMaintenance(editingId, { + maintenance_date: formData.maintenance_date, + type: formData.type, + content: formData.content, + images: formData.images, + parts: selectedParts + }); + alert("수정되었습니다."); + } else { + // Create new record + await assetApi.addMaintenance(assetId, { + maintenance_date: formData.maintenance_date, + type: formData.type, + content: formData.content, + images: formData.images, + parts: selectedParts + }); + } + + // Reset Form setIsWriting(false); + setEditingId(null); setFormData({ maintenance_date: new Date().toISOString().split('T')[0], type: '정기점검', content: '', images: [] }); + setSelectedParts([]); loadHistory(); } catch (error) { console.error("Failed to save maintenance record:", error); @@ -640,6 +704,32 @@ function HistoryTab({ assetId }: { assetId: string }) { } }; + const handleEdit = (item: MaintenanceRecord) => { + setEditingId(item.id); + setFormData({ + maintenance_date: new Date(item.maintenance_date).toISOString().split('T')[0], + type: item.type, + content: item.content, + images: item.images || [] + }); + setSelectedParts(item.parts || []); + setIsWriting(true); + // Scroll to form + document.querySelector('.maintenance-form')?.scrollIntoView({ behavior: 'smooth' }); + }; + + const handleCancel = () => { + setIsWriting(false); + setEditingId(null); + setFormData({ + maintenance_date: new Date().toISOString().split('T')[0], + type: '정기점검', + content: '', + images: [] + }); + setSelectedParts([]); + }; + const handleDelete = async (id: number) => { if (confirm("정비 이력을 삭제하시겠습니까?")) { try { @@ -665,11 +755,11 @@ function HistoryTab({ assetId }: { assetId: string }) { onClick={handleSubmit} className="flex items-center gap-2 px-3 py-1.5 rounded text-sm font-medium transition-colors bg-blue-600 text-white hover:bg-blue-700 shadow-sm" > - 저장하기 + {editingId ? '수정완료' : '저장하기'} )} + + {isWriting && (
@@ -767,7 +859,65 @@ function HistoryTab({ assetId }: { assetId: string }) {
+ {/* Part Selection UI */} +
+ +
+ + setPartInput({ ...partInput, quantity: parseInt(e.target.value) || 1 })} + /> + +
+ {/* Selected Parts List */} + {selectedParts.length > 0 && ( +
+ {selectedParts.map(part => ( +
+
+
+ +
+ {part.part_name} + ({part.model_name}) +
+
+ + {part.quantity}개 + + +
+
+ ))} +
+ )} +
)} @@ -806,8 +956,19 @@ function HistoryTab({ assetId }: { assetId: string }) { {item.type} - - {item.content} + +
{item.content}
+ {/* Display Used Parts */} + {item.parts && item.parts.length > 0 && ( +
+ {item.parts.map((part, idx) => ( + + + {part.part_name} ({part.quantity}) + + ))} +
+ )}
@@ -844,13 +1005,22 @@ function HistoryTab({ assetId }: { assetId: string }) {
- +
+ + +
)) @@ -883,14 +1053,13 @@ function ManualTab({ assetId }: { assetId: string }) { if (!file) return; try { - // Reusing uploadImage since it returns {url} and works for files const res = await assetApi.uploadImage(file); await assetApi.addManual(assetId, { file_name: file.name, file_url: res.data.url }); loadManuals(); - e.target.value = ''; // Reset input + e.target.value = ''; } catch (error) { console.error("Failed to upload manual:", error); alert("파일 업로드에 실패했습니다."); diff --git a/src/shared/api/assetApi.ts b/src/shared/api/assetApi.ts index 47cde10..852750b 100644 --- a/src/shared/api/assetApi.ts +++ b/src/shared/api/assetApi.ts @@ -47,6 +47,15 @@ export interface MaintenanceRecord { image_url?: string; // Legacy images?: string[]; // New: Multiple images created_at?: string; + parts?: PartUsage[]; +} + +export interface PartUsage { + id?: number; // maintenance_parts id + part_id: string; // asset id + part_name?: string; // joined + model_name?: string; // joined + quantity: number; } // Manual Interface @@ -105,6 +114,10 @@ export const assetApi = { return apiClient.delete(`/maintenance/${id}`); }, + updateMaintenance: async (id: number, data: Partial) => { + return apiClient.put(`/maintenance/${id}`, data); + }, + // Manuals / Instructions getManuals: async (assetId: string): Promise => { const response = await apiClient.get(`/assets/${assetId}/manuals`); diff --git a/src/shared/auth/ModuleGuard.tsx b/src/shared/auth/ModuleGuard.tsx new file mode 100644 index 0000000..2682c9f --- /dev/null +++ b/src/shared/auth/ModuleGuard.tsx @@ -0,0 +1,25 @@ +import { type ReactNode } from 'react'; +import { Navigate } from 'react-router-dom'; +import { useSystem } from '../context/SystemContext'; + +interface ModuleGuardProps { + moduleCode: string; + children: ReactNode; +} + +export function ModuleGuard({ moduleCode, children }: ModuleGuardProps) { + const { modules, isLoading } = useSystem(); + + if (isLoading) { + return
Loading permissions...
; + } + + const isActive = modules[moduleCode]?.active; + + if (!isActive) { + // Redirect to home or showing a customizable "Unauthorized" page could be better + return ; + } + + return <>{children}; +} diff --git a/vite.config.ts b/vite.config.ts index f125622..6966c0a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,6 +26,10 @@ export default defineConfig({ }); }, }, + '/uploads': { + target: 'http://localhost:3005', + changeOrigin: true, + }, } } })