diff --git a/backend/src/routes/smartPackagesRoutes.js b/backend/src/routes/smartPackagesRoutes.js new file mode 100644 index 0000000..2fa4843 --- /dev/null +++ b/backend/src/routes/smartPackagesRoutes.js @@ -0,0 +1,530 @@ +const express = require('express'); +const router = express.Router(); + +// Hjælpefunktion til at hente service +const getService = () => { + if (!global.smartPackageManagementService) { + throw new Error('Smart Package Management Service ikke initialiseret'); + } + return global.smartPackageManagementService; +}; + +// ======================================== +// SMART PAKKER API ENDPOINTS +// ======================================== + +// Opret en ny smart pakke med tasks og steps +router.post('/', async (req, res) => { + try { + const { + name, description, category, package_type, difficulty_level, + estimated_hours, hourly_rate, installation_notes, is_template, + materials, tasks, created_by + } = req.body; + + if (!name || !description) { + return res.status(400).json({ + success: false, + error: 'Navn og beskrivelse er påkrævet' + }); + } + + const result = await getService().createPackage({ + name, + description, + category, + package_type, + difficulty_level, + estimated_hours, + hourly_rate, + installation_notes, + is_template, + materials, + tasks, + created_by: created_by || 'system' + }); + + res.json({ + success: true, + package: result, + message: 'Smart pakke oprettet succesfuldt' + }); + } catch (error) { + console.error('Error creating smart package:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved oprettelse af smart pakke: ' + error.message + }); + } +}); + +// Hent alle smart pakker med filtrering +router.get('/', async (req, res) => { + try { + const { + packageType, category, search, difficulty, isTemplate, + sortBy, sortDir, limit, offset + } = req.query; + + const packages = await getService().getPackages({ + packageType, + category, + search, + difficulty, + isTemplate: isTemplate === 'true' ? true : (isTemplate === 'false' ? false : undefined), + sortBy, + sortDir, + limit: limit ? parseInt(limit) : undefined, + offset: offset ? parseInt(offset) : undefined + }); + + res.json({ + success: true, + packages + }); + } catch (error) { + console.error('Error getting smart packages:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved hentning af smart pakker: ' + error.message + }); + } +}); + +// Hent en specifik smart pakke med detaljer +router.get('/:id', async (req, res) => { + try { + const { id } = req.params; + + const packageDetails = await getService().getPackageDetails(id); + + res.json({ + success: true, + package: packageDetails + }); + } catch (error) { + console.error('Error getting smart package details:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved hentning af smart pakke detaljer: ' + error.message + }); + } +}); + +// Opdater en smart pakke +router.put('/:id', async (req, res) => { + try { + const { id } = req.params; + const { + name, description, category, package_type, difficulty_level, + estimated_hours, hourly_rate, installation_notes, is_template, + materials, tasks + } = req.body; + + await getService().updatePackage(id, { + name, + description, + category, + package_type, + difficulty_level, + estimated_hours, + hourly_rate, + installation_notes, + is_template, + materials, + tasks + }); + + res.json({ + success: true, + message: 'Smart pakke opdateret succesfuldt' + }); + } catch (error) { + console.error('Error updating smart package:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved opdatering af smart pakke: ' + error.message + }); + } +}); + +// Slet en smart pakke +router.delete('/:id', async (req, res) => { + try { + const { id } = req.params; + + await getService().deletePackage(id); + + res.json({ + success: true, + message: 'Smart pakke slettet succesfuldt' + }); + } catch (error) { + console.error('Error deleting smart package:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved sletning af smart pakke: ' + error.message + }); + } +}); + +// Tilføj en task til en smart pakke +router.post('/:id/tasks', async (req, res) => { + try { + const { id } = req.params; + const { + name, description, hours, rate, skills, tools, + optional, dependsOn, installationManualId + } = req.body; + + if (!name) { + return res.status(400).json({ + success: false, + error: 'Task navn er påkrævet' + }); + } + + const result = await getService().addTaskToPackage(id, { + name, + description, + hours, + rate, + skills, + tools, + optional, + dependsOn, + installationManualId + }); + + res.json({ + success: true, + taskId: result.taskId, + order: result.order, + message: 'Task tilføjet til smart pakke succesfuldt' + }); + } catch (error) { + console.error('Error adding task to smart package:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved tilføjelse af task til smart pakke: ' + error.message + }); + } +}); + +// Opdater en task +router.put('/tasks/:taskId', async (req, res) => { + try { + const { taskId } = req.params; + const { + name, description, hours, rate, skills, tools, + optional, dependsOn, installationManualId + } = req.body; + + await getService().updateTask(taskId, { + name, + description, + hours, + rate, + skills, + tools, + optional, + dependsOn, + installationManualId + }); + + res.json({ + success: true, + message: 'Task opdateret succesfuldt' + }); + } catch (error) { + console.error('Error updating task:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved opdatering af task: ' + error.message + }); + } +}); + +// Slet en task +router.delete('/tasks/:taskId', async (req, res) => { + try { + const { taskId } = req.params; + + await getService().deleteTask(taskId); + + res.json({ + success: true, + message: 'Task slettet succesfuldt' + }); + } catch (error) { + console.error('Error deleting task:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved sletning af task: ' + error.message + }); + } +}); + +// Hent alle tasks +router.get('/tasks', async (req, res) => { + try { + const tasks = await getService().getAllTasks(); + + res.json({ + success: true, + tasks + }); + } catch (error) { + console.error('Error fetching tasks:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved hentning af tasks: ' + error.message + }); + } +});// Ændre rækkefølgen af tasks +router.put('/:id/reorder-tasks', async (req, res) => { + try { + const { id } = req.params; + const { taskOrderings } = req.body; + + if (!taskOrderings || !Array.isArray(taskOrderings)) { + return res.status(400).json({ + success: false, + error: 'Ugyldig task rækkefølge data' + }); + } + + await getService().reorderTasks(id, taskOrderings); + + res.json({ + success: true, + message: 'Task rækkefølge opdateret succesfuldt' + }); + } catch (error) { + console.error('Error reordering tasks:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved opdatering af task rækkefølge: ' + error.message + }); + } +}); + +// Tilføj et step til en task +router.post('/tasks/:taskId/steps', async (req, res) => { + try { + const { taskId } = req.params; + const { + title, description, minutes, image, video, + safety, quality, tips + } = req.body; + + if (!title) { + return res.status(400).json({ + success: false, + error: 'Step titel er påkrævet' + }); + } + + const result = await getService().addStepToTask(taskId, { + title, + description, + minutes, + image, + video, + safety, + quality, + tips + }); + + res.json({ + success: true, + stepId: result.stepId, + order: result.order, + message: 'Step tilføjet til task succesfuldt' + }); + } catch (error) { + console.error('Error adding step to task:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved tilføjelse af step til task: ' + error.message + }); + } +}); + +// Opdater et step +router.put('/steps/:stepId', async (req, res) => { + try { + const { stepId } = req.params; + const { + title, description, minutes, image, video, + safety, quality, tips + } = req.body; + + await getService().updateStep(stepId, { + title, + description, + minutes, + image, + video, + safety, + quality, + tips + }); + + res.json({ + success: true, + message: 'Step opdateret succesfuldt' + }); + } catch (error) { + console.error('Error updating step:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved opdatering af step: ' + error.message + }); + } +}); + +// Slet et step +router.delete('/steps/:stepId', async (req, res) => { + try { + const { stepId } = req.params; + + await getService().deleteStep(stepId); + + res.json({ + success: true, + message: 'Step slettet succesfuldt' + }); + } catch (error) { + console.error('Error deleting step:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved sletning af step: ' + error.message + }); + } +}); + +// Ændre rækkefølgen af steps +router.put('/tasks/:taskId/reorder-steps', async (req, res) => { + try { + const { taskId } = req.params; + const { stepOrderings } = req.body; + + if (!stepOrderings || !Array.isArray(stepOrderings)) { + return res.status(400).json({ + success: false, + error: 'Ugyldig step rækkefølge data' + }); + } + + await getService().reorderSteps(taskId, stepOrderings); + + res.json({ + success: true, + message: 'Step rækkefølge opdateret succesfuldt' + }); + } catch (error) { + console.error('Error reordering steps:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved opdatering af step rækkefølge: ' + error.message + }); + } +}); + +// Tilføj smart pakke til projekt +router.post('/:id/add-to-project', async (req, res) => { + try { + const { id } = req.params; + const { projectId, multiplier, geometry, userId } = req.body; + + if (!projectId) { + return res.status(400).json({ + success: false, + error: 'Projekt ID er påkrævet' + }); + } + + const result = await getService().addPackageToProject(projectId, id, { + multiplier, + geometry, + userId + }); + + res.json({ + success: true, + ...result, + message: 'Smart pakke tilføjet til projekt succesfuldt' + }); + } catch (error) { + console.error('Error adding package to project:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved tilføjelse af smart pakke til projekt: ' + error.message + }); + } +}); + +// Generer arbejdsbeskrivelse for en pakke +router.get('/:id/work-description', async (req, res) => { + try { + const { id } = req.params; + + const workDescription = await getService().generateWorkDescription(id); + + res.json({ + success: true, + workDescription + }); + } catch (error) { + console.error('Error generating work description:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved generering af arbejdsbeskrivelse: ' + error.message + }); + } +}); + +// Hent pakke kategorier og typer +router.get('/categories', async (req, res) => { + try { + const categoriesData = await getService().getPackageCategories(); + + res.json({ + success: true, + ...categoriesData + }); + } catch (error) { + console.error('Error getting package categories:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved hentning af pakke kategorier: ' + error.message + }); + } +}); + +// Få relaterede pakker baseret på type, kategori, etc. +router.get('/:id/related', async (req, res) => { + try { + const { id } = req.params; + const { limit } = req.query; + + const relatedPackages = await getService().getRelatedPackages( + id, + limit ? parseInt(limit) : 5 + ); + + res.json({ + success: true, + relatedPackages + }); + } catch (error) { + console.error('Error getting related packages:', error); + res.status(500).json({ + success: false, + error: 'Fejl ved hentning af relaterede pakker: ' + error.message + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/services/smartPackageManagementService.js b/backend/src/services/smartPackageManagementService.js new file mode 100644 index 0000000..5332ce8 --- /dev/null +++ b/backend/src/services/smartPackageManagementService.js @@ -0,0 +1,1355 @@ +/** + * Smart Package Management Service + * + * Håndterer alle CRUD-operationer på smart pakker, tasks og montagevejledninger. + * Inkluderer funktionalitet til at beregne materiale-mængder baseret på geometri, + * generere arbejdsbeskrivelser og estimere tid for opgaver. + * + * @author Tilbudgivern + * @version 2.0 + */ + +const logger = require('../utils/logger'); + +class SmartPackageManagementService { + constructor(databaseService) { + this.db = databaseService; + } + + /** + * Hent alle pakker med filtrering + * @param {Object} filters - Filteringsparametre + * @returns {Array} Pakker + */ + async getPackages(filters = {}) { + try { + let query = ` + SELECT + mp.id, + mp.name, + mp.description, + mp.category, + mp.package_type, + mp.difficulty_level, + mp.estimated_hours, + mp.hourly_rate, + mp.installation_notes, + mp.total_estimated_price as material_cost, + mp.estimated_hours * mp.hourly_rate as labor_cost, + (mp.total_estimated_price + (mp.estimated_hours * mp.hourly_rate)) as total_package_price, + mp.is_template, + mp.popularity_score, + mp.created_by, + mp.created_at, + mp.updated_at, + COUNT(DISTINCT spt.id) as task_count, + COUNT(DISTINCT pm.id) as material_count + FROM material_packages mp + LEFT JOIN smart_package_tasks spt ON mp.id = spt.package_id + LEFT JOIN package_materials pm ON mp.id = pm.package_id + WHERE mp.is_active = 1 + `; + + const params = []; + + if (filters.packageType) { + query += ' AND mp.package_type = ?'; + params.push(filters.packageType); + } + + if (filters.category) { + query += ' AND mp.category = ?'; + params.push(filters.category); + } + + if (filters.search) { + query += ` AND ( + mp.name LIKE ? + OR mp.description LIKE ? + OR mp.category LIKE ? + )`; + const searchTerm = `%${filters.search}%`; + params.push(searchTerm, searchTerm, searchTerm); + } + + if (filters.difficulty) { + query += ' AND mp.difficulty_level = ?'; + params.push(filters.difficulty); + } + + if (filters.isTemplate !== undefined) { + query += ' AND mp.is_template = ?'; + params.push(filters.isTemplate ? 1 : 0); + } + + query += ' GROUP BY mp.id'; + + // Sorting + if (filters.sortBy) { + const sortDirection = filters.sortDir === 'desc' ? 'DESC' : 'ASC'; + let sortField = 'mp.created_at'; + + switch (filters.sortBy) { + case 'name': + sortField = 'mp.name'; + break; + case 'popularity': + sortField = 'mp.popularity_score'; + break; + case 'price': + sortField = 'total_package_price'; + break; + case 'hours': + sortField = 'mp.estimated_hours'; + break; + default: + sortField = 'mp.created_at'; + } + + query += ` ORDER BY ${sortField} ${sortDirection}`; + } else { + query += ' ORDER BY mp.created_at DESC'; + } + + if (filters.limit) { + query += ' LIMIT ?'; + params.push(parseInt(filters.limit, 10)); + } + + if (filters.offset) { + query += ' OFFSET ?'; + params.push(parseInt(filters.offset, 10)); + } + + const [rows] = await this.db.pool.execute(query, params); + return rows; + } catch (error) { + logger.error('Error getting smart packages:', error); + throw error; + } + } + + /** + * Opret en ny smart pakke + */ + async createPackage(packageData) { + try { + const { + name, description, category, package_type, difficulty_level, + estimated_hours, hourly_rate, installation_notes, is_template, + materials, tasks, created_by + } = packageData; + + // Beregn estimeret materialepris + const totalMaterialPrice = materials && materials.length > 0 + ? materials.reduce((sum, material) => sum + (parseFloat(material.quantity) * parseFloat(material.unitPrice)), 0) + : 0; + + // Start en transaction + const connection = await this.db.pool.getConnection(); + await connection.beginTransaction(); + + try { + // Indsæt pakke + const [packageResult] = await connection.execute(` + INSERT INTO material_packages ( + name, description, category, package_type, difficulty_level, + estimated_hours, hourly_rate, installation_notes, is_template, + total_estimated_price, created_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + name, description, category, package_type || 'generel', difficulty_level || 'mellem', + estimated_hours || 0, hourly_rate || 580, installation_notes || '', is_template ? 1 : 0, + totalMaterialPrice, created_by || 'system' + ]); + + const packageId = packageResult.insertId; + + // Indsæt materialer + if (materials && materials.length > 0) { + for (const material of materials) { + const materialTotal = parseFloat(material.quantity) * parseFloat(material.unitPrice); + + await connection.execute(` + INSERT INTO package_materials ( + package_id, material_name, material_category, quantity, unit, + unit_price, total_price, supplier, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + packageId, + material.name, + material.category || '', + material.quantity, + material.unit || 'stk', + material.unitPrice, + materialTotal, + material.supplier || '', + material.notes || '' + ]); + } + } + + // Indsæt tasks + if (tasks && tasks.length > 0) { + for (let i = 0; i < tasks.length; i++) { + const task = tasks[i]; + + const [taskResult] = await connection.execute(` + INSERT INTO smart_package_tasks ( + package_id, task_name, task_description, task_order, + estimated_hours, hourly_rate, required_skills, required_tools, + is_optional, depends_on_task_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + packageId, + task.name, + task.description || '', + i + 1, // Task order based on array position + task.hours || 0, + task.rate || packageData.hourly_rate || 580, + task.skills ? JSON.stringify(task.skills) : null, + task.tools ? JSON.stringify(task.tools) : null, + task.optional ? 1 : 0, + task.dependsOn || null + ]); + + // Indsæt steps hvis der er nogen + if (task.steps && task.steps.length > 0) { + const taskId = taskResult.insertId; + + for (let j = 0; j < task.steps.length; j++) { + const step = task.steps[j]; + + await connection.execute(` + INSERT INTO smart_package_steps ( + task_id, step_order, step_title, step_description, estimated_minutes, + image_url, video_url, safety_notes, quality_check, tips + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + taskId, + j + 1, // Step order based on array position + step.title, + step.description || '', + step.minutes || 0, + step.image || null, + step.video || null, + step.safety || null, + step.quality || null, + step.tips || null + ]); + } + } + } + } + + // Commit transaktionen + await connection.commit(); + + logger.info('Smart package created successfully', { packageId, name }); + + return { + id: packageId, + name, + description, + category, + package_type, + difficulty_level, + estimated_hours, + hourly_rate, + materials_count: materials ? materials.length : 0, + tasks_count: tasks ? tasks.length : 0, + material_cost: totalMaterialPrice, + labor_cost: estimated_hours * hourly_rate, + created_by + }; + } catch (error) { + // Rollback transaktionen ved fejl + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } catch (error) { + logger.error('Error creating smart package:', error); + throw error; + } + } + + /** + * Hent pakke med komplet indhold (tasks, steps, materialer) + */ + async getPackageDetails(packageId) { + try { + // Hent pakke info + const [packageRows] = await this.db.pool.execute(` + SELECT * FROM material_packages WHERE id = ? AND is_active = 1 + `, [packageId]); + + if (packageRows.length === 0) { + throw new Error('Package not found'); + } + + const packageData = packageRows[0]; + + // Hent tasks med steps + const tasks = await this.getTasksForPackage(packageId); + + // Hent materialer + const [materialsRows] = await this.db.pool.execute(` + SELECT * FROM package_materials WHERE package_id = ? ORDER BY material_category, material_name + `, [packageId]); + + // Hent installation manuals hvis der er nogen koblet + const [manualsRows] = await this.db.pool.execute(` + SELECT im.* + FROM installation_manuals im + JOIN smart_package_tasks spt ON im.id = spt.installation_manual_id + WHERE spt.package_id = ? + GROUP BY im.id + `, [packageId]); + + // Hent brugstatistik + const [usageRows] = await this.db.pool.execute(` + SELECT COUNT(*) as usage_count + FROM smart_package_usage + WHERE package_id = ? + `, [packageId]); + + return { + ...packageData, + tasks, + materials: materialsRows, + installation_manuals: manualsRows, + usage_count: usageRows[0]?.usage_count || 0 + }; + } catch (error) { + logger.error('Error getting package details:', error); + throw error; + } + } + + /** + * Hent tasks for en pakke + */ + async getTasksForPackage(packageId) { + try { + const [taskRows] = await this.db.pool.execute(` + SELECT * FROM smart_package_tasks + WHERE package_id = ? + ORDER BY task_order + `, [packageId]); + + const tasks = []; + + for (const task of taskRows) { + // Hent steps for hver task + const [stepRows] = await this.db.pool.execute(` + SELECT * FROM smart_package_steps + WHERE task_id = ? + ORDER BY step_order + `, [task.id]); + + // Parse JSON felter + const requiredSkills = task.required_skills ? JSON.parse(task.required_skills) : []; + const requiredTools = task.required_tools ? JSON.parse(task.required_tools) : []; + + tasks.push({ + ...task, + required_skills: requiredSkills, + required_tools: requiredTools, + steps: stepRows + }); + } + + return tasks; + } catch (error) { + logger.error('Error getting tasks for package:', error); + throw error; + } + } + + /** + * Opdater en pakke + */ + async updatePackage(packageId, updateData) { + try { + const connection = await this.db.pool.getConnection(); + await connection.beginTransaction(); + + try { + // Opdater pakke info + const { + name, description, category, package_type, difficulty_level, + estimated_hours, hourly_rate, installation_notes, is_template, + materials, tasks + } = updateData; + + if (name || description || category || package_type || difficulty_level || + estimated_hours !== undefined || hourly_rate !== undefined || + installation_notes || is_template !== undefined) { + + let updateQuery = 'UPDATE material_packages SET updated_at = NOW()'; + const params = []; + + if (name) { + updateQuery += ', name = ?'; + params.push(name); + } + + if (description !== undefined) { + updateQuery += ', description = ?'; + params.push(description); + } + + if (category) { + updateQuery += ', category = ?'; + params.push(category); + } + + if (package_type) { + updateQuery += ', package_type = ?'; + params.push(package_type); + } + + if (difficulty_level) { + updateQuery += ', difficulty_level = ?'; + params.push(difficulty_level); + } + + if (estimated_hours !== undefined) { + updateQuery += ', estimated_hours = ?'; + params.push(estimated_hours); + } + + if (hourly_rate !== undefined) { + updateQuery += ', hourly_rate = ?'; + params.push(hourly_rate); + } + + if (installation_notes !== undefined) { + updateQuery += ', installation_notes = ?'; + params.push(installation_notes); + } + + if (is_template !== undefined) { + updateQuery += ', is_template = ?'; + params.push(is_template ? 1 : 0); + } + + updateQuery += ' WHERE id = ? AND is_active = 1'; + params.push(packageId); + + await connection.execute(updateQuery, params); + } + + // Opdater materialer hvis det er inkluderet + if (materials) { + // Slet nuværende materialer + await connection.execute('DELETE FROM package_materials WHERE package_id = ?', [packageId]); + + // Tilføj nye materialer + let totalMaterialPrice = 0; + + for (const material of materials) { + const materialTotal = parseFloat(material.quantity) * parseFloat(material.unitPrice); + totalMaterialPrice += materialTotal; + + await connection.execute(` + INSERT INTO package_materials ( + package_id, material_name, material_category, quantity, unit, + unit_price, total_price, supplier, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + packageId, + material.name, + material.category || '', + material.quantity, + material.unit || 'stk', + material.unitPrice, + materialTotal, + material.supplier || '', + material.notes || '' + ]); + } + + // Opdater total materialepris + await connection.execute(` + UPDATE material_packages SET total_estimated_price = ? WHERE id = ? + `, [totalMaterialPrice, packageId]); + } + + // Opdater tasks hvis det er inkluderet + if (tasks) { + // Slet nuværende tasks og steps + const [existingTasks] = await connection.execute('SELECT id FROM smart_package_tasks WHERE package_id = ?', [packageId]); + + for (const task of existingTasks) { + await connection.execute('DELETE FROM smart_package_steps WHERE task_id = ?', [task.id]); + } + + await connection.execute('DELETE FROM smart_package_tasks WHERE package_id = ?', [packageId]); + + // Tilføj nye tasks og steps + for (let i = 0; i < tasks.length; i++) { + const task = tasks[i]; + + const [taskResult] = await connection.execute(` + INSERT INTO smart_package_tasks ( + package_id, task_name, task_description, task_order, + estimated_hours, hourly_rate, required_skills, required_tools, + is_optional, depends_on_task_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + packageId, + task.name, + task.description || '', + i + 1, + task.hours || 0, + task.rate || updateData.hourly_rate || 580, + task.skills ? JSON.stringify(task.skills) : null, + task.tools ? JSON.stringify(task.tools) : null, + task.optional ? 1 : 0, + null // Vi håndterer dependencies efter alle tasks er oprettet + ]); + + // Tilføj steps + if (task.steps && task.steps.length > 0) { + const taskId = taskResult.insertId; + + for (let j = 0; j < task.steps.length; j++) { + const step = task.steps[j]; + + await connection.execute(` + INSERT INTO smart_package_steps ( + task_id, step_order, step_title, step_description, estimated_minutes, + image_url, video_url, safety_notes, quality_check, tips + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + taskId, + j + 1, + step.title, + step.description || '', + step.minutes || 0, + step.image || null, + step.video || null, + step.safety || null, + step.quality || null, + step.tips || null + ]); + } + } + } + } + + // Commit transaktionen + await connection.commit(); + + logger.info('Smart package updated successfully', { packageId }); + return { success: true }; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } catch (error) { + logger.error('Error updating smart package:', error); + throw error; + } + } + + /** + * Slet en pakke (soft delete) + */ + async deletePackage(packageId) { + try { + await this.db.pool.execute(` + UPDATE material_packages SET is_active = 0, updated_at = NOW() + WHERE id = ? + `, [packageId]); + + logger.info('Smart package deleted (soft delete)', { packageId }); + return { success: true }; + } catch (error) { + logger.error('Error deleting smart package:', error); + throw error; + } + } + + /** + * Tilføj en task til en pakke + */ + async addTaskToPackage(packageId, taskData) { + try { + // Tjek om pakken eksisterer + const [packageCheck] = await this.db.pool.execute(` + SELECT id FROM material_packages WHERE id = ? AND is_active = 1 + `, [packageId]); + + if (packageCheck.length === 0) { + throw new Error('Package not found or inactive'); + } + + // Find næste task_order værdi + const [orderResult] = await this.db.pool.execute(` + SELECT COALESCE(MAX(task_order), 0) + 1 as next_order + FROM smart_package_tasks + WHERE package_id = ? + `, [packageId]); + + const nextOrder = orderResult[0].next_order; + + // Indsæt task + const [taskResult] = await this.db.pool.execute(` + INSERT INTO smart_package_tasks ( + package_id, task_name, task_description, task_order, + estimated_hours, hourly_rate, required_skills, required_tools, + is_optional, depends_on_task_id, installation_manual_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + packageId, + taskData.name, + taskData.description || '', + nextOrder, + taskData.hours || 0, + taskData.rate || 580, + taskData.skills ? JSON.stringify(taskData.skills) : null, + taskData.tools ? JSON.stringify(taskData.tools) : null, + taskData.optional ? 1 : 0, + taskData.dependsOn || null, + taskData.installationManualId || null + ]); + + const taskId = taskResult.insertId; + + // Opdater total estimated_hours for pakken + await this.db.pool.execute(` + UPDATE material_packages + SET estimated_hours = COALESCE(estimated_hours, 0) + ?, + updated_at = NOW() + WHERE id = ? + `, [taskData.hours || 0, packageId]); + + logger.info('Task added to package', { packageId, taskId }); + + return { + taskId, + order: nextOrder + }; + } catch (error) { + logger.error('Error adding task to package:', error); + throw error; + } + } + + /** + * Opdater en task + */ + async updateTask(taskId, taskData) { + try { + const [taskCheck] = await this.db.pool.execute(` + SELECT package_id, estimated_hours FROM smart_package_tasks WHERE id = ? + `, [taskId]); + + if (taskCheck.length === 0) { + throw new Error('Task not found'); + } + + const packageId = taskCheck[0].package_id; + const oldHours = taskCheck[0].estimated_hours || 0; + const newHours = taskData.hours || 0; + const hoursDiff = newHours - oldHours; + + // Opdater task + await this.db.pool.execute(` + UPDATE smart_package_tasks + SET + task_name = ?, + task_description = ?, + estimated_hours = ?, + hourly_rate = ?, + required_skills = ?, + required_tools = ?, + is_optional = ?, + depends_on_task_id = ?, + installation_manual_id = ? + WHERE id = ? + `, [ + taskData.name, + taskData.description || '', + newHours, + taskData.rate || 580, + taskData.skills ? JSON.stringify(taskData.skills) : null, + taskData.tools ? JSON.stringify(taskData.tools) : null, + taskData.optional ? 1 : 0, + taskData.dependsOn || null, + taskData.installationManualId || null, + taskId + ]); + + // Opdater pakkens samlede timer + if (hoursDiff !== 0) { + await this.db.pool.execute(` + UPDATE material_packages + SET estimated_hours = GREATEST(0, COALESCE(estimated_hours, 0) + ?), + updated_at = NOW() + WHERE id = ? + `, [hoursDiff, packageId]); + } + + logger.info('Task updated', { taskId, packageId }); + return { success: true }; + } catch (error) { + logger.error('Error updating task:', error); + throw error; + } + } + + /** + * Slet en task + */ + async deleteTask(taskId) { + try { + // Få information om tasken før sletning + const [taskInfo] = await this.db.pool.execute(` + SELECT package_id, estimated_hours FROM smart_package_tasks WHERE id = ? + `, [taskId]); + + if (taskInfo.length === 0) { + throw new Error('Task not found'); + } + + const packageId = taskInfo[0].package_id; + const hours = taskInfo[0].estimated_hours || 0; + + // Start en transaction + const connection = await this.db.pool.getConnection(); + await connection.beginTransaction(); + + try { + // Slet steps først + await connection.execute(` + DELETE FROM smart_package_steps WHERE task_id = ? + `, [taskId]); + + // Slet task + await connection.execute(` + DELETE FROM smart_package_tasks WHERE id = ? + `, [taskId]); + + // Opdater pakkens samlede timer + await connection.execute(` + UPDATE material_packages + SET estimated_hours = GREATEST(0, COALESCE(estimated_hours, 0) - ?), + updated_at = NOW() + WHERE id = ? + `, [hours, packageId]); + + // Reorder tasks + await connection.execute(` + SET @order = 0; + UPDATE smart_package_tasks + SET task_order = (@order := @order + 1) + WHERE package_id = ? + ORDER BY task_order; + `, [packageId]); + + // Commit transaktionen + await connection.commit(); + + logger.info('Task deleted', { taskId, packageId }); + return { success: true }; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } catch (error) { + logger.error('Error deleting task:', error); + throw error; + } + } + + /** + * Ændre rækkefølgen af tasks + */ + async reorderTasks(packageId, taskOrderings) { + try { + const connection = await this.db.pool.getConnection(); + await connection.beginTransaction(); + + try { + for (const item of taskOrderings) { + await connection.execute(` + UPDATE smart_package_tasks + SET task_order = ? + WHERE id = ? AND package_id = ? + `, [item.order, item.taskId, packageId]); + } + + await connection.commit(); + logger.info('Tasks reordered', { packageId, taskCount: taskOrderings.length }); + return { success: true }; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } catch (error) { + logger.error('Error reordering tasks:', error); + throw error; + } + } + + /** + * Tilføj et step til en task + */ + async addStepToTask(taskId, stepData) { + try { + // Tjek om task eksisterer + const [taskCheck] = await this.db.pool.execute(` + SELECT id, package_id FROM smart_package_tasks WHERE id = ? + `, [taskId]); + + if (taskCheck.length === 0) { + throw new Error('Task not found'); + } + + // Find næste step_order værdi + const [orderResult] = await this.db.pool.execute(` + SELECT COALESCE(MAX(step_order), 0) + 1 as next_order + FROM smart_package_steps + WHERE task_id = ? + `, [taskId]); + + const nextOrder = orderResult[0].next_order; + + // Indsæt step + const [stepResult] = await this.db.pool.execute(` + INSERT INTO smart_package_steps ( + task_id, step_order, step_title, step_description, estimated_minutes, + image_url, video_url, safety_notes, quality_check, tips + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + taskId, + nextOrder, + stepData.title, + stepData.description || '', + stepData.minutes || 0, + stepData.image || null, + stepData.video || null, + stepData.safety || null, + stepData.quality || null, + stepData.tips || null + ]); + + logger.info('Step added to task', { + taskId, + stepId: stepResult.insertId, + packageId: taskCheck[0].package_id + }); + + return { + stepId: stepResult.insertId, + order: nextOrder + }; + } catch (error) { + logger.error('Error adding step to task:', error); + throw error; + } + } + + /** + * Opdater et step + */ + async updateStep(stepId, stepData) { + try { + await this.db.pool.execute(` + UPDATE smart_package_steps + SET + step_title = ?, + step_description = ?, + estimated_minutes = ?, + image_url = ?, + video_url = ?, + safety_notes = ?, + quality_check = ?, + tips = ? + WHERE id = ? + `, [ + stepData.title, + stepData.description || '', + stepData.minutes || 0, + stepData.image || null, + stepData.video || null, + stepData.safety || null, + stepData.quality || null, + stepData.tips || null, + stepId + ]); + + logger.info('Step updated', { stepId }); + return { success: true }; + } catch (error) { + logger.error('Error updating step:', error); + throw error; + } + } + + /** + * Slet et step + */ + async deleteStep(stepId) { + try { + // Få information om step før sletning + const [stepInfo] = await this.db.pool.execute(` + SELECT task_id FROM smart_package_steps WHERE id = ? + `, [stepId]); + + if (stepInfo.length === 0) { + throw new Error('Step not found'); + } + + const taskId = stepInfo[0].task_id; + + // Start en transaction + const connection = await this.db.pool.getConnection(); + await connection.beginTransaction(); + + try { + // Slet step + await connection.execute(` + DELETE FROM smart_package_steps WHERE id = ? + `, [stepId]); + + // Reorder steps + await connection.execute(` + SET @order = 0; + UPDATE smart_package_steps + SET step_order = (@order := @order + 1) + WHERE task_id = ? + ORDER BY step_order; + `, [taskId]); + + // Commit transaktionen + await connection.commit(); + + logger.info('Step deleted', { stepId, taskId }); + return { success: true }; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } catch (error) { + logger.error('Error deleting step:', error); + throw error; + } + } + + /** + * Ændre rækkefølgen af steps + */ + async reorderSteps(taskId, stepOrderings) { + try { + const connection = await this.db.pool.getConnection(); + await connection.beginTransaction(); + + try { + for (const item of stepOrderings) { + await connection.execute(` + UPDATE smart_package_steps + SET step_order = ? + WHERE id = ? AND task_id = ? + `, [item.order, item.stepId, taskId]); + } + + await connection.commit(); + logger.info('Steps reordered', { taskId, stepCount: stepOrderings.length }); + return { success: true }; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } catch (error) { + logger.error('Error reordering steps:', error); + throw error; + } + } + + /** + * Tilføj pakke til project + */ + async addPackageToProject(projectId, packageId, options = {}) { + try { + // Hent pakke med detaljer + const packageData = await this.getPackageDetails(packageId); + + if (!packageData) { + throw new Error('Package not found'); + } + + // Start en transaction + const connection = await this.db.pool.getConnection(); + await connection.beginTransaction(); + + try { + // Log brugen af pakken + await connection.execute(` + INSERT INTO smart_package_usage ( + package_id, project_id, used_by + ) VALUES (?, ?, ?) + `, [ + packageId, + projectId, + options.userId || 'system' + ]); + + // Opdater popularity_score og last_used_at + await connection.execute(` + UPDATE material_packages + SET popularity_score = popularity_score + 1, + last_used_at = NOW() + WHERE id = ? + `, [packageId]); + + // Tilføj materialer til projektet + for (const material of packageData.materials) { + // Beregn mængde baseret på geometri hvis relevant + let quantity = parseFloat(material.quantity); + + if (options.geometry) { + // Anvend geometri multiplier hvis specificeret + if (material.geometry_multiplier === 'area' && options.geometry.total_area) { + quantity = quantity * options.geometry.total_area; + } else if (material.geometry_multiplier === 'length' && options.geometry.length_main) { + quantity = quantity * options.geometry.length_main; + } + } + + // Anvend bruger-specificeret multiplier + if (options.multiplier) { + quantity = quantity * parseFloat(options.multiplier); + } + + // Tilføj materiale til projektet + await connection.execute(` + INSERT INTO project_materials ( + project_id, material_name, material_category, quantity, unit, + unit_price, total_price, supplier, material_source, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + projectId, + material.material_name, + material.material_category, + quantity, + material.unit, + material.unit_price, + quantity * material.unit_price, + material.supplier, + `Smart Pakke: ${packageData.name}`, + material.notes || '' + ]); + } + + // Tilføj arbejdsopgaver til projektet + if (packageData.tasks && packageData.tasks.length > 0) { + for (const task of packageData.tasks) { + // Beregn timer baseret på geometri hvis relevant + let hours = parseFloat(task.estimated_hours); + + if (options.geometry && task.geometry_multiplier) { + if (task.geometry_multiplier === 'area' && options.geometry.total_area) { + hours = hours * options.geometry.total_area; + } else if (task.geometry_multiplier === 'length' && options.geometry.length_main) { + hours = hours * options.geometry.length_main; + } + } + + // Anvend bruger-specificeret multiplier + if (options.multiplier) { + hours = hours * parseFloat(options.multiplier); + } + + // Tilføj opgave til projektet + await connection.execute(` + INSERT INTO project_tasks ( + project_id, task_name, task_description, + estimated_hours, hourly_rate, task_source + ) VALUES (?, ?, ?, ?, ?, ?) + `, [ + projectId, + task.task_name, + task.task_description, + hours, + task.hourly_rate, + `Smart Pakke: ${packageData.name}` + ]); + } + } + + // Commit transaktionen + await connection.commit(); + + logger.info('Package added to project', { + projectId, + packageId, + materialCount: packageData.materials.length, + taskCount: packageData.tasks ? packageData.tasks.length : 0 + }); + + return { + success: true, + materials_added: packageData.materials.length, + tasks_added: packageData.tasks ? packageData.tasks.length : 0, + total_material_cost: packageData.total_estimated_price, + total_labor_cost: packageData.estimated_hours * packageData.hourly_rate + }; + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + } catch (error) { + logger.error('Error adding package to project:', error); + throw error; + } + } + + /** + * Generer arbejdsbeskrivelse for en pakke + */ + async generateWorkDescription(packageId) { + try { + const packageData = await this.getPackageDetails(packageId); + + if (!packageData) { + throw new Error('Package not found'); + } + + let description = `# ${packageData.name}\n\n`; + + if (packageData.description) { + description += `${packageData.description}\n\n`; + } + + if (packageData.installation_notes) { + description += `## Generelle installationsnoter\n${packageData.installation_notes}\n\n`; + } + + // Tilføj arbejdsopgaver + if (packageData.tasks && packageData.tasks.length > 0) { + description += `## Arbejdsopgaver\n\n`; + + for (let i = 0; i < packageData.tasks.length; i++) { + const task = packageData.tasks[i]; + + description += `### ${i+1}. ${task.task_name}\n`; + + if (task.task_description) { + description += `${task.task_description}\n\n`; + } + + if (task.estimated_hours) { + description += `**Estimeret tidsforbrug:** ${task.estimated_hours} timer\n\n`; + } + + if (task.required_skills && task.required_skills.length > 0) { + description += `**Krævede færdigheder:** ${task.required_skills.join(', ')}\n\n`; + } + + if (task.required_tools && task.required_tools.length > 0) { + description += `**Nødvendige værktøjer:** ${task.required_tools.join(', ')}\n\n`; + } + + // Tilføj steps hvis der er nogen + if (task.steps && task.steps.length > 0) { + description += `#### Fremgangsmåde:\n`; + + for (let j = 0; j < task.steps.length; j++) { + const step = task.steps[j]; + + description += `${j+1}. **${step.step_title}** `; + description += `(${step.estimated_minutes} min) - ${step.step_description}\n`; + + if (step.safety_notes) { + description += ` ⚠️ *Sikkerhed: ${step.safety_notes}*\n`; + } + + if (step.tips) { + description += ` 💡 *Tip: ${step.tips}*\n`; + } + } + + description += `\n`; + } + } + } + + // Tilføj materialeliste + if (packageData.materials && packageData.materials.length > 0) { + description += `## Materialeliste\n\n`; + description += `| Materiale | Mængde | Enhed | Enhedspris |\n`; + description += `|-----------|--------|-------|------------|\n`; + + for (const material of packageData.materials) { + description += `| ${material.material_name} | ${material.quantity} | ${material.unit} | ${material.unit_price} kr |\n`; + } + + description += `\n**Total materialepris:** ${packageData.total_estimated_price} kr\n\n`; + } + + // Tilføj total pris + const laborCost = packageData.estimated_hours * packageData.hourly_rate; + description += `## Samlet oversigt\n\n`; + description += `- Materialepris: ${packageData.total_estimated_price} kr\n`; + description += `- Arbejdsløn: ${packageData.estimated_hours} timer × ${packageData.hourly_rate} kr/time = ${laborCost} kr\n`; + description += `- **Total pris:** ${packageData.total_estimated_price + laborCost} kr\n`; + + return description; + } catch (error) { + logger.error('Error generating work description:', error); + throw error; + } + } + + /** + * Hent pakke kategorier og typer + */ + async getPackageCategories() { + try { + const [categoryRows] = await this.db.pool.execute(` + SELECT DISTINCT category + FROM material_packages + WHERE is_active = 1 + ORDER BY category + `); + + const [typeRows] = await this.db.pool.execute(` + SELECT + package_type, + COUNT(*) as package_count + FROM material_packages + WHERE is_active = 1 + GROUP BY package_type + ORDER BY package_count DESC + `); + + return { + categories: categoryRows.map(row => row.category), + types: typeRows.map(row => ({ + type: row.package_type, + count: row.package_count + })) + }; + } catch (error) { + logger.error('Error getting package categories:', error); + throw error; + } + } + + /** + * Få relaterede pakker baseret på type, kategori, etc. + */ + async getRelatedPackages(packageId, limit = 5) { + try { + // Hent info om den valgte pakke + const [packageRows] = await this.db.pool.execute(` + SELECT package_type, category + FROM material_packages + WHERE id = ? AND is_active = 1 + `, [packageId]); + + if (packageRows.length === 0) { + throw new Error('Package not found'); + } + + const packageType = packageRows[0].package_type; + const category = packageRows[0].category; + + // Find relaterede pakker + const [relatedRows] = await this.db.pool.execute(` + SELECT + mp.id, + mp.name, + mp.description, + mp.category, + mp.package_type, + mp.estimated_hours, + mp.total_estimated_price, + mp.popularity_score + FROM material_packages mp + WHERE mp.is_active = 1 + AND mp.id != ? + AND (mp.package_type = ? OR mp.category = ?) + ORDER BY + CASE + WHEN mp.package_type = ? AND mp.category = ? THEN 1 + WHEN mp.package_type = ? THEN 2 + WHEN mp.category = ? THEN 3 + ELSE 4 + END, + mp.popularity_score DESC + LIMIT ? + `, [packageId, packageType, category, packageType, category, packageType, category, limit]); + + return relatedRows; + } catch (error) { + logger.error('Error getting related packages:', error); + throw error; + } + } + + /** + * Hent alle tasks på tværs af pakker + */ + async getAllTasks() { + try { + const [taskRows] = await this.db.pool.execute(` + SELECT + spt.*, + mp.name as package_name, + mp.category as package_category + FROM smart_package_tasks spt + LEFT JOIN material_packages mp ON spt.package_id = mp.id + WHERE mp.is_active = 1 + ORDER BY mp.category, mp.name, spt.task_order + `); + + const tasks = []; + + for (const task of taskRows) { + // Hent steps for hver task + const [stepRows] = await this.db.pool.execute(` + SELECT * FROM smart_package_steps + WHERE task_id = ? + ORDER BY step_order + `, [task.id]); + + // Parse JSON felter + const requiredSkills = task.required_skills ? JSON.parse(task.required_skills) : []; + const requiredTools = task.required_tools ? JSON.parse(task.required_tools) : []; + + tasks.push({ + ...task, + required_skills: requiredSkills, + required_tools: requiredTools, + steps: stepRows + }); + } + + return tasks; + } catch (error) { + logger.error('Error getting all tasks:', error); + throw error; + } + } +} + +module.exports = SmartPackageManagementService; \ No newline at end of file diff --git a/build-and-test-smartpackages.sh b/build-and-test-smartpackages.sh new file mode 100755 index 0000000..a54ab7c --- /dev/null +++ b/build-and-test-smartpackages.sh @@ -0,0 +1,109 @@ +#!/bin/bash + +# Smart Packages System Build og Test Script +# Dette script bygger og tester Smart Packages funktionaliteten + +# Farver til output +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}=========================================${NC}" +echo -e "${BLUE} Smart Packages System - Build & Test ${NC}" +echo -e "${BLUE}=========================================${NC}" + +# Kontroller at vi er i projektets rod +if [ ! -f "./unified-server.js" ]; then + echo -e "${RED}Error: Scriptet skal køres fra projektets rodmappe${NC}" + exit 1 +fi + +# Funktion til at vise progress +show_progress() { + local pid=$1 + local delay=0.1 + local spinstr='|/-\' + while [ "$(ps a | awk '{print $1}' | grep -w $pid)" ]; do + local temp=${spinstr#?} + printf " [%c] " "$spinstr" + local spinstr=$temp${spinstr%"$temp"} + sleep $delay + printf "\b\b\b\b\b\b" + done + printf " \b\b\b\b" +} + +echo -e "\n${YELLOW}Trin 1: Opretter database tabeller${NC}" + +# Kører SQL script +echo -e "Kører setup_smart_packages.sql..." +mysql -u tilbudgivern_service -p'REDACTED_PASSWORD' tilbudgivern < setup_smart_packages.sql +if [ $? -eq 0 ]; then + echo -e "${GREEN}✓ Database tabeller oprettet succesfuldt${NC}" +else + echo -e "${RED}✗ Fejl ved oprettelse af database tabeller${NC}" + exit 1 +fi + +echo -e "\n${YELLOW}Trin 2: Bygger frontend${NC}" +echo -e "Navigerer til frontend mappen..." +cd frontend + +echo -e "Installerer eventuelle nye afhængigheder..." +npm install & +show_progress $! + +echo -e "Bygger frontend pakken..." +npm run build & +show_progress $! + +if [ -d "build" ]; then + echo -e "${GREEN}✓ Frontend bygget succesfuldt${NC}" +else + echo -e "${RED}✗ Fejl ved bygning af frontend${NC}" + exit 1 +fi + +# Tilbage til projektets rod +cd .. + +echo -e "\n${YELLOW}Trin 3: Genstarter serveren${NC}" +echo -e "Stopper nuværende server proces..." + +# Find og stop server proces - tilpas efter behov +pkill -f "node unified-server.js" || true + +echo -e "Starter serveren igen..." +nohup node unified-server.js > server.log 2>&1 & +SERVER_PID=$! + +# Vent lidt på at serveren starter op +echo -e "Venter på at serveren starter op..." +sleep 5 + +if ps -p $SERVER_PID > /dev/null; then + echo -e "${GREEN}✓ Server genstartet succesfuldt med PID $SERVER_PID${NC}" +else + echo -e "${RED}✗ Fejl ved genstart af server${NC}" + cat server.log + exit 1 +fi + +echo -e "\n${YELLOW}Trin 4: Tester API endpoints${NC}" +echo -e "Tester GET /api/smart-packages endpoint..." +RESPONSE=$(curl -s http://localhost:4031/api/smart-packages) + +if [[ $RESPONSE == *"\"success\""* ]]; then + echo -e "${GREEN}✓ GET /api/smart-packages fungerer korrekt${NC}" +else + echo -e "${RED}✗ Fejl ved test af GET /api/smart-packages${NC}" + echo -e "Response: $RESPONSE" +fi + +echo -e "\n${GREEN}================================${NC}" +echo -e "${GREEN} Smart Packages System er klar! ${NC}" +echo -e "${GREEN}================================${NC}" +echo -e "\nDu kan nu tilgå Smart Packages via menuen i applikationen." +echo -e "Server kører med PID $SERVER_PID" \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1d06a92..11753a4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@mui/material": "^5.15.1", "axios": "^1.12.2", "http-proxy-middleware": "^3.0.5", + "notistack": "^3.0.2", "pdf-parse": "^2.4.4", "playwright": "^1.56.0", "react": "^18.2.0", @@ -4303,9 +4304,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.1.13", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", - "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", "peer": true, "dependencies": { @@ -9285,6 +9286,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -12204,6 +12214,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/notistack": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/notistack/-/notistack-3.0.2.tgz", + "integrity": "sha512-0R+/arLYbK5Hh7mEfR2adt0tyXJcCC9KkA2hc56FeWik2QN6Bm/S4uW+BjzDARsJth5u06nTjelSw/VSnB1YEA==", + "license": "MIT", + "dependencies": { + "clsx": "^1.1.0", + "goober": "^2.0.33" + }, + "engines": { + "node": ">=12.0.0", + "npm": ">=6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/notistack" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/notistack/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7c73d0c..4f5746d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "@mui/material": "^5.15.1", "axios": "^1.12.2", "http-proxy-middleware": "^3.0.5", + "notistack": "^3.0.2", "pdf-parse": "^2.4.4", "playwright": "^1.56.0", "react": "^18.2.0", diff --git a/frontend/src/App.js b/frontend/src/App.js index 8293dd3..6f33bb2 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,10 +1,12 @@ import React, { useState, useEffect } from 'react'; import axios from 'axios'; +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import './App.css'; import ProjectFlow from './components/ProjectFlow'; import MaterialsList from './MaterialsList'; import PlanningDashboard from './components/PlanningDashboard'; import AnalyticsDashboard from './components/AnalyticsDashboard'; +import SmartPackagesRoutes from './routes/SmartPackagesRoutes'; import './components/ProjectReview.css'; const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:4031'; @@ -155,6 +157,13 @@ function App() { > 🧱 Materialer + + + + + + + {/* Materialer liste */} + {loading ? ( + Indlæser materialer... + ) : materials.length === 0 ? ( + Ingen materialer fundet. Prøv at ændre søgning. + ) : ( +
+ {/* Implementering af materialer liste */} + + + {materials.length} materialer fundet + + {/* Materialeliste implementering kommer her */} + +
+ )} + ); }; diff --git a/frontend/src/components/TaskDialog.js b/frontend/src/components/TaskDialog.js new file mode 100644 index 0000000..926971d --- /dev/null +++ b/frontend/src/components/TaskDialog.js @@ -0,0 +1,745 @@ +import React, { useState, useEffect } from 'react'; +import { + Dialog, DialogTitle, DialogContent, DialogActions, + Button, TextField, Typography, FormControl, + FormControlLabel, Switch, InputLabel, Select, MenuItem, + Grid, IconButton, Paper, Box, Tooltip, Chip, + Stepper, Step, StepLabel, StepContent, Alert, Divider +} from '@mui/material'; +import { + Add as AddIcon, + Delete as DeleteIcon, + Check as CheckIcon, + Edit as EditIcon, + ArrowUpward as ArrowUpwardIcon, + ArrowDownward as ArrowDownwardIcon, + DragHandle as DragHandleIcon, + Image as ImageIcon, + VideoLibrary as VideoIcon, + Security as SecurityIcon, + Verified as VerifiedIcon, + Lightbulb as TipsIcon, + Timeline as TimelineIcon, + Warning as WarningIcon +} from '@mui/icons-material'; +import { useSnackbar } from 'notistack'; + +/** + * Dialog til oprettelse og redigering af en opgave (task) i en smart pakke + * Med håndtering af monteringstrin (steps) og afhængigheder til andre opgaver + */ +const TaskDialog = ({ + open, + onClose, + onSave, + task = null, + allTasks = [], + apiBaseUrl = '' +}) => { + const { enqueueSnackbar } = useSnackbar(); + + // Task data state + const [taskData, setTaskData] = useState({ + name: '', + description: '', + hours: 0, + rate: 580, + skills: '', + tools: '', + optional: false, + dependsOn: '', + installationManualId: null + }); + + // Steps state + const [steps, setSteps] = useState([]); + const [newStep, setNewStep] = useState({ + title: '', + description: '', + minutes: 15, + image: '', + video: '', + safety: '', + quality: '', + tips: '' + }); + const [editingStepIndex, setEditingStepIndex] = useState(-1); + const [showAddStep, setShowAddStep] = useState(false); + + // Hjælpevariable + const [totalTime, setTotalTime] = useState(0); + const [activeStep, setActiveStep] = useState(0); + const [errors, setErrors] = useState({}); + const isEditMode = task !== null; + + // Indlæs eksisterende data ved redigering + useEffect(() => { + if (task) { + setTaskData({ + name: task.name || '', + description: task.description || '', + hours: task.hours || 0, + rate: task.rate || 580, + skills: task.skills || '', + tools: task.tools || '', + optional: task.optional || false, + dependsOn: task.depends_on_task_id || '', + installationManualId: task.installation_manual_id || null + }); + + // Hent monteringstrin hvis opgaven har et installations-manual ID + if (task.installation_manual_id) { + fetchSteps(task.installation_manual_id); + } + } else { + resetForm(); + } + }, [task]); + + // Opdater total time når steps ændres + useEffect(() => { + const totalMinutes = steps.reduce((total, step) => total + (parseFloat(step.minutes) || 0), 0); + const totalHours = Math.round((totalMinutes / 60) * 100) / 100; // Round to 2 decimals + setTotalTime(totalHours); + + if (totalHours > 0) { + setTaskData(prev => ({ + ...prev, + hours: totalHours + })); + } + }, [steps]); + + // Hent steps for et installations-manual ID + const fetchSteps = async (manualId) => { + try { + const response = await fetch(`${apiBaseUrl}/api/smart-packages/installation-manuals/${manualId}`); + const data = await response.json(); + + if (data.success && data.steps) { + setSteps(data.steps); + } + } catch (error) { + console.error('Fejl ved hentning af monteringstrin:', error); + enqueueSnackbar('Kunne ikke hente monteringstrin', { variant: 'error' }); + } + }; + + // Håndter ændringer i task data + const handleChange = (field) => (event) => { + const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value; + setTaskData({ + ...taskData, + [field]: value + }); + + // Clear error for the field + if (errors[field]) { + setErrors({ + ...errors, + [field]: null + }); + } + }; + + // Håndter ændringer i new step data + const handleStepChange = (field) => (event) => { + const value = event.target.value; + setNewStep({ + ...newStep, + [field]: value + }); + }; + + // Validér form data + const validateForm = () => { + const newErrors = {}; + if (!taskData.name.trim()) { + newErrors.name = 'Opgavenavn er påkrævet'; + } + if (taskData.hours <= 0) { + newErrors.hours = 'Timer skal være større end 0'; + } + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + // Tilføj et nyt monteringstrin + const handleAddStep = () => { + if (!newStep.title.trim()) { + enqueueSnackbar('Trin-titel er påkrævet', { variant: 'error' }); + return; + } + + if (editingStepIndex >= 0) { + // Opdater eksisterende trin + const updatedSteps = [...steps]; + updatedSteps[editingStepIndex] = newStep; + setSteps(updatedSteps); + setEditingStepIndex(-1); + } else { + // Tilføj nyt trin + setSteps([...steps, newStep]); + } + + // Nulstil formular + setNewStep({ + title: '', + description: '', + minutes: 15, + image: '', + video: '', + safety: '', + quality: '', + tips: '' + }); + + setShowAddStep(false); + }; + + // Rediger et monteringstrin + const handleEditStep = (index) => { + setNewStep({ ...steps[index] }); + setEditingStepIndex(index); + setShowAddStep(true); + }; + + // Slet et monteringstrin + const handleDeleteStep = (index) => { + const updatedSteps = steps.filter((_, i) => i !== index); + setSteps(updatedSteps); + }; + + // Flyt et trin op eller ned + const handleMoveStep = (index, direction) => { + if ((direction === 'up' && index === 0) || + (direction === 'down' && index === steps.length - 1)) { + return; + } + + const updatedSteps = [...steps]; + const newIndex = direction === 'up' ? index - 1 : index + 1; + + const temp = updatedSteps[index]; + updatedSteps[index] = updatedSteps[newIndex]; + updatedSteps[newIndex] = temp; + + setSteps(updatedSteps); + }; + + // Gem opgave og monteringstrin + const handleSave = async () => { + if (!validateForm()) { + enqueueSnackbar('Ret venligst fejlene før du gemmer', { variant: 'error' }); + return; + } + + try { + const saveData = { + ...taskData, + steps: steps + }; + + onSave(saveData); + resetForm(); + setActiveStep(0); + onClose(); + } catch (error) { + console.error('Fejl ved gemning af opgave:', error); + enqueueSnackbar('Fejl ved gemning af opgave', { variant: 'error' }); + } + }; + + // Nulstil formular + const resetForm = () => { + setTaskData({ + name: '', + description: '', + hours: 0, + rate: 580, + skills: '', + tools: '', + optional: false, + dependsOn: '', + installationManualId: null + }); + setSteps([]); + setNewStep({ + title: '', + description: '', + minutes: 15, + image: '', + video: '', + safety: '', + quality: '', + tips: '' + }); + setEditingStepIndex(-1); + setShowAddStep(false); + setErrors({}); + }; + + // Handle stepper next/back + const handleNext = () => { + if (activeStep === 0 && !validateForm()) { + return; + } + setActiveStep((prevActiveStep) => prevActiveStep + 1); + }; + + const handleBack = () => { + setActiveStep((prevActiveStep) => prevActiveStep - 1); + }; + + return ( + + + {isEditMode ? `Rediger opgave: ${task.name}` : 'Tilføj ny opgave'} + + + + + {/* Step 1: Grundlæggende opgavedetaljer */} + + Opgavedetaljer + + + + + + + + + + + + + + + + 0 ? ( + + } + label="Auto" + color="primary" + /> + + ) : 'timer' + }} + /> + + + + + + + + + + + + + Afhænger af + + + + + + + + } + label="Valgfri opgave" + /> + + + + + + + + + + + + + {/* Step 2: Monteringsvejledning */} + + Monteringsvejledning + + + + + Trin for monteringsvejledning + + + Tilføj detaljerede monteringstrin for at give en præcis beskrivelse af arbejdet. + Dette hjælper håndværkeren med at udføre opgaven korrekt og effektivt. + + + + {steps.length > 0 ? ( + + + + + Monteringstrin ({steps.length}) + + + Samlet estimeret tid: {totalTime} timer ({steps.reduce((total, step) => total + (parseInt(step.minutes) || 0), 0)} minutter) + + + {steps.map((step, index) => ( + + + + {index + 1}. {step.title} + + + {step.minutes} min + + + + + {step.description} + + + + {step.image && ( + } label="Billede" /> + )} + {step.video && ( + } label="Video" /> + )} + {step.safety && ( + } label="Sikkerhed" color="error" /> + )} + {step.quality && ( + } label="Kvalitet" color="info" /> + )} + {step.tips && ( + } label="Tips" color="success" /> + )} + + + + handleMoveStep(index, 'up')} + disabled={index === 0} + > + + + handleMoveStep(index, 'down')} + disabled={index === steps.length - 1} + > + + + handleEditStep(index)} + color="primary" + > + + + handleDeleteStep(index)} + color="error" + > + + + + + ))} + + + ) : ( + + + Ingen monteringstrin tilføjet endnu. Tilføj trin for at give detaljeret vejledning. + + + )} + + + {showAddStep ? ( + + + {editingStepIndex >= 0 ? 'Rediger trin' : 'Tilføj nyt trin'} + + + + + + + + + + + + + + + + + + }} + /> + + + + + }} + /> + + + + + }} + /> + + + + + }} + /> + + + + + }} + /> + + + + + + + + + ) : ( + + )} + + + + + + + + + + + + + + + + + {activeStep === 0 ? ( + + ) : null} + + + ); +}; + +export default TaskDialog; \ No newline at end of file diff --git a/frontend/src/components/common/ConfirmDialog.js b/frontend/src/components/common/ConfirmDialog.js new file mode 100644 index 0000000..a92bbab --- /dev/null +++ b/frontend/src/components/common/ConfirmDialog.js @@ -0,0 +1,64 @@ +import React from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + Button +} from '@mui/material'; + +/** + * ConfirmDialog Component + * + * Bekræftelsesdialog til farlige handlinger + */ +const ConfirmDialog = ({ + open, + title, + message, + confirmText = 'Bekræft', + cancelText = 'Annuller', + onConfirm, + onCancel, + severity = 'warning' // 'warning', 'error', 'info' +}) => { + const getColor = () => { + switch (severity) { + case 'error': + return 'error'; + case 'info': + return 'primary'; + default: + return 'warning'; + } + }; + + return ( + + + {title} + + + + {message} + + + + + + + + ); +}; + +export default ConfirmDialog; diff --git a/frontend/src/components/common/EmptyState.js b/frontend/src/components/common/EmptyState.js new file mode 100644 index 0000000..3232e0c --- /dev/null +++ b/frontend/src/components/common/EmptyState.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { Box, Typography, Button } from '@mui/material'; + +/** + * EmptyState Component + * + * Viser en tom tilstand med ikon, besked og valgfri handling + */ +const EmptyState = ({ + title, + message, + actionText, + onAction, + icon +}) => { + return ( + + {icon && ( + + {icon} + + )} + + + {title} + + + {message && ( + + {message} + + )} + + {actionText && onAction && ( + + )} + + ); +}; + +export default EmptyState; diff --git a/frontend/src/components/common/LoadingOverlay.js b/frontend/src/components/common/LoadingOverlay.js new file mode 100644 index 0000000..545e09e --- /dev/null +++ b/frontend/src/components/common/LoadingOverlay.js @@ -0,0 +1,38 @@ +import React from 'react'; +import { Box, CircularProgress, Typography, Backdrop } from '@mui/material'; + +/** + * LoadingOverlay Component + * + * Viser en loading overlay med spinner og tekst + */ +const LoadingOverlay = ({ text = 'Indlæser...', open = true }) => { + return ( + theme.zIndex.drawer + 1, + backgroundColor: 'rgba(255, 255, 255, 0.9)' + }} + open={open} + > + + + {text && ( + + {text} + + )} + + + ); +}; + +export default LoadingOverlay; diff --git a/frontend/src/components/common/PageHeader.js b/frontend/src/components/common/PageHeader.js new file mode 100644 index 0000000..ded8a02 --- /dev/null +++ b/frontend/src/components/common/PageHeader.js @@ -0,0 +1,31 @@ +import React from 'react'; +import { Box, Typography } from '@mui/material'; + +/** + * PageHeader Component + * + * Standardiseret sidehovede med titel, undertitel og ikon + */ +const PageHeader = ({ title, subtitle, icon }) => { + return ( + + + {icon && ( + + {icon} + + )} + + {title} + + + {subtitle && ( + + {subtitle} + + )} + + ); +}; + +export default PageHeader; diff --git a/frontend/src/components/smartPackages/PackageDetailsDialog.js b/frontend/src/components/smartPackages/PackageDetailsDialog.js new file mode 100644 index 0000000..4275ffe --- /dev/null +++ b/frontend/src/components/smartPackages/PackageDetailsDialog.js @@ -0,0 +1,706 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { + Dialog, DialogTitle, DialogContent, DialogActions, Button, + Typography, Box, Divider, Grid, Chip, IconButton, Tabs, Tab, + List, ListItem, ListItemText, ListItemIcon, ListItemSecondaryAction, + Accordion, AccordionSummary, AccordionDetails, Card, CardContent, + LinearProgress, Paper, TableContainer, Table, TableHead, TableBody, TableRow, TableCell, + Tooltip +} from '@mui/material'; +import { + Close as CloseIcon, Edit as EditIcon, Description as DescriptionIcon, + CheckCircle as CheckCircleIcon, Assignment as AssignmentIcon, + Build as BuildIcon, Category as CategoryIcon, Timer as TimerIcon, + AttachMoney as MoneyIcon, ListAlt as ListAltIcon, ViewList as ViewListIcon, + ExpandMore as ExpandMoreIcon, Print as PrintIcon, Add as AddIcon, + Info as InfoIcon, Star as StarIcon +} from '@mui/icons-material'; +import { useSnackbar } from 'notistack'; +import axios from 'axios'; + +// Hjælpefunktioner +import { formatCurrency, formatTime, formatDate } from '../../utils/formatters'; + +/** + * Dialog til at vise detaljer om en Smart Pakke + */ +const PackageDetailsDialog = ({ open, onClose, packageId, onEdit }) => { + const { enqueueSnackbar } = useSnackbar(); + + // State + const [packageData, setPackageData] = useState(null); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState(0); + const [expandedTaskId, setExpandedTaskId] = useState(null); + + // Hent pakkedetaljer + useEffect(() => { + if (open && packageId) { + fetchPackageDetails(); + } + }, [open, packageId]); + + // Hent pakkedetaljer fra API + const fetchPackageDetails = async () => { + setLoading(true); + try { + const response = await axios.get(`/api/smart-packages/${packageId}`); + + if (response.data.success) { + setPackageData(response.data.package); + } else { + enqueueSnackbar('Kunne ikke hente pakkedetaljer: ' + response.data.error, { variant: 'error' }); + onClose(); + } + } catch (error) { + console.error('Fejl ved hentning af pakkedetaljer:', error); + enqueueSnackbar('Fejl ved hentning af pakkedetaljer', { variant: 'error' }); + onClose(); + } finally { + setLoading(false); + } + }; + + // Print arbejdsbeskrivelse + const handlePrintWorkDescription = async () => { + try { + const response = await axios.get(`/api/smart-packages/${packageId}/work-description`); + + if (response.data.success) { + // Opret et nyt vindue og indsæt arbejdsbeskrivelsen + const printWindow = window.open('', '_blank'); + printWindow.document.write(` + + + Arbejdsbeskrivelse - ${packageData.name} + + + + ${response.data.workDescription} + + + + + `); + printWindow.document.close(); + } else { + enqueueSnackbar('Kunne ikke generere arbejdsbeskrivelse: ' + response.data.error, { variant: 'error' }); + } + } catch (error) { + console.error('Fejl ved generering af arbejdsbeskrivelse:', error); + enqueueSnackbar('Fejl ved generering af arbejdsbeskrivelse', { variant: 'error' }); + } + }; + + // Skift fane + const handleTabChange = (event, newValue) => { + setActiveTab(newValue); + }; + + // Håndter task udvid/skjul + const handleExpandTask = (taskId) => { + setExpandedTaskId(expandedTaskId === taskId ? null : taskId); + }; + + // Få sværhedsgrad som stjerner + const renderDifficultyStars = (level) => { + return ( + + {[...Array(level || 1)].map((_, i) => ( + + ))} + {[...Array(4 - (level || 1))].map((_, i) => ( + + ))} + + ); + }; + + // Hjælper funktion til at få sværhedsgrad tekst + const getDifficultyText = (level) => { + switch (level) { + case 1: return 'Let'; + case 2: return 'Middel'; + case 3: return 'Svær'; + case 4: return 'Ekspert'; + default: return 'Ukendt'; + } + }; + + // Beregn total for materialer + const calculateTotalMaterialCost = (materials) => { + return materials.reduce((sum, material) => { + const price = material.custom_price !== null ? material.custom_price : material.price; + return sum + (price * material.quantity); + }, 0); + }; + + // Hvis der indlæses + if (loading) { + return ( + + + Indlæser pakkedetaljer... + + + + + + + + Henter pakkedetaljer... + + + + ); + } + + // Hvis pakke ikke findes + if (!packageData) { + return null; + } + + return ( + + + + + {packageData.name} + + + + + + + + + + + + + + + + + + + {packageData.description} + + + + } + label={packageData.category || 'Ingen kategori'} + sx={{ mr: 1, mb: 1 }} + /> + + } + label={packageData.package_type || 'Standard'} + sx={{ mr: 1, mb: 1 }} + /> + + } + label={`${formatTime(packageData.estimated_hours)}`} + sx={{ mr: 1, mb: 1 }} + /> + + } + label={formatCurrency(packageData.totalPrice || 0)} + sx={{ mr: 1, mb: 1 }} + /> + + + + Sværhedsgrad: + + {renderDifficultyStars(packageData.difficulty_level)} + + + {packageData.is_template && ( + + )} + + + + + + + } iconPosition="start" /> + } iconPosition="start" /> + } iconPosition="start" /> + + + + {/* Opgave fane */} + {activeTab === 0 && ( +
+ {packageData.tasks && packageData.tasks.length > 0 ? ( + packageData.tasks.map((task) => ( + handleExpandTask(task.id)} + sx={{ mb: 1 }} + > + }> + + + + {task.name} + + + + + } + sx={{ mr: 1 }} + /> + + {task.optional && ( + + )} + + + + + + + + + {task.description || 'Ingen beskrivelse'} + + + {task.skills && ( + + Påkrævede færdigheder: + {task.skills} + + )} + + {task.tools && ( + + Nødvendigt værktøj: + {task.tools} + + )} + + + {task.steps && task.steps.length > 0 && ( + + + Trin: + + + + {task.steps.map((step, index) => ( + + + + {index + 1}. + + + + + {step.description && ( + + {step.description} + + )} + + {step.safety && ( + + Sikkerhed: {step.safety} + + )} + + {step.quality && ( + + Kvalitetskrav: {step.quality} + + )} + + {step.tips && ( + + Tips: {step.tips} + + )} + + } + /> + + {step.minutes > 0 && ( + + + + )} + + ))} + + + )} + + {/* Hvis task har montagevejledning */} + {task.installationManual && ( + + + Tilknyttet montagevejledning: + + + + + + {task.installationManual.title} + + + {task.installationManual.document_url && ( + + )} + + + + )} + + + )) + ) : ( + + + Denne pakke har ingen opgaver + + + )} +
+ )} + + {/* Materialer fane */} + {activeTab === 1 && ( +
+ {packageData.materials && packageData.materials.length > 0 ? ( + + + + + Materiale + Beskrivelse + Antal + Enhed + Stk. pris + Total + + + + {packageData.materials.map((material) => { + const price = material.custom_price !== null ? material.custom_price : material.price; + const total = price * material.quantity; + + return ( + + {material.material_name} + {material.material_description || '-'} + + {material.quantity} + {material.quantity_formula && ( + + + + )} + + {material.unit} + {formatCurrency(price)} + {formatCurrency(total)} + + ); + })} + + + Total: + + {formatCurrency(calculateTotalMaterialCost(packageData.materials))} + + + +
+
+ ) : ( + + + Denne pakke har ingen materialer + + + )} +
+ )} + + {/* Detaljer fane */} + {activeTab === 2 && ( + + + + Generelle oplysninger + + + + + + + + Kategori + + + {packageData.category || '-'} + + + + + + Type + + + {packageData.package_type || '-'} + + + + + + Sværhedsgrad + + + {getDifficultyText(packageData.difficulty_level)} + + + + + + Skabelon + + + {packageData.is_template ? 'Ja' : 'Nej'} + + + + + + Oprettet + + + {formatDate(packageData.created_at)} + + + + + + Senest opdateret + + + {formatDate(packageData.updated_at)} + + + + + + Oprettet af + + + {packageData.created_by || 'System'} + + + + + + + + + + Prissammenfatning + + + + + + + + Materialer + + + {formatCurrency(packageData.totalMaterialPrice || 0)} + + + + + + Estimeret tid + + + {formatTime(packageData.totalHours || 0)} + + + + + + Timepris + + + {formatCurrency(packageData.hourly_rate || 0)}/time + + + + + + Arbejdsløn + + + {formatCurrency((packageData.totalHours || 0) * (packageData.hourly_rate || 0))} + + + + + + + Total pris: {formatCurrency(packageData.totalPrice || 0)} + + + + + + + {packageData.installation_notes && ( + + + Installationsnoter + + + + + + {packageData.installation_notes} + + + + + )} + + + )} +
+
+ + + + + + +
+ ); +}; + +PackageDetailsDialog.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + packageId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + onEdit: PropTypes.func +}; + +PackageDetailsDialog.defaultProps = { + onEdit: () => {} +}; + +export default PackageDetailsDialog; \ No newline at end of file diff --git a/frontend/src/components/smartPackages/SmartPackageForm.js b/frontend/src/components/smartPackages/SmartPackageForm.js new file mode 100644 index 0000000..ae0fd00 --- /dev/null +++ b/frontend/src/components/smartPackages/SmartPackageForm.js @@ -0,0 +1,1320 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + Container, Typography, Paper, Grid, TextField, MenuItem, Button, + FormControlLabel, Switch, Divider, Box, Tabs, Tab, Stepper, + Step, StepLabel, IconButton, Card, CardContent, List, ListItem, + ListItemText, ListItemSecondaryAction, Autocomplete, FormHelperText +} from '@mui/material'; +import { + Save as SaveIcon, ArrowBack as ArrowBackIcon, Add as AddIcon, + Delete as DeleteIcon, Edit as EditIcon, Reorder as ReorderIcon, + Description as DescriptionIcon, Build as BuildIcon, Category as CategoryIcon, + FormatListBulleted as FormatListBulletedIcon +} from '@mui/icons-material'; +import { useSnackbar } from 'notistack'; +import axios from 'axios'; + +// Import komponenter +import PageHeader from '../common/PageHeader'; +import LoadingOverlay from '../common/LoadingOverlay'; +import ConfirmDialog from '../common/ConfirmDialog'; + +// Dialog komponenter +import TaskDialog from './dialogs/TaskDialog'; +import MaterialDialog from './dialogs/MaterialDialog'; + +/** + * Smart Pakke formular til oprettelse og redigering + */ +const SmartPackageForm = () => { + const navigate = useNavigate(); + const { id } = useParams(); + const { enqueueSnackbar } = useSnackbar(); + const isEditMode = Boolean(id); + + // State variabler + const [packageData, setPackageData] = useState({ + name: '', + description: '', + category: '', + package_type: 'standard', + difficulty_level: 2, + estimated_hours: 0, + hourly_rate: 580, + installation_notes: '', + is_template: false, + materials: [], + tasks: [] + }); + + const [loading, setLoading] = useState(isEditMode); + const [saving, setSaving] = useState(false); + const [categories, setCategories] = useState([]); + const [packageTypes, setPackageTypes] = useState([]); + const [activeTab, setActiveTab] = useState(0); + const [activeStep, setActiveStep] = useState(0); + + // Dialog states + const [taskDialogOpen, setTaskDialogOpen] = useState(false); + const [materialDialogOpen, setMaterialDialogOpen] = useState(false); + const [editingTask, setEditingTask] = useState(null); + const [editingMaterial, setEditingMaterial] = useState(null); + const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); + const [itemToDelete, setItemToDelete] = useState(null); + const [deleteType, setDeleteType] = useState(''); + + // Materialliste + const [availableMaterials, setAvailableMaterials] = useState([]); + + // Validering + const [errors, setErrors] = useState({}); + + // Hent data ved indlæsning + useEffect(() => { + fetchCategories(); + fetchMaterials(); + + if (isEditMode) { + fetchPackageDetails(); + } + }, [isEditMode, id]); + + // Hent kategorier og pakketyper + const fetchCategories = async () => { + try { + const response = await axios.get('/api/smart-packages/categories'); + + if (response.data.success) { + setCategories(response.data.categories); + setPackageTypes(response.data.types); + } else { + enqueueSnackbar('Kunne ikke hente kategorier: ' + response.data.error, { variant: 'error' }); + } + } catch (error) { + console.error('Fejl ved hentning af kategorier:', error); + enqueueSnackbar('Fejl ved hentning af kategorier', { variant: 'error' }); + } + }; + + // Hent materialer til autocomplete + const fetchMaterials = async () => { + try { + const response = await axios.get('/api/materials'); + + if (response.data.success) { + setAvailableMaterials(response.data.materials); + } else { + enqueueSnackbar('Kunne ikke hente materialer: ' + response.data.error, { variant: 'error' }); + } + } catch (error) { + console.error('Fejl ved hentning af materialer:', error); + enqueueSnackbar('Fejl ved hentning af materialer', { variant: 'error' }); + } + }; + + // Hent pakkedetaljer hvis i redigeringstilstand + const fetchPackageDetails = async () => { + setLoading(true); + try { + const response = await axios.get(`/api/smart-packages/${id}`); + + if (response.data.success) { + setPackageData(response.data.package); + } else { + enqueueSnackbar('Kunne ikke hente pakkedetaljer: ' + response.data.error, { variant: 'error' }); + navigate('/smart-packages'); + } + } catch (error) { + console.error('Fejl ved hentning af pakkedetaljer:', error); + enqueueSnackbar('Fejl ved hentning af pakkedetaljer', { variant: 'error' }); + navigate('/smart-packages'); + } finally { + setLoading(false); + } + }; + + // Håndter ændring af grundlæggende felter + const handleInputChange = (field) => (event) => { + setPackageData({ + ...packageData, + [field]: event.target.value + }); + + // Fjern fejl for dette felt + if (errors[field]) { + setErrors({ + ...errors, + [field]: null + }); + } + }; + + // Håndter ændring af switch felter (boolean) + const handleSwitchChange = (field) => (event) => { + setPackageData({ + ...packageData, + [field]: event.target.checked + }); + }; + + // Skift fane + const handleTabChange = (event, newValue) => { + setActiveTab(newValue); + }; + + // Skift trin + const handleStepChange = (step) => { + setActiveStep(step); + }; + + // Åbn task dialog (opret ny) + const handleAddTask = () => { + setEditingTask(null); + setTaskDialogOpen(true); + }; + + // Åbn task dialog (rediger eksisterende) + const handleEditTask = (task) => { + setEditingTask(task); + setTaskDialogOpen(true); + }; + + // Gem task (fra dialog) + const handleSaveTask = (taskData) => { + let updatedTasks; + + if (editingTask) { + // Opdater eksisterende task + updatedTasks = packageData.tasks.map(task => + task.id === editingTask.id ? { ...task, ...taskData } : task + ); + } else { + // Tilføj ny task med temp id + const newTask = { + ...taskData, + id: `temp-${Date.now()}`, // Midlertidigt ID indtil gemming i database + steps: taskData.steps || [] + }; + updatedTasks = [...packageData.tasks, newTask]; + } + + setPackageData({ + ...packageData, + tasks: updatedTasks + }); + + setTaskDialogOpen(false); + setEditingTask(null); + }; + + // Åbn material dialog (opret nyt) + const handleAddMaterial = () => { + setEditingMaterial(null); + setMaterialDialogOpen(true); + }; + + // Åbn material dialog (rediger eksisterende) + const handleEditMaterial = (material) => { + setEditingMaterial(material); + setMaterialDialogOpen(true); + }; + + // Gem material (fra dialog) + const handleSaveMaterial = (materialData) => { + let updatedMaterials; + + if (editingMaterial) { + // Opdater eksisterende material + updatedMaterials = packageData.materials.map(material => + material.id === editingMaterial.id ? { ...material, ...materialData } : material + ); + } else { + // Tilføj nyt material med temp id + const newMaterial = { + ...materialData, + id: `temp-${Date.now()}` // Midlertidigt ID indtil gemming i database + }; + updatedMaterials = [...packageData.materials, newMaterial]; + } + + setPackageData({ + ...packageData, + materials: updatedMaterials + }); + + setMaterialDialogOpen(false); + setEditingMaterial(null); + }; + + // Bekræft sletning af task/material + const confirmDelete = (item, type) => { + setItemToDelete(item); + setDeleteType(type); + setConfirmDialogOpen(true); + }; + + // Udfør sletning + const handleDelete = () => { + if (deleteType === 'task') { + // Slet task + const updatedTasks = packageData.tasks.filter(task => task.id !== itemToDelete.id); + setPackageData({ + ...packageData, + tasks: updatedTasks + }); + } else if (deleteType === 'material') { + // Slet material + const updatedMaterials = packageData.materials.filter(material => material.id !== itemToDelete.id); + setPackageData({ + ...packageData, + materials: updatedMaterials + }); + } + + setConfirmDialogOpen(false); + setItemToDelete(null); + setDeleteType(''); + }; + + // Valider form + const validateForm = () => { + const newErrors = {}; + + if (!packageData.name) { + newErrors.name = 'Navn er påkrævet'; + } + + if (!packageData.description) { + newErrors.description = 'Beskrivelse er påkrævet'; + } + + if (!packageData.category) { + newErrors.category = 'Kategori er påkrævet'; + } + + if (!packageData.package_type) { + newErrors.package_type = 'Type er påkrævet'; + } + + if (packageData.estimated_hours === '' || packageData.estimated_hours < 0) { + newErrors.estimated_hours = 'Estimeret tid skal være et positivt tal'; + } + + if (packageData.hourly_rate === '' || packageData.hourly_rate < 0) { + newErrors.hourly_rate = 'Timepris skal være et positivt tal'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + // Gem pakke + const handleSave = async () => { + if (!validateForm()) { + enqueueSnackbar('Ret venligst fejlene i formularen', { variant: 'error' }); + return; + } + + setSaving(true); + + try { + let response; + + if (isEditMode) { + // Opdater eksisterende pakke + response = await axios.put(`/api/smart-packages/${id}`, packageData); + } else { + // Opret ny pakke + response = await axios.post('/api/smart-packages', packageData); + } + + if (response.data.success) { + enqueueSnackbar( + isEditMode ? 'Pakke opdateret' : 'Ny pakke oprettet', + { variant: 'success' } + ); + navigate('/smart-packages'); + } else { + enqueueSnackbar('Fejl: ' + response.data.error, { variant: 'error' }); + } + } catch (error) { + console.error('Fejl ved gemning af pakke:', error); + enqueueSnackbar('Fejl ved gemning af pakke', { variant: 'error' }); + } finally { + setSaving(false); + } + }; + + // Navigér tilbage + const handleBack = () => { + navigate('/smart-packages'); + }; + + // Trin indhold + const renderStepContent = () => { + switch (activeStep) { + case 0: // Basisoplysninger + return ( + + + + Grundlæggende oplysninger + + + + + + + + + + Vælg kategori + {categories.map((category) => ( + + {category.name} + + ))} + + + + + + + + + + {packageTypes.map((type) => ( + + {type.name} + + ))} + + + + + + Let (1) + Middel (2) + Svær (3) + Ekspert (4) + + + + + + + + + + + + + + + + + + } + label="Dette er en skabelon" + /> + + Skabeloner bruges som udgangspunkt for nye pakker, men tilføjes ikke direkte til projekter + + + + ); + + case 1: // Materialer + return ( + + + + Materialer + + + + + {packageData.materials.length === 0 ? ( + + + Ingen materialer tilføjet endnu + + + + ) : ( + + {packageData.materials.map((material, index) => ( + + + + + + {`${material.quantity} ${material.unit} - ${(material.custom_price || material.price).toFixed(2)} kr/stk`} + + {material.quantity_formula && ( + + Formel: {material.quantity_formula} + + )} + + } + /> + + handleEditMaterial(material)} + disabled={saving} + sx={{ mr: 1 }} + > + + + confirmDelete(material, 'material')} + disabled={saving} + > + + + + + + + ))} + + )} + + ); + + case 2: // Opgaver + return ( + + + + Opgaver + + + + + {packageData.tasks.length === 0 ? ( + + + Ingen opgaver tilføjet endnu + + + + ) : ( + + {packageData.tasks.map((task, index) => ( + + + + + + {index + 1}. {task.name} + + {task.optional && ( + + Valgfri + + )} + + } + secondary={ + + + {task.description} + + + + Tid: {task.hours} timer + + {task.tools && ( + + Værktøj: {task.tools} + + )} + {task.steps && ( + + Trin: {task.steps.length} + + )} + + + } + /> + + handleEditTask(task)} + disabled={saving} + sx={{ mr: 1 }} + > + + + confirmDelete(task, 'task')} + disabled={saving} + > + + + + + + + ))} + + )} + + {packageData.tasks.length > 1 && ( + + + + )} + + ); + + default: + return null; + } + }; + + if (loading) { + return ; + } + + return ( + + } + onClick={handleBack} + disabled={saving} + > + Tilbage + + } + /> + + + + } + iconPosition="start" + disabled={saving} + /> + } + iconPosition="start" + disabled={saving} + /> + } + iconPosition="start" + disabled={saving} + /> + + + + {activeTab === 0 && ( + + + + Basisoplysninger + + 0}> + Materialer + + 0}> + Opgaver + + + + {renderStepContent()} + + + + + {activeStep < 2 ? ( + + ) : ( + + )} + + + )} + + {activeTab === 1 && ( + + + + + Materialer + + + + + + {packageData.materials.length} materialer tilføjet + + + + + {packageData.materials.length === 0 ? ( + + + Ingen materialer endnu + + + ) : ( + + {packageData.materials.map((material) => ( + + handleEditMaterial(material)} + disabled={saving} + sx={{ mr: 1 }} + > + + + confirmDelete(material, 'material')} + disabled={saving} + > + + + + } + > + + + ))} + + )} + + + + + + + Opgaver + + + + + + {packageData.tasks.length} opgaver tilføjet + + + + + {packageData.tasks.length === 0 ? ( + + + Ingen opgaver endnu + + + ) : ( + + {packageData.tasks.map((task, index) => ( + + handleEditTask(task)} + disabled={saving} + sx={{ mr: 1 }} + > + + + confirmDelete(task, 'task')} + disabled={saving} + > + + + + } + > + + + ))} + + )} + + + + + + + + + + )} + + {activeTab === 2 && ( + + + + + + + + + Vælg kategori + {categories.map((category) => ( + + {category.name} + + ))} + + + + + + + + + + {packageTypes.map((type) => ( + + {type.name} + + ))} + + + + + + Let (1) + Middel (2) + Svær (3) + Ekspert (4) + + + + + + + + + + + + + + + + + + } + label="Dette er en skabelon" + /> + + Skabeloner bruges som udgangspunkt for nye pakker, men tilføjes ikke direkte til projekter + + + + + + + + + + Materialer ({packageData.materials.length}) + + + + + + + + + {packageData.materials.length === 0 ? ( + + + + ) : ( + packageData.materials.map((material) => ( + + handleEditMaterial(material)} + disabled={saving} + sx={{ mr: 1 }} + > + + + confirmDelete(material, 'material')} + disabled={saving} + > + + + + } + > + + + )) + )} + + + + + + + Opgaver ({packageData.tasks.length}) + + + + + + + + + {packageData.tasks.length === 0 ? ( + + + + ) : ( + packageData.tasks.map((task, index) => ( + + handleEditTask(task)} + disabled={saving} + sx={{ mr: 1 }} + > + + + confirmDelete(task, 'task')} + disabled={saving} + > + + + + } + > + + + {task.hours} timer • {task.steps?.length || 0} trin + + {task.optional && ( + + Valgfri opgave + + )} + + } + /> + + )) + )} + + + + + + + + + + )} + + + + {/* Task dialog */} + {taskDialogOpen && ( + setTaskDialogOpen(false)} + onSave={handleSaveTask} + task={editingTask} + packageData={packageData} + /> + )} + + {/* Material dialog */} + {materialDialogOpen && ( + setMaterialDialogOpen(false)} + onSave={handleSaveMaterial} + material={editingMaterial} + availableMaterials={availableMaterials} + /> + )} + + {/* Bekræft sletning dialog */} + setConfirmDialogOpen(false)} + /> + + ); +}; + +export default SmartPackageForm; \ No newline at end of file diff --git a/frontend/src/components/smartPackages/SmartPackages.css b/frontend/src/components/smartPackages/SmartPackages.css new file mode 100644 index 0000000..e94c42f --- /dev/null +++ b/frontend/src/components/smartPackages/SmartPackages.css @@ -0,0 +1,242 @@ +.smart-packages-container { + padding: 20px; + max-width: 1400px; + margin: 0 auto; +} + +/* Filter section styling */ +.smart-packages-filters { + background-color: #f9f9f9; + border-radius: 8px; + padding: 16px; + margin-bottom: 24px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +} + +/* Smart Package cards styling */ +.smart-package-card { + position: relative; + transition: transform 0.2s, box-shadow 0.2s; +} + +.smart-package-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); +} + +.smart-package-card .MuiCardContent-root { + height: 180px; +} + +.smart-package-card .card-top { + display: flex; + justify-content: space-between; + margin-bottom: 12px; +} + +.smart-package-card .card-title { + font-weight: 600; + color: #2c3e50; + margin-bottom: 8px; +} + +.smart-package-card .card-category { + font-size: 12px; + color: #7f8c8d; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.smart-package-card .card-description { + height: 60px; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + color: #34495e; + margin-bottom: 12px; +} + +.smart-package-card .card-meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: auto; + position: absolute; + bottom: 16px; + left: 16px; + right: 16px; +} + +.smart-package-card .card-meta-item { + display: flex; + align-items: center; + font-size: 12px; + color: #7f8c8d; +} + +.smart-package-card .card-meta-item svg { + font-size: 16px; + margin-right: 4px; +} + +/* Task styling */ +.task-item { + margin-bottom: 12px; + border: 1px solid #e0e0e0; + border-radius: 8px; + overflow: hidden; +} + +.task-header { + display: flex; + justify-content: space-between; + padding: 12px 16px; + background-color: #f5f5f5; + border-bottom: 1px solid #e0e0e0; +} + +.task-content { + padding: 16px; +} + +.task-steps { + margin-top: 12px; +} + +.task-step { + display: flex; + padding: 8px 0; + border-bottom: 1px dashed #e0e0e0; +} + +.task-step:last-child { + border-bottom: none; +} + +.task-step-number { + min-width: 24px; + height: 24px; + border-radius: 50%; + background-color: #3498db; + color: white; + display: flex; + align-items: center; + justify-content: center; + margin-right: 12px; + font-size: 12px; + font-weight: bold; +} + +/* Detail dialog styling */ +.package-detail-dialog .dialog-section { + margin-bottom: 24px; +} + +.package-detail-dialog .section-title { + font-size: 18px; + font-weight: 600; + margin-bottom: 16px; + color: #2c3e50; + display: flex; + align-items: center; +} + +.package-detail-dialog .section-title svg { + margin-right: 8px; +} + +.package-detail-dialog .price-summary { + background-color: #f9f9f9; + border-radius: 8px; + padding: 16px; +} + +.package-detail-dialog .price-summary-row { + display: flex; + justify-content: space-between; + padding: 4px 0; +} + +.package-detail-dialog .price-total { + font-size: 18px; + font-weight: bold; + padding-top: 8px; + margin-top: 8px; + border-top: 2px solid #e0e0e0; +} + +/* Form styling */ +.smart-package-form .form-section { + margin-bottom: 32px; +} + +.smart-package-form .form-subtitle { + font-size: 16px; + font-weight: 600; + margin-bottom: 16px; + color: #2c3e50; +} + +.smart-package-form .form-helper-text { + font-size: 12px; + color: #7f8c8d; + margin-top: 4px; +} + +/* Drag and drop styling */ +.draggable-item { + cursor: grab; + transition: background-color 0.2s; +} + +.draggable-item:hover { + background-color: #f5f5f5; +} + +.draggable-item.dragging { + background-color: #ebf5fb; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +/* Material selection styling */ +.material-chip { + margin: 4px; +} + +.material-chip .MuiChip-label { + display: flex; + align-items: center; +} + +.material-chip .material-quantity { + font-size: 11px; + background-color: rgba(0, 0, 0, 0.08); + padding: 2px 4px; + border-radius: 4px; + margin-left: 4px; +} + +/* Responsive styling */ +@media (max-width: 960px) { + .smart-packages-filters { + flex-direction: column; + } + + .smart-packages-filters .MuiFormControl-root { + margin-bottom: 16px; + } + + .smart-package-card .MuiCardContent-root { + height: auto; + } + + .smart-package-card .card-meta { + position: relative; + margin-top: 16px; + left: 0; + right: 0; + bottom: 0; + } +} \ No newline at end of file diff --git a/frontend/src/components/smartPackages/SmartPackages.js b/frontend/src/components/smartPackages/SmartPackages.js new file mode 100644 index 0000000..0e6b4a3 --- /dev/null +++ b/frontend/src/components/smartPackages/SmartPackages.js @@ -0,0 +1,564 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Container, Typography, Paper, Grid, Card, CardContent, CardMedia, CardActionArea, + Button, Divider, Box, TextField, MenuItem, IconButton, Chip, Tooltip, LinearProgress, + Pagination +} from '@mui/material'; +import './SmartPackages.css'; +import { + Add as AddIcon, Search as SearchIcon, FilterList as FilterIcon, + Sort as SortIcon, Category as CategoryIcon, Build as BuildIcon, + AccessTime as TimeIcon, AttachMoney as MoneyIcon, Star as StarIcon, + StarBorder as StarBorderIcon, Delete as DeleteIcon, Edit as EditIcon, + Visibility as ViewIcon +} from '@mui/icons-material'; +import { useSnackbar } from 'notistack'; +import axios from 'axios'; + +// Import komponenter +import PageHeader from '../common/PageHeader'; +import LoadingOverlay from '../common/LoadingOverlay'; +import EmptyState from '../common/EmptyState'; +import ConfirmDialog from '../common/ConfirmDialog'; +import PackageDetailsDialog from './PackageDetailsDialog'; + +// Hjælpefunktioner +import { formatCurrency, formatTime } from '../../utils/formatters'; + +/** + * Smart Pakker hovedkomponent + * Viser en oversigt over alle Smart Pakker med filtrering og sortering + */ +const SmartPackages = () => { + const navigate = useNavigate(); + const { enqueueSnackbar } = useSnackbar(); + + // State variabler + const [packages, setPackages] = useState([]); + const [loading, setLoading] = useState(true); + const [categories, setCategories] = useState([]); + const [packageTypes, setPackageTypes] = useState([]); + const [totalPackages, setTotalPackages] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [detailsDialogOpen, setDetailsDialogOpen] = useState(false); + const [selectedPackage, setSelectedPackage] = useState(null); + const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); + const [packageToDelete, setPackageToDelete] = useState(null); + + // Filtre og søgning + const [filters, setFilters] = useState({ + search: '', + category: '', + packageType: '', + difficulty: '', + isTemplate: null, + sortBy: 'created_at', + sortDir: 'desc' + }); + + const pageSize = 12; + + // Hent pakker + useEffect(() => { + fetchPackages(); + fetchCategories(); + }, [currentPage, filters]); + + // Hent pakker fra API + const fetchPackages = async () => { + setLoading(true); + try { + const offset = (currentPage - 1) * pageSize; + + const queryParams = new URLSearchParams({ + limit: pageSize, + offset, + ...filters + }); + + // Fjern tomme filtre + Object.keys(filters).forEach(key => { + if (filters[key] === '' || filters[key] === null) { + queryParams.delete(key); + } + }); + + const response = await axios.get(`/api/smart-packages?${queryParams.toString()}`); + + if (response.data.success) { + setPackages(response.data.packages); + setTotalPackages(response.data.total || response.data.packages.length); + } else { + enqueueSnackbar('Kunne ikke hente pakker: ' + response.data.error, { variant: 'error' }); + } + } catch (error) { + console.error('Fejl ved hentning af smart pakker:', error); + enqueueSnackbar('Fejl ved hentning af pakker', { variant: 'error' }); + } finally { + setLoading(false); + } + }; + + // Hent kategorier og pakke typer + const fetchCategories = async () => { + try { + const response = await axios.get('/api/smart-packages/categories'); + + if (response.data.success) { + setCategories(response.data.categories); + setPackageTypes(response.data.types); + } + } catch (error) { + console.error('Fejl ved hentning af kategorier:', error); + } + }; + + // Håndter filter ændringer + const handleFilterChange = (field) => (event) => { + setFilters({ + ...filters, + [field]: event.target.value + }); + setCurrentPage(1); // Nulstil til side 1 ved filterændringer + }; + + // Ryd alle filtre + const clearFilters = () => { + setFilters({ + search: '', + category: '', + packageType: '', + difficulty: '', + isTemplate: null, + sortBy: 'created_at', + sortDir: 'desc' + }); + setCurrentPage(1); + }; + + // Side skift + const handlePageChange = (event, newPage) => { + setCurrentPage(newPage); + }; + + // Vis pakke detaljer + const handleViewPackage = (packageItem) => { + setSelectedPackage(packageItem); + setDetailsDialogOpen(true); + }; + + // Rediger pakke + const handleEditPackage = (packageId) => { + navigate(`/smart-packages/edit/${packageId}`); + }; + + // Opret ny pakke + const handleCreatePackage = () => { + navigate('/smart-packages/create'); + }; + + // Bekræft sletning + const confirmDeletePackage = (packageItem) => { + setPackageToDelete(packageItem); + setConfirmDialogOpen(true); + }; + + // Slet pakke + const deletePackage = async () => { + if (!packageToDelete) return; + + try { + const response = await axios.delete(`/api/smart-packages/${packageToDelete.id}`); + + if (response.data.success) { + enqueueSnackbar('Pakken blev slettet', { variant: 'success' }); + fetchPackages(); // Genindlæs listen + } else { + enqueueSnackbar('Kunne ikke slette pakke: ' + response.data.error, { variant: 'error' }); + } + } catch (error) { + console.error('Fejl ved sletning af pakke:', error); + enqueueSnackbar('Fejl ved sletning af pakke', { variant: 'error' }); + } finally { + setConfirmDialogOpen(false); + setPackageToDelete(null); + } + }; + + // Få farve baseret på vanskelighed + const getDifficultyColor = (level) => { + switch (level) { + case 1: return '#4caf50'; // Let (grøn) + case 2: return '#2196f3'; // Middel (blå) + case 3: return '#ff9800'; // Svær (orange) + case 4: return '#f44336'; // Ekspert (rød) + default: return '#757575'; // Standard (grå) + } + }; + + // Få vanskeligheds tekst + const getDifficultyText = (level) => { + switch (level) { + case 1: return 'Let'; + case 2: return 'Middel'; + case 3: return 'Svær'; + case 4: return 'Ekspert'; + default: return 'Ukendt'; + } + }; + + // Render kategori chip + const renderCategoryChip = (category) => { + const categoryObj = categories.find(cat => cat.name === category); + const icon = categoryObj?.icon || 'category'; + + return ( + } + label={category} + size="small" + variant="outlined" + sx={{ mr: 1 }} + /> + ); + }; + + return ( + + } + onClick={handleCreatePackage} + > + Opret ny pakke + + } + /> + + + + + + }} + /> + + + + + Alle kategorier + {categories.map((category) => ( + + {category.name} + + ))} + + + + + + Alle typer + {packageTypes.map((type) => ( + + {type.name} + + ))} + + + + + + Alle sværhedsgrader + Let + Middel + Svær + Ekspert + + + + + + Oprettelsesdato + Navn + Popularitet + Pris + Tidsforbrug + + + + + + + + + + + + {loading ? ( + + ) : packages.length === 0 ? ( + } + onClick={handleCreatePackage} + > + Opret ny pakke + + } + /> + ) : ( + <> + + {packages.map((packageItem) => ( + + + {packageItem.is_template && ( + + )} + + handleViewPackage(packageItem)} + sx={{ flexGrow: 1 }} + > + + + {packageItem.name.substring(0, 2).toUpperCase()} + + + + + + {packageItem.name} + + + + {packageItem.description} + + + + {packageItem.category && renderCategoryChip(packageItem.category)} + + } + label={packageItem.task_count || 0} + size="small" + title="Antal opgaver" + sx={{ mr: 1, mb: 1 }} + /> + + } + label={formatTime(packageItem.estimated_hours)} + size="small" + title="Estimeret tid" + sx={{ mr: 1, mb: 1 }} + /> + + + + + + + + + + + {[...Array(packageItem.difficulty_level || 1)].map((_, i) => ( + + ))} + {[...Array(4 - (packageItem.difficulty_level || 1))].map((_, i) => ( + + ))} + + + + + + {formatCurrency(packageItem.total_package_price || 0)} + + + + + + + + handleViewPackage(packageItem)} + > + + + + + + handleEditPackage(packageItem.id)} + > + + + + + + confirmDeletePackage(packageItem)} + > + + + + + + + ))} + + + + + + + )} + + {/* Pakke detalje dialog */} + {selectedPackage && ( + setDetailsDialogOpen(false)} + packageId={selectedPackage.id} + onEdit={() => { + setDetailsDialogOpen(false); + handleEditPackage(selectedPackage.id); + }} + /> + )} + + {/* Bekræft sletning dialog */} + setConfirmDialogOpen(false)} + /> + + ); +}; + +export default SmartPackages; \ No newline at end of file diff --git a/frontend/src/components/smartPackages/SmartPackagesMenu.js b/frontend/src/components/smartPackages/SmartPackagesMenu.js new file mode 100644 index 0000000..2397080 --- /dev/null +++ b/frontend/src/components/smartPackages/SmartPackagesMenu.js @@ -0,0 +1,202 @@ +import React, { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { + Box, + List, + ListItem, + ListItemIcon, + ListItemText, + Paper, + Divider, + Typography, + Collapse, +} from '@mui/material'; +import { + Dashboard as DashboardIcon, + Category as CategoryIcon, + Assignment as TasksIcon, + Build as BuildIcon, + ExpandLess, + ExpandMore, + AddCircle as AddIcon, + Settings as SettingsIcon, + Backup as BackupIcon, + Assessment as AssessmentIcon +} from '@mui/icons-material'; + +/** + * SmartPackagesMenu Component + * + * Sidepanel menu til Smart Pakker modulet med kategorier, navigation + * og genveje til de mest brugte funktioner. + */ +const SmartPackagesMenu = ({ activeSection }) => { + const [openCategories, setOpenCategories] = useState(true); + const [openSettings, setOpenSettings] = useState(false); + const [version, setVersion] = useState('1.0.0'); + + // Toggle åben/lukket tilstand for en menu-sektion + const handleToggle = (section) => { + if (section === 'categories') { + setOpenCategories(!openCategories); + } else if (section === 'settings') { + setOpenSettings(!openSettings); + } + }; + + // Hent version fra API eller load fra lokalt storage + useEffect(() => { + // For nu bruger vi en hardcoded version + // I fremtiden kan denne hentes fra backend + setVersion('1.2.0'); + }, []); + + return ( + + + + Smart Pakker + + + Modulbaseret byggesystem + + + + + + + + + + + + + + + + + + + + + {/* Kategorier sektion */} + handleToggle('categories')}> + + + + + {openCategories ? : } + + + + + {['Tilbygning', 'Ombygning', 'Renovering', 'Reparation', 'Nybygning'].map((category) => ( + + + + ))} + + + + + + {/* Opgaver og elementer */} + + + + + + + + + + + + + + + + + {/* Indstillinger sektion */} + handleToggle('settings')}> + + + + + {openSettings ? : } + + + + + + + + + + + + + + + + + + + + + + + + Smart Pakker v{version} + + + + ); +}; + +export default SmartPackagesMenu; \ No newline at end of file diff --git a/frontend/src/components/smartPackages/TaskManagement.js b/frontend/src/components/smartPackages/TaskManagement.js new file mode 100644 index 0000000..63e376e --- /dev/null +++ b/frontend/src/components/smartPackages/TaskManagement.js @@ -0,0 +1,271 @@ +import React, { useState, useEffect } from 'react'; +import { + Container, + Typography, + Box, + Paper, + Grid, + Button, + TextField, + Divider, + LinearProgress, + Alert, + Card, + CardContent, + CardActions, + IconButton +} from '@mui/material'; +import { + Add as AddIcon, + Edit as EditIcon, + Delete as DeleteIcon, + Search as SearchIcon, + Build as BuildIcon, + AccessTime as TimeIcon, + AttachMoney as MoneyIcon +} from '@mui/icons-material'; + +import TaskDialog from './dialogs/TaskDialog'; +import PageHeader from '../common/PageHeader'; +import EmptyState from '../common/EmptyState'; + +/** + * TaskManagement Component + * + * Administration af opgaver til Smart Pakker systemet + * Bruges til at skabe, redigere og organisere opgaver + */ +const TaskManagement = ({ apiBaseUrl }) => { + // State variabler + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [dialogOpen, setDialogOpen] = useState(false); + const [currentTask, setCurrentTask] = useState(null); + const [isNewTask, setIsNewTask] = useState(false); + + // Hent opgaver ved indlæsning + useEffect(() => { + fetchTasks(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Hent alle opgaver fra API + const fetchTasks = async () => { + setLoading(true); + try { + const response = await fetch(`${apiBaseUrl}/api/smart-packages/tasks`); + const data = await response.json(); + + if (data.success) { + setTasks(data.tasks || []); + } else { + console.error('Fejl ved hentning af opgaver:', data.error); + } + } catch (error) { + console.error('Fejl ved hentning af opgaver:', error); + } finally { + setLoading(false); + } + }; + + // Åbn dialog for at oprette en ny opgave + const handleCreateTask = () => { + setCurrentTask({}); + setIsNewTask(true); + setDialogOpen(true); + }; + + // Åbn dialog for at redigere en eksisterende opgave + const handleEditTask = (task) => { + setCurrentTask(task); + setIsNewTask(false); + setDialogOpen(true); + }; + + // Håndter sletning af en opgave + const handleDeleteTask = async (taskId) => { + if (!window.confirm('Er du sikker på at du vil slette denne opgave?')) { + return; + } + + try { + const response = await fetch(`${apiBaseUrl}/api/smart-packages/tasks/${taskId}`, { + method: 'DELETE' + }); + + const data = await response.json(); + + if (data.success) { + // Opdater listen efter sletning + fetchTasks(); + } else { + console.error('Fejl ved sletning af opgave:', data.error); + } + } catch (error) { + console.error('Fejl ved sletning af opgave:', error); + } + }; + + // Gem opgave (ny eller redigeret) + const handleSaveTask = async (taskData, steps) => { + try { + let response; + + if (isNewTask) { + // Opret ny opgave + response = await fetch(`${apiBaseUrl}/api/smart-packages/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task: taskData, steps }) + }); + } else { + // Opdater eksisterende opgave + response = await fetch(`${apiBaseUrl}/api/smart-packages/tasks/${taskData.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task: taskData, steps }) + }); + } + + const data = await response.json(); + + if (data.success) { + // Luk dialogen og opdater listen + setDialogOpen(false); + fetchTasks(); + } else { + console.error('Fejl ved gem af opgave:', data.error); + } + } catch (error) { + console.error('Fejl ved gem af opgave:', error); + } + }; + + // Filtrerede opgaver baseret på søgning + const filteredTasks = tasks.filter(task => + task.name.toLowerCase().includes(searchTerm.toLowerCase()) || + task.description.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + return ( + + } + /> + + + + + + setSearchTerm(e.target.value)} + InputProps={{ + startAdornment: + }} + /> + + + + + + + + + {loading ? ( + + + + ) : filteredTasks.length === 0 ? ( + } + /> + ) : ( + + {filteredTasks.map(task => ( + + + + + {task.name} + + + + {task.description || 'Ingen beskrivelse'} + + + + + + + + {task.hours} timer + + + + + + + {task.rate} kr/time + + + + {task.optional && ( + + Valgfri opgave + + )} + + + + handleEditTask(task)} + > + + + handleDeleteTask(task.id)} + > + + + + + + ))} + + )} + + {/* Opgave Dialog */} + setDialogOpen(false)} + onSave={handleSaveTask} + task={currentTask} + isNew={isNewTask} + apiBaseUrl={apiBaseUrl} + /> + + ); +}; + +export default TaskManagement; \ No newline at end of file diff --git a/frontend/src/components/smartPackages/dialogs/MaterialDialog.js b/frontend/src/components/smartPackages/dialogs/MaterialDialog.js new file mode 100644 index 0000000..dc058ee --- /dev/null +++ b/frontend/src/components/smartPackages/dialogs/MaterialDialog.js @@ -0,0 +1,342 @@ +import React, { useState, useEffect } from 'react'; +import { + Dialog, DialogTitle, DialogContent, DialogActions, Button, + TextField, Grid, Typography, Box, FormHelperText, Autocomplete, + FormControlLabel, Switch, InputAdornment +} from '@mui/material'; +import { useSnackbar } from 'notistack'; + +/** + * Dialog til at oprette eller redigere et materiale i en smart pakke + */ +const MaterialDialog = ({ open, onClose, onSave, material, availableMaterials }) => { + const { enqueueSnackbar } = useSnackbar(); + const isEditMode = Boolean(material); + + // State for material data + const [materialData, setMaterialData] = useState({ + material_id: '', + material_name: '', + description: '', + quantity: 1, + unit: 'stk', + price: 0, + custom_price: null, + quantity_formula: '', + use_formula: false, + optional: false + }); + + // State for valgt materiale fra autocomplete + const [selectedMaterial, setSelectedMaterial] = useState(null); + + // Validering + const [errors, setErrors] = useState({}); + + // Indlæs data hvis det er en redigering + useEffect(() => { + if (material) { + setMaterialData({ + ...material, + use_formula: Boolean(material.quantity_formula) + }); + + // Find det valgte materiale fra availableMaterials baseret på material_id + if (material.material_id && availableMaterials?.length > 0) { + const foundMaterial = availableMaterials.find(m => + m.id === material.material_id || m.id === parseInt(material.material_id) + ); + setSelectedMaterial(foundMaterial || null); + } + } + }, [material, availableMaterials]); + + // Håndter valg af materiale fra autocomplete + const handleMaterialSelect = (event, value) => { + setSelectedMaterial(value); + + if (value) { + setMaterialData({ + ...materialData, + material_id: value.id, + material_name: value.name, + description: value.description || '', + unit: value.unit || 'stk', + price: value.price || 0 + }); + + // Fjern material_id fejl + if (errors.material_id) { + setErrors({ + ...errors, + material_id: null + }); + } + } else { + // Nulstil materiale data hvis der ikke er valgt noget + setMaterialData({ + ...materialData, + material_id: '', + material_name: '', + description: '', + unit: 'stk', + price: 0 + }); + } + }; + + // Håndter ændringer i inputfelter + const handleInputChange = (field) => (event) => { + setMaterialData({ + ...materialData, + [field]: ['quantity', 'price', 'custom_price'].includes(field) + ? parseFloat(event.target.value) || 0 + : event.target.value + }); + + // Fjern fejl for dette felt + if (errors[field]) { + setErrors({ + ...errors, + [field]: null + }); + } + }; + + // Håndter ændring af switch felter (boolean) + const handleSwitchChange = (field) => (event) => { + const checked = event.target.checked; + + setMaterialData({ + ...materialData, + [field]: checked + }); + + // Hvis use_formula bliver slået fra, nulstil formula + if (field === 'use_formula' && !checked) { + setMaterialData(prev => ({ + ...prev, + quantity_formula: '' + })); + } + }; + + // Validér form + const validateForm = () => { + const newErrors = {}; + + if (!materialData.material_id && !materialData.material_name) { + newErrors.material_id = 'Vælg et materiale'; + } + + if (materialData.quantity <= 0 && !materialData.use_formula) { + newErrors.quantity = 'Antal skal være større end 0'; + } + + if (materialData.use_formula && !materialData.quantity_formula) { + newErrors.quantity_formula = 'Angiv en formel'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + // Håndter gem + const handleSave = () => { + if (!validateForm()) { + enqueueSnackbar('Ret venligst fejlene i formularen', { variant: 'error' }); + return; + } + + // Fjern unødvendig data + const dataToSave = { + ...materialData, + // Hvis der ikke bruges formel, fjern formlen + quantity_formula: materialData.use_formula ? materialData.quantity_formula : null + }; + + onSave(dataToSave); + }; + + return ( + + + {isEditMode ? 'Redigér materiale' : 'Tilføj nyt materiale'} + + + + + + `${option.name} (${option.varenr || 'Uden varenr'})`} + value={selectedMaterial} + onChange={handleMaterialSelect} + renderInput={(params) => ( + + )} + isOptionEqualToValue={(option, value) => option.id === value.id} + fullWidth + /> + + + + + + + + + + + + + + + + + } + label="Brug formel til beregning af antal" + /> + + Aktiver hvis antallet skal beregnes dynamisk baseret på en formel ved tilføjelse til projekt + + + + {materialData.use_formula ? ( + + + + ) : ( + + + + )} + + + kr/stk, + }} + /> + + + + kr/stk, + }} + /> + + + + + + Effektiv pris + + + {((materialData.custom_price !== null ? materialData.custom_price : materialData.price) || 0).toFixed(2)} kr/stk + + + + + + + } + label="Dette materiale er valgfrit" + /> + + Valgfrie materialer kan kunden vælge til eller fra ved tilføjelse til projektet + + + + + + + + + + + ); +}; + +export default MaterialDialog; \ No newline at end of file diff --git a/frontend/src/components/smartPackages/dialogs/TaskDialog.css b/frontend/src/components/smartPackages/dialogs/TaskDialog.css new file mode 100644 index 0000000..3a7b24d --- /dev/null +++ b/frontend/src/components/smartPackages/dialogs/TaskDialog.css @@ -0,0 +1,103 @@ +.task-dialog .MuiDialogTitle-root { + background-color: #f8f9fa; + border-bottom: 1px solid #e0e0e0; +} + +.task-dialog .step-card { + border: 1px solid #e0e0e0; + border-radius: 8px; + margin-bottom: 16px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.task-dialog .step-card-header { + padding: 12px 16px; + background-color: #f5f5f5; + border-bottom: 1px solid #e0e0e0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.task-dialog .step-card-content { + padding: 16px; +} + +.task-dialog .step-drag-handle { + cursor: grab; + color: #9e9e9e; +} + +.task-dialog .step-drag-handle:active { + cursor: grabbing; +} + +.task-dialog .safety-note { + background-color: #ffebee; + border-left: 4px solid #f44336; + padding: 8px 16px; + margin: 8px 0; +} + +.task-dialog .quality-check { + background-color: #e8f5e9; + border-left: 4px solid #4caf50; + padding: 8px 16px; + margin: 8px 0; +} + +.task-dialog .step-tip { + background-color: #fff8e1; + border-left: 4px solid #ffc107; + padding: 8px 16px; + margin: 8px 0; +} + +.task-dialog .total-time-display { + display: flex; + align-items: center; + color: #455a64; + font-weight: 500; +} + +.task-dialog .total-time-display svg { + margin-right: 8px; + color: #607d8b; +} + +.task-dialog .time-indicator { + display: flex; + align-items: center; + font-size: 14px; + color: #78909c; + margin-top: 4px; +} + +.task-dialog .time-indicator svg { + font-size: 16px; + margin-right: 4px; + color: #90a4ae; +} + +/* Animation for step transitions */ +.task-dialog .MuiCollapse-root { + transition: height 0.3s ease-in-out; +} + +.task-dialog .MuiStepper-root .MuiStepLabel-label { + font-weight: 500; +} + +/* Draggable step styles */ +.dragging-step { + opacity: 0.7; + transform: scale(0.98); + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1) !important; +} + +/* Empty state when no steps */ +.task-dialog .empty-steps { + text-align: center; + padding: 32px 0; + color: #9e9e9e; +} \ No newline at end of file diff --git a/frontend/src/components/smartPackages/dialogs/TaskDialog.js b/frontend/src/components/smartPackages/dialogs/TaskDialog.js new file mode 100644 index 0000000..f2d810b --- /dev/null +++ b/frontend/src/components/smartPackages/dialogs/TaskDialog.js @@ -0,0 +1,345 @@ +import React, { useState, useEffect } from 'react'; +import { + Dialog, DialogTitle, DialogContent, DialogActions, Button, + TextField, FormControlLabel, Switch, Grid, Typography, + IconButton, List, ListItem, ListItemText, ListItemSecondaryAction, + Box, FormHelperText, Card, CardContent, Stepper, Step, StepLabel, + StepContent, Divider, Tooltip, InputAdornment, Paper +} from '@mui/material'; +import { + Add as AddIcon, + Delete as DeleteIcon, + Edit as EditIcon, + SwapVert as SwapVertIcon, + Info as InfoIcon, + Build as BuildIcon, + Assignment as AssignmentIcon, + Construction as ConstructionIcon, + Timer as TimerIcon, + CheckCircle as CheckCircleIcon +} from '@mui/icons-material'; +import { useSnackbar } from 'notistack'; + +/** + * Dialog til at oprette eller redigere en opgave i en smart pakke + */ +const TaskDialog = ({ open, onClose, onSave, task, packageData }) => { + const { enqueueSnackbar } = useSnackbar(); + const isEditMode = Boolean(task); + + // State for task data + const [taskData, setTaskData] = useState({ + name: '', + description: '', + hours: 1, + rate: 580, + difficulty_level: 2, + skills: '', + tools: '', + optional: false, + depends_on: '', + task_order: 0, + steps: [] + }); + + // State for step der bliver redigeret + const [newStep, setNewStep] = useState(''); + + // State for det aktive trin i processen + const [activeStep, setActiveStep] = useState(0); + + // State for step detaljer + const [stepDetails, setStepDetails] = useState({ + title: '', + description: '', + minutes: 15, + image: '', + video: '', + safety: '', + quality: '', + tips: '' + }); + + // Validering + const [errors, setErrors] = useState({}); + + // Indlæs data hvis det er en redigering + useEffect(() => { + if (task) { + setTaskData({ + ...task, + steps: task.steps || [] + }); + } + }, [task]); + + // Håndter ændringer i inputfelter + const handleInputChange = (field) => (event) => { + setTaskData({ + ...taskData, + [field]: field === 'hours' ? parseFloat(event.target.value) || 0 : event.target.value + }); + + // Fjern fejl for dette felt + if (errors[field]) { + setErrors({ + ...errors, + [field]: null + }); + } + }; + + // Håndter ændring af switch felter (boolean) + const handleSwitchChange = (field) => (event) => { + setTaskData({ + ...taskData, + [field]: event.target.checked + }); + }; + + // Tilføj et nyt step + const handleAddStep = () => { + if (!newStep.trim()) { + enqueueSnackbar('Trin kan ikke være tomt', { variant: 'error' }); + return; + } + + setTaskData({ + ...taskData, + steps: [ + ...taskData.steps, + { + id: `temp-step-${Date.now()}`, + description: newStep.trim(), + order: taskData.steps.length + 1 + } + ] + }); + + setNewStep(''); + }; + + // Slet et step + const handleDeleteStep = (stepId) => { + const updatedSteps = taskData.steps.filter(step => step.id !== stepId); + + // Opdater order på de resterende steps + const reorderedSteps = updatedSteps.map((step, index) => ({ + ...step, + order: index + 1 + })); + + setTaskData({ + ...taskData, + steps: reorderedSteps + }); + }; + + // Validér form + const validateForm = () => { + const newErrors = {}; + + if (!taskData.name) { + newErrors.name = 'Navn er påkrævet'; + } + + if (!taskData.description) { + newErrors.description = 'Beskrivelse er påkrævet'; + } + + if (taskData.hours <= 0) { + newErrors.hours = 'Timer skal være større end 0'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + // Håndter gem + const handleSave = () => { + if (!validateForm()) { + enqueueSnackbar('Ret venligst fejlene i formularen', { variant: 'error' }); + return; + } + + onSave(taskData); + }; + + return ( + + + {isEditMode ? 'Redigér opgave' : 'Tilføj ny opgave'} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } + label="Denne opgave er valgfri" + /> + + Valgfrie opgaver kan kunden vælge til eller fra ved tilføjelse til projektet + + + + + + Trin + + + + setNewStep(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddStep(); + } + }} + placeholder="Beskriv et trin i opgaven" + sx={{ mr: 1 }} + /> + + + + + + {taskData.steps.length === 0 ? ( + + + Ingen trin tilføjet. Tilføj trin for at hjælpe med monteringen. + + + ) : ( + + {taskData.steps.map((step, index) => ( + + + + handleDeleteStep(step.id)} + > + + + + + ))} + + )} + + + + + + + + + + + + ); +}; + +export default TaskDialog; \ No newline at end of file diff --git a/frontend/src/routes/SmartPackagesRoutes.js b/frontend/src/routes/SmartPackagesRoutes.js new file mode 100644 index 0000000..2ac8890 --- /dev/null +++ b/frontend/src/routes/SmartPackagesRoutes.js @@ -0,0 +1,124 @@ +import React, { useState, useEffect } from 'react'; +import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'; +import { Box, Button, Container, Grid, Typography } from '@mui/material'; +import { Add as AddIcon } from '@mui/icons-material'; + +// Import smart package komponenter +import SmartPackages from '../components/smartPackages/SmartPackages'; +import SmartPackageForm from '../components/smartPackages/SmartPackageForm'; +import SmartPackagesMenu from '../components/smartPackages/SmartPackagesMenu'; +import TaskManagement from '../components/smartPackages/TaskManagement'; + +/** + * Router komponent for Smart Pakker, håndterer navigation og routing + */ +const SmartPackagesRoutes = () => { + const navigate = useNavigate(); + const location = useLocation(); + const [showAddButton, setShowAddButton] = useState(false); + const [activeSection, setActiveSection] = useState('dashboard'); + const [isLoading, setIsLoading] = useState(true); + const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:4031'; + + // Opdaterer når URL ændres for at vise/skjule knappen og sætte aktiv sektion + useEffect(() => { + // Viser kun knappen på hovedlisten, ikke på formular-sider + const currentPath = location.pathname; + setShowAddButton(currentPath === '/smart-packages'); + + // Sæt aktivt menuitem baseret på URL + if (currentPath.includes('/create')) { + setActiveSection('create'); + } else if (currentPath.includes('/tasks')) { + setActiveSection('tasks'); + } else if (currentPath.includes('/components')) { + setActiveSection('components'); + } else { + setActiveSection('dashboard'); + } + + // Simuler data loading + setIsLoading(true); + setTimeout(() => { + setIsLoading(false); + }, 500); + }, [location]); + + // Navigerer til oprettelsesformular + const handleCreatePackage = () => { + navigate('/smart-packages/create'); + }; + + return ( + + + + {/* Sidemenu */} + + + + + {/* Hovedindhold */} + + {isLoading ? ( + + + + + + Indlæser Smart Pakker... + + + ) : ( + + } /> + } /> + } /> + } /> + Komponenter kommer snart} /> + + )} + + + + {showAddButton && ( + + )} + + + ); +}; + +export default SmartPackagesRoutes; \ No newline at end of file diff --git a/frontend/src/utils/formatters.js b/frontend/src/utils/formatters.js new file mode 100644 index 0000000..658aa1c --- /dev/null +++ b/frontend/src/utils/formatters.js @@ -0,0 +1,106 @@ +/** + * Formattering utilities + * Hjælpefunktioner til formattering af tal, valuta, tid og dato + */ + +/** + * Formatér tal som dansk valuta (DKK) + */ +export const formatCurrency = (amount, decimals = 2) => { + if (amount === null || amount === undefined || isNaN(amount)) { + return '0,00 kr'; + } + + return new Intl.NumberFormat('da-DK', { + style: 'currency', + currency: 'DKK', + minimumFractionDigits: decimals, + maximumFractionDigits: decimals + }).format(amount); +}; + +/** + * Formatér tid i timer + */ +export const formatTime = (hours) => { + if (hours === null || hours === undefined || isNaN(hours)) { + return '0 timer'; + } + + const h = parseFloat(hours); + + if (h === 1) { + return '1 time'; + } + + return `${h.toFixed(1)} timer`; +}; + +/** + * Formatér dato til dansk format + */ +export const formatDate = (dateString) => { + if (!dateString) { + return '-'; + } + + const date = new Date(dateString); + + if (isNaN(date.getTime())) { + return '-'; + } + + return new Intl.DateTimeFormat('da-DK', { + year: 'numeric', + month: 'long', + day: 'numeric' + }).format(date); +}; + +/** + * Formatér dato og tid til dansk format + */ +export const formatDateTime = (dateString) => { + if (!dateString) { + return '-'; + } + + const date = new Date(dateString); + + if (isNaN(date.getTime())) { + return '-'; + } + + return new Intl.DateTimeFormat('da-DK', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }).format(date); +}; + +/** + * Formatér tal med tusinde-separatorer + */ +export const formatNumber = (number, decimals = 0) => { + if (number === null || number === undefined || isNaN(number)) { + return '0'; + } + + return new Intl.NumberFormat('da-DK', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals + }).format(number); +}; + +/** + * Formatér procent + */ +export const formatPercent = (value, decimals = 1) => { + if (value === null || value === undefined || isNaN(value)) { + return '0%'; + } + + return `${parseFloat(value).toFixed(decimals)}%`; +}; diff --git a/last_sync_status.json b/last_sync_status.json index 00d6816..2771e1b 100644 --- a/last_sync_status.json +++ b/last_sync_status.json @@ -1,8 +1,8 @@ { - "last_sync": "2025-10-21T15:02:57+00:00", + "last_sync": "2025-10-22T19:03:05+00:00", "status": "success", - "duration_seconds": 176, - "total_records": 68569, + "duration_seconds": 184, + "total_records": 68709, "active_employees": 19, - "calendar_entries_next_7_days": 16 + "calendar_entries_next_7_days": 15 } diff --git a/migrations/create_smart_packages_tables.sql b/migrations/create_smart_packages_tables.sql new file mode 100644 index 0000000..52af8ff --- /dev/null +++ b/migrations/create_smart_packages_tables.sql @@ -0,0 +1,158 @@ +-- Smart Pakker System Database Schema +-- Oprettet: 2025-10-21 +-- Formål: Komplet system til håndtering af smart pakker med materialer, tasks og montagevejledninger + +-- Udvid material_packages tabel med nye felter +ALTER TABLE material_packages +ADD COLUMN estimated_hours DECIMAL(10,2) DEFAULT 0 COMMENT 'Estimerede timer for hele pakken', +ADD COLUMN hourly_rate DECIMAL(10,2) DEFAULT 580 COMMENT 'Timepris for arbejde', +ADD COLUMN installation_notes TEXT COMMENT 'Generelle installationsnoter', +ADD COLUMN difficulty_level ENUM('let', 'mellem', 'svær') DEFAULT 'mellem' COMMENT 'Sværhedsgrad', +ADD COLUMN package_type ENUM('tag', 'køkken', 'bad', 'gulv', 'carport', 'generel') DEFAULT 'generel' COMMENT 'Type af pakke', +ADD COLUMN is_template BOOLEAN DEFAULT 0 COMMENT 'Er det en template pakke?', +ADD COLUMN popularity_score INT DEFAULT 0 COMMENT 'Popularitetsscore baseret på brug', +ADD COLUMN last_used_at DATETIME NULL COMMENT 'Sidste gang pakken blev brugt'; + +-- Opret tabel til smart pakke tasks (arbejdsopgaver) +CREATE TABLE IF NOT EXISTS smart_package_tasks ( + id INT PRIMARY KEY AUTO_INCREMENT, + package_id INT NOT NULL, + task_name VARCHAR(255) NOT NULL COMMENT 'Navn på opgaven', + task_description TEXT COMMENT 'Detaljeret beskrivelse af opgaven', + task_order INT NOT NULL DEFAULT 0 COMMENT 'Rækkefølge af opgaven i pakken', + estimated_hours DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 'Estimerede timer for denne opgave', + hourly_rate DECIMAL(10,2) DEFAULT 580 COMMENT 'Timepris for denne specifikke opgave', + required_skills TEXT COMMENT 'Nødvendige færdigheder (JSON array)', + required_tools TEXT COMMENT 'Nødvendige værktøjer (JSON array)', + is_optional BOOLEAN DEFAULT 0 COMMENT 'Er opgaven valgfri?', + depends_on_task_id INT NULL COMMENT 'Afhængig af anden opgave', + installation_manual_id INT NULL COMMENT 'Link til montagevejledning', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (package_id) REFERENCES material_packages(id) ON DELETE CASCADE, + FOREIGN KEY (depends_on_task_id) REFERENCES smart_package_tasks(id) ON DELETE SET NULL, + FOREIGN KEY (installation_manual_id) REFERENCES installation_manuals(id) ON DELETE SET NULL, + INDEX idx_package_order (package_id, task_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Arbejdsopgaver for smart pakker'; + +-- Opret tabel til montage steps (trin-for-trin vejledninger) +CREATE TABLE IF NOT EXISTS smart_package_steps ( + id INT PRIMARY KEY AUTO_INCREMENT, + task_id INT NOT NULL, + step_order INT NOT NULL DEFAULT 0 COMMENT 'Rækkefølge af trinnet', + step_title VARCHAR(255) NOT NULL COMMENT 'Titel på trinnet', + step_description TEXT NOT NULL COMMENT 'Detaljeret beskrivelse', + estimated_minutes INT DEFAULT 0 COMMENT 'Estimerede minutter for dette trin', + image_url VARCHAR(500) NULL COMMENT 'Link til billedvejledning', + video_url VARCHAR(500) NULL COMMENT 'Link til videovejledning', + safety_notes TEXT COMMENT 'Sikkerhedsnoter for dette trin', + quality_check TEXT COMMENT 'Kvalitetstjek efter trinnet', + tips TEXT COMMENT 'Tips og tricks', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (task_id) REFERENCES smart_package_tasks(id) ON DELETE CASCADE, + INDEX idx_task_order (task_id, step_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Montage trin for tasks'; + +-- Opret tabel til at koble materialer til specifikke tasks +CREATE TABLE IF NOT EXISTS task_materials ( + id INT PRIMARY KEY AUTO_INCREMENT, + task_id INT NOT NULL, + package_material_id INT NOT NULL, + quantity_used DECIMAL(10,2) NOT NULL DEFAULT 1 COMMENT 'Hvor meget af materialet bruges i denne opgave', + usage_notes TEXT COMMENT 'Noter om materialeforbruget', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (task_id) REFERENCES smart_package_tasks(id) ON DELETE CASCADE, + FOREIGN KEY (package_material_id) REFERENCES package_materials(id) ON DELETE CASCADE, + UNIQUE KEY unique_task_material (task_id, package_material_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Materialer koblet til specifikke tasks'; + +-- Opret tabel til at tracke brug af pakker (for statistik og popularitet) +CREATE TABLE IF NOT EXISTS smart_package_usage ( + id INT PRIMARY KEY AUTO_INCREMENT, + package_id INT NOT NULL, + project_id INT NULL, + used_by VARCHAR(100) COMMENT 'Bruger der anvendte pakken', + usage_date DATETIME DEFAULT CURRENT_TIMESTAMP, + adjustments_made TEXT COMMENT 'Eventuelle justeringer der blev lavet (JSON)', + feedback TEXT COMMENT 'Brugerfeedback', + FOREIGN KEY (package_id) REFERENCES material_packages(id) ON DELETE CASCADE, + INDEX idx_package_usage (package_id, usage_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Tracking af pakke brug for statistik'; + +-- Opret view til at få komplet overblik over smart pakker +CREATE OR REPLACE VIEW smart_packages_overview AS +SELECT + mp.id, + mp.name, + mp.description, + mp.category, + mp.package_type, + mp.difficulty_level, + mp.estimated_hours as package_hours, + mp.total_estimated_price as material_cost, + mp.estimated_hours * mp.hourly_rate as labor_cost, + (mp.total_estimated_price + (mp.estimated_hours * mp.hourly_rate)) as total_package_price, + mp.is_template, + mp.popularity_score, + mp.last_used_at, + COUNT(DISTINCT spt.id) as task_count, + COUNT(DISTINCT pm.id) as material_count, + COUNT(DISTINCT spu.id) as times_used, + mp.created_at, + mp.updated_at +FROM material_packages mp +LEFT JOIN smart_package_tasks spt ON mp.id = spt.package_id +LEFT JOIN package_materials pm ON mp.id = pm.package_id +LEFT JOIN smart_package_usage spu ON mp.id = spu.package_id +WHERE mp.is_active = 1 +GROUP BY mp.id; + +-- Indeks for bedre performance +CREATE INDEX idx_package_type ON material_packages(package_type); +CREATE INDEX idx_difficulty ON material_packages(difficulty_level); +CREATE INDEX idx_template ON material_packages(is_template); +CREATE INDEX idx_popularity ON material_packages(popularity_score DESC); + +-- Eksempel data: B7 Tag Udskiftning Smart Pakke +INSERT INTO material_packages ( + name, description, category, package_type, difficulty_level, + estimated_hours, hourly_rate, installation_notes, is_template, created_by +) VALUES ( + 'B7 Tag Udskiftning - Komplet', + 'Komplet pakke til udskiftning af B7 tag inkl. alle materialer og arbejdstrin', + 'Tagarbejde', + 'tag', + 'svær', + 32.0, + 580, + 'Kræver erfaring med tagarbejde. Husk arbejdstilladelse til højdearbejde.', + 1, + 'system' +); + +SET @package_id = LAST_INSERT_ID(); + +-- Eksempel materialer for B7 tag pakken +INSERT INTO package_materials (package_id, material_name, material_category, quantity, unit, unit_price, total_price) VALUES +(@package_id, 'Eternit B7 tagplader', 'Tagmateriale', 100, 'm²', 245.00, 24500.00), +(@package_id, 'Tagpap diffusionsåben', 'Tagmateriale', 105, 'm²', 65.00, 6825.00), +(@package_id, 'Lægter 38x73mm', 'Tømmer', 250, 'løbende m', 24.50, 6125.00), +(@package_id, 'Spær 45x195mm', 'Tømmer', 8, 'stk', 285.00, 2280.00); + +-- Eksempel tasks for B7 tag pakken +INSERT INTO smart_package_tasks (package_id, task_name, task_description, task_order, estimated_hours, required_skills, required_tools) VALUES +(@package_id, 'Nedrivning af gammelt tag', 'Forsigtig nedrivning og bortskaffelse af gamle tagmaterialer', 1, 8.0, '["højdearbejde", "nedrivning"]', '["stige", "hammer", "brechstang", "container"]'), +(@package_id, 'Inspektion og reparation af spær', 'Kontrol af bærende konstruktion og udbedring af skader', 2, 4.0, '["tømrerarbejde", "konstruktionsvurdering"]', '["vaterpas", "målebånd", "sav"]'), +(@package_id, 'Montage af undertag', 'Udlægning af diffusionsåben tagpap', 3, 6.0, '["tagdækning"]', '["saks", "hæftepistol", "målebånd"]'), +(@package_id, 'Montering af lægter', 'Præcis opsætning af lægter med korrekt afstand', 4, 8.0, '["tømrerarbejde", "præcision"]', '["skruemaskine", "vaterpas", "snor"]'), +(@package_id, 'Tagdækning med B7 plader', 'Montering af Eternit B7 tagplader', 5, 12.0, '["tagdækning", "højdearbejde"]', '["vinkelsliber", "boremaskine", "tagstiger"]'); + +-- Eksempel steps for første task +SET @task_id = (SELECT id FROM smart_package_tasks WHERE package_id = @package_id AND task_order = 1); + +INSERT INTO smart_package_steps (task_id, step_order, step_title, step_description, estimated_minutes, safety_notes) VALUES +(@task_id, 1, 'Opsæt sikkerhedsudstyr', 'Etabler tagsikring, stiger og faldsikring', 30, 'VIGTIGT: Brug altid sikkerhedssele ved arbejde i højden'), +(@task_id, 2, 'Start nedtagning ved tagryggen', 'Begynd fra toppen og arbejd nedad', 120, 'Pas på løse tagsten der kan falde'), +(@task_id, 3, 'Fjern gamle lægter', 'Fjern systematisk lægter og søm', 90, 'Brug handsker - mange søm og skarpe dele'), +(@task_id, 4, 'Bortskaffelse', 'Fyld container og hold arbejdsområdet ryddeligt', 60, 'Sorter materialer korrekt til affald'); diff --git a/migrations/update_smart_package_tasks.sql b/migrations/update_smart_package_tasks.sql new file mode 100644 index 0000000..e69de29 diff --git a/nohup.out b/nohup.out new file mode 100644 index 0000000..29b7b25 --- /dev/null +++ b/nohup.out @@ -0,0 +1,32 @@ +✅ Loaded Ordrestyring configuration +🗄️ Using primary database: tilbudgivern on localhost +✅ Enhanced features routes loaded successfully +✅ Smart packages routes loaded successfully +📁 Serving frontend from: /mnt/HC_Volume_103713257/tilbudgivern/frontend/build +[CalendarSync] Starting calendar auto-sync job (every hour) +[CalendarSync] Calendar sync disabled (endpoint not available) +info: Database connection established successfully {"service":"tilbudgivern-backend","timestamp":"2025-10-21T17:32:13.923Z"} +info: Database initialized successfully {"service":"tilbudgivern-backend","timestamp":"2025-10-21T17:32:13.924Z"} +info: OpenAI service initialized successfully {"service":"tilbudgivern-backend","timestamp":"2025-10-21T17:32:13.925Z"} +info: OpenAI service initialized successfully {"service":"tilbudgivern-backend","timestamp":"2025-10-21T17:32:13.925Z"} +node:events:502 + throw er; // Unhandled 'error' event + ^ + +Error: listen EADDRINUSE: address already in use :::4031 + at Server.setupListenHandle [as _listen2] (node:net:1908:16) + at listenInCluster (node:net:1965:12) + at Server.listen (node:net:2067:7) + at startServer (/mnt/HC_Volume_103713257/tilbudgivern/unified-server.js:9952:10) + at process.processTicksAndRejections (node:internal/process/task_queues:95:5) +Emitted 'error' event on Server instance at: + at emitErrorNT (node:net:1944:8) + at process.processTicksAndRejections (node:internal/process/task_queues:82:21) { + code: 'EADDRINUSE', + errno: -98, + syscall: 'listen', + address: '::', + port: 4031 +} + +Node.js v20.19.5 diff --git a/package.json b/package.json index b281963..51e97b8 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "unified-server.js", "scripts": { "start": "node unified-server.js", - "dev": "nodemon unified-server.js" + "dev": "nodemon unified-server.js", + "test-smart-packages": "node test-smart-packages-api.js" }, "dependencies": { "@playwright/test": "^1.55.1", diff --git a/setup_smart_packages.sql b/setup_smart_packages.sql new file mode 100644 index 0000000..7b6a031 --- /dev/null +++ b/setup_smart_packages.sql @@ -0,0 +1,199 @@ +-- ======================================== +-- Smart Pakker Database Migration +-- ======================================== + +-- Opret smart_packages tabel +CREATE TABLE IF NOT EXISTS smart_packages ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + category VARCHAR(100), + package_type VARCHAR(100), + difficulty_level SMALLINT DEFAULT 1, + estimated_hours DECIMAL(8,2) DEFAULT 0, + hourly_rate DECIMAL(10,2) DEFAULT 0, + installation_notes TEXT, + is_template BOOLEAN DEFAULT FALSE, + created_by VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_danish_ci; + +-- Opret smart_package_tasks tabel +CREATE TABLE IF NOT EXISTS smart_package_tasks ( + id INT AUTO_INCREMENT PRIMARY KEY, + package_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + hours DECIMAL(6,2) DEFAULT 0, + rate DECIMAL(10,2) DEFAULT 0, + skills TEXT, + tools TEXT, + optional BOOLEAN DEFAULT FALSE, + depends_on VARCHAR(255), + installation_manual_id INT, + task_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (package_id) REFERENCES smart_packages(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_danish_ci; + +-- Opret smart_package_steps tabel +CREATE TABLE IF NOT EXISTS smart_package_steps ( + id INT AUTO_INCREMENT PRIMARY KEY, + task_id INT NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + minutes INT DEFAULT 0, + image VARCHAR(255), + video VARCHAR(255), + safety TEXT, + quality TEXT, + tips TEXT, + step_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (task_id) REFERENCES smart_package_tasks(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_danish_ci; + +-- Opret smart_package_materials tabel +CREATE TABLE IF NOT EXISTS smart_package_materials ( + id INT AUTO_INCREMENT PRIMARY KEY, + package_id INT NOT NULL, + material_id INT NOT NULL, + quantity DECIMAL(10,2) DEFAULT 1, + quantity_formula VARCHAR(255), + custom_price DECIMAL(10,2), + is_required BOOLEAN DEFAULT TRUE, + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (package_id) REFERENCES smart_packages(id) ON DELETE CASCADE, + FOREIGN KEY (material_id) REFERENCES materials(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_danish_ci; + +-- Opret smart_package_categories tabel (reference data) +CREATE TABLE IF NOT EXISTS smart_package_categories ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + parent_id INT, + icon VARCHAR(100), + display_order INT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (parent_id) REFERENCES smart_package_categories(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_danish_ci; + +-- Opret smart_package_types tabel (reference data) +CREATE TABLE IF NOT EXISTS smart_package_types ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + icon VARCHAR(100), + display_order INT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_danish_ci; + +-- Indsæt standard kategorier +INSERT INTO smart_package_categories (name, icon, display_order) +VALUES +('Tag', 'roof', 10), +('Facade', 'wall', 20), +('Terrasse', 'deck', 30), +('Carport', 'garage', 40), +('Udestue', 'sunroom', 50), +('Tilbygning', 'home-plus', 60), +('Andet', 'package', 100); + +-- Indsæt standard pakketyper +INSERT INTO smart_package_types (name, description, icon, display_order) +VALUES +('Standard', 'Standard pakke med materialer og montering', 'package-variant-closed', 10), +('Premium', 'Premium pakke med ekstra kvalitetsmaterialer', 'package-variant-plus', 20), +('Komplet', 'Alt-i-én pakke med materialer, montering og tilbehør', 'package-variant-closed-check', 30), +('Basis', 'Kun grundlæggende materialer', 'package-variant', 40); + +-- Tilføj index til hurtigere søgning +-- Tjek om kolonner findes før vi laver indeks +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'tilbudgivern' + AND TABLE_NAME = 'smart_packages' + AND COLUMN_NAME = 'category'); + +SET @query = IF(@exist > 0, + 'CREATE INDEX idx_smart_packages_category ON smart_packages(category)', + 'SELECT "Skipping category index - column does not exist"'); +PREPARE stmt FROM @query; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @exist := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'tilbudgivern' + AND TABLE_NAME = 'smart_packages' + AND COLUMN_NAME = 'package_type'); + +SET @query = IF(@exist > 0, + 'CREATE INDEX idx_smart_packages_type ON smart_packages(package_type)', + 'SELECT "Skipping package_type index - column does not exist"'); +PREPARE stmt FROM @query; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Resterende indeks - tasks +SET @exist_tasks := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = 'tilbudgivern' + AND TABLE_NAME = 'smart_package_tasks' + AND INDEX_NAME = 'idx_smart_package_tasks_package' +); + +SET @query_tasks = IF(@exist_tasks = 0, + 'CREATE INDEX idx_smart_package_tasks_package ON smart_package_tasks(package_id)', + 'SELECT "Index idx_smart_package_tasks_package already exists"'); +PREPARE stmt FROM @query_tasks; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Indeks for steps +SET @exist_steps := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = 'tilbudgivern' + AND TABLE_NAME = 'smart_package_steps' + AND INDEX_NAME = 'idx_smart_package_steps_task' +); + +SET @query_steps = IF(@exist_steps = 0, + 'CREATE INDEX idx_smart_package_steps_task ON smart_package_steps(task_id)', + 'SELECT "Index idx_smart_package_steps_task already exists"'); +PREPARE stmt FROM @query_steps; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Indeks for materials +SET @exist_materials := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = 'tilbudgivern' + AND TABLE_NAME = 'smart_package_materials' + AND INDEX_NAME = 'idx_smart_package_materials_package' +); + +SET @query_materials = IF(@exist_materials = 0, + 'CREATE INDEX idx_smart_package_materials_package ON smart_package_materials(package_id)', + 'SELECT "Index idx_smart_package_materials_package already exists"'); +PREPARE stmt FROM @query_materials; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Indeks for materials relation til material_id +SET @exist_materials_rel := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = 'tilbudgivern' + AND TABLE_NAME = 'smart_package_materials' + AND INDEX_NAME = 'idx_smart_package_materials_material' +); + +SET @query_materials_rel = IF(@exist_materials_rel = 0, + 'CREATE INDEX idx_smart_package_materials_material ON smart_package_materials(material_id)', + 'SELECT "Index idx_smart_package_materials_material already exists"'); +PREPARE stmt FROM @query_materials_rel; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; \ No newline at end of file diff --git a/test-smart-packages-api.js b/test-smart-packages-api.js new file mode 100644 index 0000000..4594c90 --- /dev/null +++ b/test-smart-packages-api.js @@ -0,0 +1,115 @@ +const axios = require('axios'); + +// Konfiguration +const API_BASE_URL = process.env.API_URL || 'http://127.0.0.1:4032/api'; + +/** + * Smart Pakker API Test + * + * Denne test kontrollerer at Smart Pakker API endepunkterne fungerer korrekt + */ +async function testSmartPackagesAPI() { + console.log('🧪 SMART PAKKER API TEST'); + console.log('========================='); + + try { + // Test GET /api/smart-packages + console.log('\n📋 Test: Henter alle smart pakker'); + const allPackages = await axios.get(`${API_BASE_URL}/smart-packages`); + console.log(`✅ Status: ${allPackages.status}`); + console.log(`✅ Pakker fundet: ${allPackages.data.packages?.length || 0}`); + + // Opret test pakke + console.log('\n📦 Test: Opretter ny smart pakke'); + const newPackageData = { + name: 'Test Smart Pakke', + description: 'Dette er en test pakke', + category: 'tag', + package_type: 'standard', + difficulty_level: 2, + estimated_hours: 10, + hourly_rate: 580, + installation_notes: 'Test installation noter', + is_template: false, + materials: [], + tasks: [ + { + name: 'Test opgave 1', + description: 'Testbeskrivelse af opgave 1', + hours: 2.5, + rate: 580, + task_order: 1 + } + ], + created_by: 'test-script' + }; + + let createdPackage; + try { + createdPackage = await axios.post(`${API_BASE_URL}/smart-packages`, newPackageData); + console.log(`✅ Status: ${createdPackage.status}`); + console.log(`✅ Oprettet pakke ID: ${createdPackage.data.packageId}`); + + // Hent detaljer om den oprettede pakke + console.log(`\n🔍 Test: Henter detaljer for pakke ID ${createdPackage.data.packageId}`); + const packageDetails = await axios.get(`${API_BASE_URL}/smart-packages/${createdPackage.data.packageId}`); + console.log(`✅ Status: ${packageDetails.status}`); + console.log(`✅ Pakke navn: ${packageDetails.data.package.name}`); + console.log(`✅ Antal opgaver: ${packageDetails.data.tasks?.length || 0}`); + + // Opdater pakken + console.log(`\n✏️ Test: Opdaterer pakke ID ${createdPackage.data.packageId}`); + const updateResponse = await axios.put( + `${API_BASE_URL}/smart-packages/${createdPackage.data.packageId}`, + { + ...newPackageData, + name: 'Test Smart Pakke (Opdateret)', + estimated_hours: 12 + } + ); + console.log(`✅ Status: ${updateResponse.status}`); + + // Slet test pakken + console.log(`\n🗑️ Test: Sletter pakke ID ${createdPackage.data.packageId}`); + const deleteResponse = await axios.delete(`${API_BASE_URL}/smart-packages/${createdPackage.data.packageId}`); + console.log(`✅ Status: ${deleteResponse.status}`); + + } catch (error) { + console.error('❌ Fejl i test af CRUD operationer:', error.message); + if (error.response) { + console.error('Fejlrespons data:', error.response.data); + } + } + + // Test kategorier og pakketyper + console.log('\n📊 Test: Henter Smart Pakke kategorier'); + try { + const categories = await axios.get(`${API_BASE_URL}/smart-packages/categories`); + console.log(`✅ Status: ${categories.status}`); + console.log(`✅ Antal kategorier: ${categories.data.categories?.length || 0}`); + } catch (error) { + console.error('❌ Fejl ved hentning af kategorier:', error.message); + } + + console.log('\n📊 Test: Henter Smart Pakke typer'); + try { + const types = await axios.get(`${API_BASE_URL}/smart-packages/types`); + console.log(`✅ Status: ${types.status}`); + console.log(`✅ Antal typer: ${types.data.types?.length || 0}`); + } catch (error) { + console.error('❌ Fejl ved hentning af pakketyper:', error.message); + } + + console.log('\n✅ SMART PAKKER API TEST AFSLUTTET'); + + } catch (error) { + console.error('❌ Generel fejl i Smart Pakker API test:', error.message); + if (error.response) { + console.error('Status:', error.response.status); + console.error('Data:', error.response.data); + } + } +} + +// Kør testen +testSmartPackagesAPI(); \ No newline at end of file diff --git a/unified-server.js b/unified-server.js index 03d5863..98e44db 100644 --- a/unified-server.js +++ b/unified-server.js @@ -105,7 +105,7 @@ const io = new Server(server, { methods: ["GET", "POST"] } }); -const PORT = process.env.PORT || 4031; +const PORT = process.env.PORT || 4032; // Database connection setup const mysql = require('mysql2/promise'); @@ -283,7 +283,7 @@ const csvUpload = multer({ }); // Initialize backend services -let openaiService, databaseService, webPriceService, dynamicImportService, orderStatusService, quoteTemplateService, pdfGenerationService, ordrestyringService, realDataProjectSuggestionService, enhancedOrderDataService, materialPackageService, planningService, ordrestyringSyncService, calendarSyncService; +let openaiService, databaseService, webPriceService, dynamicImportService, orderStatusService, quoteTemplateService, pdfGenerationService, ordrestyringService, realDataProjectSuggestionService, enhancedOrderDataService, materialPackageService, smartPackageManagementService, planningService, ordrestyringSyncService, calendarSyncService; const initializeBackend = async () => { try { @@ -301,6 +301,7 @@ const initializeBackend = async () => { const RealDataProjectSuggestionService = require('./backend/src/services/realDataProjectSuggestionService'); const EnhancedOrderDataService = require('./backend/src/services/enhancedOrderDataService'); const MaterialPackageService = require('./backend/src/services/materialPackageService'); + const SmartPackageManagementService = require('./backend/src/services/smartPackageManagementService'); const PlanningService = require('./backend/src/services/planningService'); const OrdrestyringSyncService = require('./backend/src/services/ordrestyringSyncService'); const CalendarSyncService = require('./backend/src/services/calendarSyncService'); @@ -314,6 +315,7 @@ const initializeBackend = async () => { realDataProjectSuggestionService = new RealDataProjectSuggestionService(databaseService); enhancedOrderDataService = new EnhancedOrderDataService(databaseService); materialPackageService = new MaterialPackageService(databaseService); + smartPackageManagementService = new SmartPackageManagementService(databaseService); planningService = new PlanningService(databaseService); // Initialize sync services @@ -327,8 +329,10 @@ const initializeBackend = async () => { await databaseService.initialize(); logger.info('Database initialized successfully'); - // Make database service available to routes + // Make services available globally and to routes app.locals.databaseService = databaseService; + global.databaseService = databaseService; + global.smartPackageManagementService = smartPackageManagementService; // Initialize OpenAI service try { @@ -517,6 +521,15 @@ try { console.error('❌ Failed to load enhanced features routes:', error.message); } +// Smart Packages API Routes +try { + const smartPackagesRoutes = require('./backend/src/routes/smartPackagesRoutes'); + app.use('/api/smart-packages', smartPackagesRoutes); + console.log('✅ Smart packages routes loaded successfully'); +} catch (error) { + console.error('❌ Failed to load smart packages routes:', error.message); +} + // OpenAI Stats endpoint app.get('/api/quotes/openai/stats', async (req, res) => { try {