fix: resolve open smart package issues
This commit is contained in:
@@ -100,6 +100,8 @@ const getCompletedMapping = (mappingJobId, fileHash) => {
|
||||
return job.result;
|
||||
};
|
||||
|
||||
const stripStoredExcelPrefix = filename => filename.replace(/^\d+-/, '');
|
||||
|
||||
// ========================================
|
||||
// SMART PAKKER API ENDPOINTS
|
||||
// ========================================
|
||||
@@ -286,6 +288,48 @@ router.get('/material-master-search', async (req, res) => {
|
||||
});
|
||||
|
||||
// Upload Excel-fil til senere SmartPakker-import
|
||||
router.get('/excel-uploads', verifyToken, async (req, res) => {
|
||||
try {
|
||||
fs.mkdirSync(excelUploadDir, { recursive: true });
|
||||
const uploadedFiles = fs.readdirSync(excelUploadDir)
|
||||
.filter(filename => ['.xlsx', '.xls', '.xlsm', '.csv'].includes(path.extname(filename).toLowerCase()))
|
||||
.map(filename => {
|
||||
const stat = fs.statSync(path.join(excelUploadDir, filename));
|
||||
return {
|
||||
filename,
|
||||
displayName: stripStoredExcelPrefix(filename),
|
||||
size: stat.size,
|
||||
uploadedAt: stat.mtime.toISOString()
|
||||
};
|
||||
});
|
||||
const importRows = await global.databaseService.query(
|
||||
`SELECT excel_source_file,
|
||||
SUM(is_active = 1) AS active_packages,
|
||||
SUM(is_active = 0) AS inactive_packages,
|
||||
MAX(updated_at) AS imported_at
|
||||
FROM material_packages
|
||||
WHERE created_by = 'excel-import' AND excel_source_file IS NOT NULL
|
||||
GROUP BY excel_source_file`
|
||||
);
|
||||
const importsByFile = new Map(importRows.map(row => [row.excel_source_file, row]));
|
||||
const files = uploadedFiles.map(file => {
|
||||
const importInfo = importsByFile.get(file.filename);
|
||||
return {
|
||||
...file,
|
||||
importStatus: importInfo ? 'imported' : 'uploaded',
|
||||
activePackages: Number(importInfo?.active_packages || 0),
|
||||
inactivePackages: Number(importInfo?.inactive_packages || 0),
|
||||
importedAt: importInfo?.imported_at || null
|
||||
};
|
||||
}).sort((left, right) => new Date(right.uploadedAt) - new Date(left.uploadedAt));
|
||||
|
||||
return res.json({ success: true, files: files.slice(0, 20) });
|
||||
} catch (error) {
|
||||
console.error('Error listing SmartPakker Excel uploads:', error);
|
||||
return res.status(500).json({ success: false, error: 'Kunne ikke hente Excel-historikken' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/excel-upload', verifyToken, (req, res) => {
|
||||
excelUpload.single('file')(req, res, async (error) => {
|
||||
if (error) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PROJECT_STATUS, getProjectStatusMeta, isFinalizedProjectStatus } from '
|
||||
import { FLOW_STEPS, getActiveStageIndex } from '../utils/statusFlow';
|
||||
import AiControlCenter from './ai/AiControlCenter';
|
||||
import { markQuoteFlowMilestone } from '../utils/quoteFlowTelemetry';
|
||||
import { normalizeProjectLine } from '../utils/projectLines';
|
||||
|
||||
const toMoneyNumber = (value) => {
|
||||
const parsed = Number(value);
|
||||
@@ -295,10 +296,10 @@ const FinalReview = ({
|
||||
const [submitResult, setSubmitResult] = useState(null);
|
||||
|
||||
// Editable pricing state
|
||||
const initialMaterials = (packageData?.materials || []).filter((item) => !isRentalItem(item));
|
||||
const initialMaterials = (packageData?.materials || []).filter((item) => !isRentalItem(item)).map(normalizeProjectLine);
|
||||
const initialRentals = (packageData?.rentals || []).length > 0
|
||||
? packageData.rentals
|
||||
: (packageData?.materials || []).filter((item) => isRentalItem(item));
|
||||
? packageData.rentals.map(normalizeProjectLine)
|
||||
: (packageData?.materials || []).filter((item) => isRentalItem(item)).map(normalizeProjectLine);
|
||||
const [editableMaterials, setEditableMaterials] = useState(initialMaterials);
|
||||
const [editableRentalItems, setEditableRentalItems] = useState(initialRentals);
|
||||
const [editableLaborTasks, setEditableLaborTasks] = useState(packageData?.laborTasks || []);
|
||||
@@ -545,11 +546,11 @@ const FinalReview = ({
|
||||
// Sync editable state when packageData changes
|
||||
React.useEffect(() => {
|
||||
if (packageData?.materials) {
|
||||
setEditableMaterials(packageData.materials.filter((item) => !isRentalItem(item)));
|
||||
setEditableMaterials(packageData.materials.filter((item) => !isRentalItem(item)).map(normalizeProjectLine));
|
||||
setEditableRentalItems(
|
||||
(packageData?.rentals || []).length > 0
|
||||
? packageData.rentals
|
||||
: packageData.materials.filter((item) => isRentalItem(item))
|
||||
? packageData.rentals.map(normalizeProjectLine)
|
||||
: packageData.materials.filter((item) => isRentalItem(item)).map(normalizeProjectLine)
|
||||
);
|
||||
}
|
||||
if (packageData?.laborTasks) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import FinalReview from './FinalReview';
|
||||
import { useNotification } from '../hooks/useNotification';
|
||||
import { getProjectStatusMeta, getProjectStepFromStatus, isFinalizedProjectStatus } from '../utils/projectStatus';
|
||||
import { normalizeProjectLaborResponse } from '../utils/projectLabor';
|
||||
import { normalizeProjectLine, reconcilePackageLines } from '../utils/projectLines';
|
||||
import {
|
||||
markQuoteFlowMilestone,
|
||||
setProductContext,
|
||||
@@ -382,13 +383,14 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
|
||||
const materialsData = await materialsResponse.json();
|
||||
if (materialsData.success && materialsData.materials) {
|
||||
console.log('🧱 Loaded materials:', materialsData.materials.length, 'items');
|
||||
loadedMaterials = materialsData.materials.map((material) => ({
|
||||
loadedMaterials = materialsData.materials.map((material) => normalizeProjectLine({
|
||||
id: material.id,
|
||||
name: material.material_name || material.name,
|
||||
category: material.material_category || material.category || 'Øvrige',
|
||||
quantity: parseFloat(material.quantity) || 0,
|
||||
unit: material.unit || 'stk',
|
||||
unitPrice: parseFloat(material.unit_price ?? material.unitPrice ?? 0) || 0,
|
||||
unitPrice: material.unit_price ?? material.unitPrice,
|
||||
total: material.total_price ?? material.total,
|
||||
supplier: material.supplier || null,
|
||||
calculation: material.notes || ''
|
||||
}));
|
||||
@@ -405,7 +407,7 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
|
||||
if (rentalsResponse.ok) {
|
||||
const rentalsData = await rentalsResponse.json();
|
||||
if (rentalsData.success && rentalsData.rentals) {
|
||||
loadedRentals = rentalsData.rentals.map((r) => ({
|
||||
loadedRentals = rentalsData.rentals.map((r) => normalizeProjectLine({
|
||||
name: r.rental_name || r.name,
|
||||
category: r.rental_category || r.category,
|
||||
quantity: r.quantity,
|
||||
@@ -447,7 +449,11 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
|
||||
sessionPackageData.rentals = loadedRentals;
|
||||
}
|
||||
const restoredPackageData = {
|
||||
...sessionPackageData,
|
||||
...reconcilePackageLines({
|
||||
sessionPackageData,
|
||||
persistedMaterials: loadedMaterials,
|
||||
persistedRentals: loadedRentals
|
||||
}),
|
||||
laborTasks: Array.isArray(sessionPackageData.laborTasks) && sessionPackageData.laborTasks.length > 0
|
||||
? sessionPackageData.laborTasks
|
||||
: loadedLaborTasks
|
||||
|
||||
@@ -60,6 +60,7 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
|
||||
const [excelMappingJob, setExcelMappingJob] = useState(null);
|
||||
const [excelMapping, setExcelMapping] = useState(null);
|
||||
const [showAllExcelIssues, setShowAllExcelIssues] = useState(false);
|
||||
const [excelUploads, setExcelUploads] = useState([]);
|
||||
|
||||
// Filtre og søgning
|
||||
const [filters, setFilters] = useState({
|
||||
@@ -103,8 +104,18 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
|
||||
console.log('🔵 SmartPackages: useEffect triggered, fetching packages...');
|
||||
fetchPackages();
|
||||
fetchCategories();
|
||||
fetchExcelUploads();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- fetchPackages/fetchCategories are stable functions
|
||||
}, [currentPage, filters]);
|
||||
|
||||
const fetchExcelUploads = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/smart-packages/excel-uploads');
|
||||
setExcelUploads(response.data?.files || []);
|
||||
} catch (error) {
|
||||
console.warn('Kunne ikke hente Excel-historik:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Hent pakker fra API
|
||||
const fetchPackages = async () => {
|
||||
@@ -268,6 +279,7 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
|
||||
|
||||
if (response.data.success) {
|
||||
setUploadedExcelFile(response.data.file);
|
||||
await fetchExcelUploads();
|
||||
setExcelValidation({ standard: response.data.standardValidation, ai: null });
|
||||
|
||||
if (response.data.standardValidation?.blocksImport) {
|
||||
@@ -412,6 +424,7 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
|
||||
|
||||
setExcelImportResult(response.data.result);
|
||||
setExcelPhase('imported');
|
||||
await fetchExcelUploads();
|
||||
setCurrentPage(1);
|
||||
await Promise.all([fetchPackages(), fetchCategories()]);
|
||||
enqueueSnackbar(`${response.data.result.totalPackages} SmartPakker importeret`, { variant: 'success' });
|
||||
@@ -739,6 +752,46 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
|
||||
{' '}{excelImportResult.updatedPackages} opdateret og {excelImportResult.deactivatedPackages} deaktiveret.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{excelUploads.length > 0 && (
|
||||
<Box sx={{ mt: 2.5 }}>
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Seneste Excel-filer</Typography>
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
{excelUploads.slice(0, 5).map(file => (
|
||||
<Box
|
||||
key={file.filename}
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: 'minmax(0, 1fr) auto' },
|
||||
gap: 1,
|
||||
alignItems: 'center',
|
||||
p: 1.25,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1.5
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" fontWeight={700} noWrap title={file.displayName}>
|
||||
{file.displayName}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(file.uploadedAt).toLocaleString('da-DK')} · {(file.size / 1024).toFixed(0)} KB
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
size="small"
|
||||
color={file.importStatus === 'imported' ? 'success' : 'info'}
|
||||
label={file.importStatus === 'imported'
|
||||
? `${file.activePackages} aktive pakker`
|
||||
: 'Uploadet – ikke importeret'}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2, mb: 3, backgroundColor: '#f5f5f5', borderRadius: '8px' }}>
|
||||
@@ -890,9 +943,9 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: 136,
|
||||
px: 2.5,
|
||||
py: 2,
|
||||
minHeight: { xs: 108, sm: 136 },
|
||||
px: { xs: 2, sm: 2.5 },
|
||||
py: { xs: 1.5, sm: 2 },
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #dbeafe 48%, #dcfce7 100%)',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
@@ -910,7 +963,7 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
|
||||
border: '1px solid rgba(148, 163, 184, 0.35)'
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h6" sx={{ mt: 2, fontWeight: 700, color: 'text.primary' }}>
|
||||
<Typography variant="h6" sx={{ mt: { xs: 1, sm: 2 }, pr: 7, fontWeight: 700, color: 'text.primary' }}>
|
||||
{packageItem.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1, color: 'text.secondary' }}>
|
||||
@@ -918,7 +971,7 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<CardContent sx={{ flexGrow: 1 }}>
|
||||
<CardContent sx={{ flexGrow: 1, display: { xs: 'none', sm: 'block' } }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
@@ -953,13 +1006,15 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box
|
||||
p={1}
|
||||
display="flex"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
<Divider sx={{ display: { xs: 'none', sm: 'block' } }} />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'flex' },
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
p: 1
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
@@ -970,12 +1025,19 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
<Divider sx={{ display: { xs: 'none', sm: 'block' } }} />
|
||||
|
||||
<Box p={1} display="flex" justifyContent="space-between">
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'flex' },
|
||||
justifyContent: 'space-between',
|
||||
p: 1
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Se detaljer">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`Se detaljer for ${packageItem.name}`}
|
||||
onClick={() => handleViewPackage(packageItem)}
|
||||
>
|
||||
<ViewIcon fontSize="small" />
|
||||
@@ -985,6 +1047,7 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
|
||||
<Tooltip title="Redigér">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`Redigér ${packageItem.name}`}
|
||||
onClick={() => handleEditPackage(packageItem.id)}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
@@ -995,12 +1058,26 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
aria-label={`Slet ${packageItem.name}`}
|
||||
onClick={() => confirmDeletePackage(packageItem)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: { xs: 'block', sm: 'none' }, p: 1 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
size="small"
|
||||
variant="outlined"
|
||||
aria-label={`Se detaljer for ${packageItem.name}`}
|
||||
startIcon={<ViewIcon />}
|
||||
onClick={() => handleViewPackage(packageItem)}
|
||||
>
|
||||
Se detaljer
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
30
frontend/src/utils/projectLines.js
Normal file
30
frontend/src/utils/projectLines.js
Normal file
@@ -0,0 +1,30 @@
|
||||
export const normalizeProjectLine = (line = {}) => {
|
||||
const quantity = Number.parseFloat(line.quantity) || 0;
|
||||
const explicitUnitPrice = Number.parseFloat(line.unitPrice ?? line.unit_price);
|
||||
const explicitTotal = Number.parseFloat(
|
||||
line.total ?? line.totalPrice ?? line.total_price ?? line.line_total
|
||||
);
|
||||
const unitPrice = Number.isFinite(explicitUnitPrice)
|
||||
? explicitUnitPrice
|
||||
: (quantity > 0 && Number.isFinite(explicitTotal) ? explicitTotal / quantity : 0);
|
||||
|
||||
return {
|
||||
...line,
|
||||
name: line.name || line.material_name || line.rental_name || 'Ukendt linje',
|
||||
category: line.category || line.material_category || line.rental_category || 'Øvrige',
|
||||
quantity,
|
||||
unit: line.unit || 'stk',
|
||||
unitPrice,
|
||||
total: Number.isFinite(explicitTotal) ? explicitTotal : quantity * unitPrice
|
||||
};
|
||||
};
|
||||
|
||||
export const reconcilePackageLines = ({ sessionPackageData, persistedMaterials, persistedRentals }) => ({
|
||||
...sessionPackageData,
|
||||
materials: persistedMaterials.length > 0
|
||||
? persistedMaterials.map(normalizeProjectLine)
|
||||
: (sessionPackageData.materials || []).map(normalizeProjectLine),
|
||||
rentals: persistedRentals.length > 0
|
||||
? persistedRentals.map(normalizeProjectLine)
|
||||
: (sessionPackageData.rentals || []).map(normalizeProjectLine)
|
||||
});
|
||||
32
frontend/src/utils/projectLines.test.js
Normal file
32
frontend/src/utils/projectLines.test.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { normalizeProjectLine, reconcilePackageLines } from './projectLines';
|
||||
|
||||
describe('project line normalization', () => {
|
||||
test('maps persisted snake_case prices into the editable line contract', () => {
|
||||
expect(normalizeProjectLine({
|
||||
material_name: 'Betontagsten',
|
||||
quantity: '72.700',
|
||||
unit: 'm²',
|
||||
unit_price: '180.00',
|
||||
total_price: '13086.00'
|
||||
})).toMatchObject({
|
||||
name: 'Betontagsten',
|
||||
quantity: 72.7,
|
||||
unitPrice: 180,
|
||||
total: 13086
|
||||
});
|
||||
});
|
||||
|
||||
test('lets persisted project lines replace stale zero-price session lines', () => {
|
||||
const result = reconcilePackageLines({
|
||||
sessionPackageData: {
|
||||
materials: [{ name: 'Betontagsten', quantity: 72.7, unitPrice: 0 }],
|
||||
rentals: [{ name: 'Stillads', quantity: 1, unitPrice: 0 }]
|
||||
},
|
||||
persistedMaterials: [{ material_name: 'Betontagsten', quantity: 72.7, unit_price: 180 }],
|
||||
persistedRentals: [{ rental_name: 'Stillads', quantity: 1, unit_price: 14000 }]
|
||||
});
|
||||
|
||||
expect(result.materials[0].unitPrice).toBe(180);
|
||||
expect(result.rentals[0].unitPrice).toBe(14000);
|
||||
});
|
||||
});
|
||||
10
status.md
10
status.md
@@ -236,6 +236,16 @@ Formål: Genbrug godkendte data sikkert videre i driften og bevar et revisionssp
|
||||
8. Tag højst ét større initiativ i gang pr. fase ad gangen, løs og verificér det, og opdatér denne tabel ved hver ændring.
|
||||
9. Ved hver månedskontrol revurderes prioritet, ejer, måldato, status og beståelseskriterium.
|
||||
|
||||
## GitHub-fejlrettelser, 13. august 2026
|
||||
|
||||
| Issue | Resultat | Verifikation |
|
||||
|---|---|---|
|
||||
| #9 — Manglende smartpakker/Excel-fil | ✅ Løst | Smartpakker viser nu de seneste Excel-uploads efter genindlæsning samt importstatus. Den importerede fil er synlig med **414 aktive pakker**. |
|
||||
| #10 — For meget indhold i feltet | ✅ Løst | Smartpakkekort er komprimeret på 390 × 844 px: kategori, navn, tid/pris og én tydelig “Se detaljer”-knap vises; gentaget pris, metadata og skrivebordshandlinger er skjult på mobil. |
|
||||
| #11 — Materiale- og udlejningslinjer viser 0,00 kr. | ✅ Løst | Projekt 392 viser i Final Review 11 prissatte materialelinjer, **44.981,80 kr.** i materialer og **14.000,00 kr.** i udlejning. Persistente projektlinjer tilsidesætter nu forældede sessionslinjer. |
|
||||
|
||||
Teknisk regression: backend **38 suites / 195 tests**, frontend **8 suites / 19 tests**, production build og live Playwright-kontrol bestod. PM2-servicen blev genstartet, og `/api/health` svarede grønt.
|
||||
|
||||
## Anbefalet næste handling
|
||||
|
||||
**Platform:** Gennemfør næste tømrer-mode-feltforsøg fra helt ny kunde gennem fysisk besigtigelse på mobil via den nye “Nyt tilbud”-indgang. August-rapportens kendte-projekt-baseline og den første mobile UX-bølge er oprettet; næste kontrol skal især måle resterende manuel indtastning, offline-risiko og den åbne P2-checkliste.
|
||||
|
||||
Reference in New Issue
Block a user