feat: Improve Asset Maintenance UI and fix syntax errors in AssetDetailPage
This commit is contained in:
parent
5ead239b71
commit
af510968ef
210
server/index.js
210
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')));
|
||||
|
||||
42
server/middleware/licenseMiddleware.js
Normal file
42
server/middleware/licenseMiddleware.js
Normal 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 };
|
||||
282
server/modules/asset/routes.js
Normal file
282
server/modules/asset/routes.js
Normal 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;
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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("파일 업로드에 실패했습니다.");
|
||||
|
||||
@ -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`);
|
||||
|
||||
25
src/shared/auth/ModuleGuard.tsx
Normal file
25
src/shared/auth/ModuleGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
@ -26,6 +26,10 @@ export default defineConfig({
|
||||
});
|
||||
},
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:3005',
|
||||
changeOrigin: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user