feat: Improve Asset Maintenance UI and fix syntax errors in AssetDetailPage

This commit is contained in:
choibk 2026-01-23 00:46:05 +09:00
parent 5ead239b71
commit af510968ef
9 changed files with 627 additions and 213 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {
<MainLayout />
</AuthGuard>
}>
<Route path="/" element={<Navigate to="/asset/dashboard" replace />} />
<Route path="/asset/dashboard" element={<DashboardPage />} />
<Route path="/asset/register" element={<AssetRegisterPage />} />
{/* Generic List Route */}
<Route path="/asset/list" element={<AssetListPage />} />
{/* Asset Management Routes */}
<Route element={<ModuleGuard moduleCode="asset"><Outlet /></ModuleGuard>}>
<Route path="/" element={<Navigate to="/asset/dashboard" replace />} />
<Route path="/asset/dashboard" element={<DashboardPage />} />
<Route path="/asset/register" element={<AssetRegisterPage />} />
<Route path="/asset/list" element={<AssetListPage />} />
<Route path="/asset/facilities" element={<AssetListPage />} />
<Route path="/asset/tools" element={<AssetListPage />} />
<Route path="/asset/general" element={<AssetListPage />} />
<Route path="/asset/settings" element={<AssetSettingsPage />} />
<Route path="/asset/detail/:assetId" element={<AssetDetailPage />} />
<Route path="/asset/consumables" element={<AssetListPage />} />
<Route path="/asset/instruments" element={<AssetListPage />} />
<Route path="/asset/vehicles" element={<AssetListPage />} />
</Route>
{/* Specific Category Routes (Reuse List Page) */}
<Route path="/asset/facilities" element={<AssetListPage />} />
<Route path="/asset/tools" element={<AssetListPage />} />
<Route path="/asset/general" element={<AssetListPage />} />
<Route path="/asset/settings" element={<AssetSettingsPage />} />
{/* Production Management Routes */}
<Route element={<ModuleGuard moduleCode="production"><Outlet /></ModuleGuard>}>
<Route path="/production/dashboard" element={<ProductionPage />} />
</Route>
<Route path="/asset/detail/:assetId" element={<AssetDetailPage />} />
<Route path="/asset/consumables" element={<AssetListPage />} />
<Route path="/asset/instruments" element={<AssetListPage />} />
<Route path="/asset/vehicles" element={<AssetListPage />} />
{/* Monitoring Routes */}
<Route element={<ModuleGuard moduleCode="monitoring"><Outlet /></ModuleGuard>}>
<Route path="/monitoring" element={<MonitoringPage />} />
</Route>
{/* Admin Routes */}
<Route path="/admin/users" element={<UserManagementPage />} />
<Route path="/admin/license" element={<LicensePage />} />
<Route path="/production/dashboard" element={<ProductionPage />} />
<Route path="/monitoring" element={<MonitoringPage />} />
<Route path="/admin" element={<div>Admin (Coming Soon)</div>} />
</Route>

View File

@ -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 ? (
<>
<img
src={editData.image.startsWith('http') ? editData.image : `${SERVER_URL}${editData.image} `}
src={
editData.image.startsWith('http')
? editData.image
: `${SERVER_URL}${!SERVER_URL && !editData.image.startsWith('/') ? '/' : ''}${editData.image}`
}
alt={asset.name}
className="w-full h-full"
style={{ objectFit: 'contain', maxWidth: '100%', maxHeight: '100%' }}
@ -579,10 +583,25 @@ function HistoryTab({ assetId }: { assetId: string }) {
images: [] as string[]
});
const [availableAssets, setAvailableAssets] = useState<Asset[]>([]);
const [selectedParts, setSelectedParts] = useState<PartUsage[]>([]);
const [partInput, setPartInput] = useState({ part_id: '', quantity: 1 });
const [editingId, setEditingId] = useState<number | null>(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"
>
<Save size={16} />
<Save size={16} /> {editingId ? '수정완료' : '저장하기'}
</button>
)}
<button
onClick={() => setIsWriting(!isWriting)}
onClick={() => isWriting ? handleCancel() : setIsWriting(true)}
className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm font-medium transition-colors ${isWriting
? 'bg-slate-100 text-slate-600 hover:bg-slate-200'
: 'bg-blue-600 text-white hover:bg-blue-700 shadow-sm'
@ -681,13 +771,15 @@ function HistoryTab({ assetId }: { assetId: string }) {
</>
) : (
<>
<Upload size={16} />
<Plus size={16} />
</>
)}
</button>
</div>
</div>
{isWriting && (
<div className="maintenance-form bg-slate-50 p-6 border-b border-slate-200 rounded-lg mb-6">
<div className="space-y-4">
@ -767,7 +859,65 @@ function HistoryTab({ assetId }: { assetId: string }) {
</div>
</div>
{/* Part Selection UI */}
<div>
<label className="block text-sm font-bold text-slate-500 mb-1"> /</label>
<div className="flex gap-2 mb-2 p-3 bg-white border border-slate-200 rounded-lg">
<select
className="flex-1 p-2 border border-slate-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={partInput.part_id}
onChange={e => setPartInput({ ...partInput, part_id: e.target.value })}
>
<option value=""> ()</option>
{availableAssets.map(asset => (
<option key={asset.id} value={asset.id}>
{asset.name} ({asset.model || '-'}) - {asset.location}
</option>
))}
</select>
<input
type="number"
min="1"
className="w-20 p-2 border border-slate-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none text-right"
value={partInput.quantity}
onChange={e => setPartInput({ ...partInput, quantity: parseInt(e.target.value) || 1 })}
/>
<button
onClick={handleAddPart}
className="px-4 py-2 bg-slate-800 text-white rounded hover:bg-slate-700 transition-colors font-medium whitespace-nowrap"
>
</button>
</div>
{/* Selected Parts List */}
{selectedParts.length > 0 && (
<div className="space-y-2 bg-white border border-slate-200 rounded-lg p-3">
{selectedParts.map(part => (
<div key={part.part_id} className="flex items-center justify-between p-2 bg-slate-50 rounded border border-slate-100">
<div className="flex items-center gap-2">
<div className="w-8 h-8 flex items-center justify-center bg-blue-100 text-blue-600 rounded-full">
<Box size={16} />
</div>
<span className="font-medium text-slate-700">{part.part_name}</span>
<span className="text-xs text-slate-400">({part.model_name})</span>
</div>
<div className="flex items-center gap-3">
<span className="bg-slate-200 text-slate-700 px-2 py-0.5 rounded text-sm font-bold">
{part.quantity}
</span>
<button
onClick={() => handleRemovePart(part.part_id)}
className="text-slate-400 hover:text-red-500 transition-colors"
>
<X size={16} />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
@ -806,8 +956,19 @@ function HistoryTab({ assetId }: { assetId: string }) {
{item.type}
</span>
</td>
<td className="px-6 py-5 text-center text-slate-600 max-w-xs truncate">
{item.content}
<td className="px-6 py-5 text-center text-slate-600 max-w-xs">
<div className="line-clamp-2">{item.content}</div>
{/* Display Used Parts */}
{item.parts && item.parts.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1 justify-center">
{item.parts.map((part, idx) => (
<span key={idx} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-slate-100 text-slate-600 border border-slate-200">
<Box size={10} />
{part.part_name} ({part.quantity})
</span>
))}
</div>
)}
</td>
<td className="px-6 py-5 text-center">
<div className="flex gap-1 justify-center flex-wrap max-w-[200px] mx-auto">
@ -844,13 +1005,22 @@ function HistoryTab({ assetId }: { assetId: string }) {
</div>
</td>
<td className="px-6 py-5 text-center">
<button
className="text-slate-400 hover:text-red-600 hover:bg-red-50 p-2 rounded-md transition-all opacity-0 group-hover:opacity-100 focus:opacity-100"
onClick={() => handleDelete(item.id)}
title="삭제"
>
<Trash2 size={20} />
</button>
<div className="flex justify-center gap-2">
<button
className="text-slate-400 hover:text-blue-600 hover:bg-blue-50 p-2 rounded-md transition-all opacity-0 group-hover:opacity-100 focus:opacity-100"
onClick={() => handleEdit(item)}
title="수정"
>
<Edit2 size={20} />
</button>
<button
className="text-slate-400 hover:text-red-600 hover:bg-red-50 p-2 rounded-md transition-all opacity-0 group-hover:opacity-100 focus:opacity-100"
onClick={() => handleDelete(item.id)}
title="삭제"
>
<Trash2 size={20} />
</button>
</div>
</td>
</tr>
))
@ -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("파일 업로드에 실패했습니다.");

View File

@ -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<MaintenanceRecord>) => {
return apiClient.put(`/maintenance/${id}`, data);
},
// Manuals / Instructions
getManuals: async (assetId: string): Promise<Manual[]> => {
const response = await apiClient.get<Manual[]>(`/assets/${assetId}/manuals`);

View File

@ -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 <div className="p-8 text-center">Loading permissions...</div>;
}
const isActive = modules[moduleCode]?.active;
if (!isActive) {
// Redirect to home or showing a customizable "Unauthorized" page could be better
return <Navigate to="/" replace />;
}
return <>{children}</>;
}

View File

@ -26,6 +26,10 @@ export default defineConfig({
});
},
},
'/uploads': {
target: 'http://localhost:3005',
changeOrigin: true,
},
}
}
})