added docker-compose for linux

This commit is contained in:
2026-02-21 19:53:04 +02:00
parent be91231b05
commit 58b1ae9a5e
23 changed files with 761 additions and 16 deletions
+25
View File
@@ -0,0 +1,25 @@
# Stage 1: build the React app
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# These are baked into the JS bundle at build time
ARG REACT_APP_API_URL
ARG REACT_APP_SERIAL_URL
ENV REACT_APP_API_URL=$REACT_APP_API_URL
ENV REACT_APP_SERIAL_URL=$REACT_APP_SERIAL_URL
RUN npm run build
# Stage 2: serve with nginx
FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+15
View File
@@ -0,0 +1,15 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# React SPA — serve index.html for all unknown paths
location / {
try_files $uri $uri/ /index.html;
}
# Disable caching for the entry point so updates are picked up immediately
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
}
+53 -1
View File
@@ -1,13 +1,16 @@
import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import Header from './Header';
import useSerialData from '../hooks/useSerialData';
import { useNomenclatures } from '../contexts/NomenclatureContext';
import { useNomenclatureData } from '../contexts/NomenclatureDataContext';
import NomenclatureDropdown from './NomenclatureUI/NomenclatureDropdown';
import ReportPreviewModal from './ReportPreviewModal';
import api from '../services/api';
import './Main.css';
export default function Main() {
const navigate = useNavigate();
const { readings, isConnected } = useSerialData();
const { vehicles } = useNomenclatures();
@@ -18,9 +21,10 @@ export default function Main() {
const [newExtraData, setNewExtraData] = useState({});
const [error, setError] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [showNoticeReport, setShowNoticeReport] = useState(false);
// Nomenclature data from context
const { definitions } = useNomenclatureData();
const { definitions, entries } = useNomenclatureData();
const vehicleNomenclatures = useMemo(
() => Object.values(definitions)
.filter(d => d.applies_to === 'vehicle')
@@ -164,6 +168,31 @@ export default function Main() {
? selectedVehicle.gross - selectedVehicle.tare
: null;
// Vehicle data shaped for report preview/design
// Transforms extra.data keys from raw codes/ids to { [nomenclature name]: display value }
const vehicleReportData = useMemo(() => {
if (!selectedVehicle) return null;
const rawExtra = selectedVehicle.extra?.data || {};
const extra = {};
for (const [code, value] of Object.entries(rawExtra)) {
const def = definitions[code];
if (!def) continue;
if (def.kind === 'lookup') {
const entry = (entries[code] || []).find(e => e.id === value);
extra[def.name] = entry?.display_value ?? '';
} else {
extra[def.name] = value ?? '';
}
}
return {
vehicle: {
...selectedVehicle,
net: netWeight,
extra,
}
};
}, [selectedVehicle, netWeight, definitions, entries]);
return (
<div>
<Header />
@@ -363,6 +392,23 @@ export default function Main() {
<div className="weight-card weight-card--net">
<div className="weight-card-header">Net</div>
<div className="weight-card-value">{netWeight} kg</div>
<button
className="weight-set-btn"
onClick={() => setShowNoticeReport(true)}
>
Print Notice
</button>
<button
className="weight-set-btn"
onClick={() => navigate('/report-editor', {
state: {
reportName: 'notice_report',
vehicleData: vehicleReportData,
}
})}
>
Design Notice
</button>
</div>
)}
</div>
@@ -398,6 +444,12 @@ export default function Main() {
)}
</div>
</div>
{showNoticeReport && vehicleReportData && (
<ReportPreviewModal
vehicleData={vehicleReportData}
onClose={() => setShowNoticeReport(false)}
/>
)}
</div>
);
}
@@ -8,6 +8,30 @@ import { useElementSelection } from './hooks/useElementSelection';
import { getElementStyle, isTextMultiLine, getTextWhiteSpace } from './utils/elementUtils';
import './elements.css';
function formatValue(raw, dataType) {
if (raw === '' || raw === null || raw === undefined) return '';
switch (dataType) {
case 'number': {
const n = Number(raw);
return isNaN(n) ? '' : String(n);
}
case 'date': {
const d = new Date(raw);
return isNaN(d.getTime()) ? '' : d.toLocaleDateString();
}
case 'time': {
const d = new Date(raw);
return isNaN(d.getTime()) ? '' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
case 'datetime': {
const d = new Date(raw);
return isNaN(d.getTime()) ? '' : d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
default:
return String(raw);
}
}
function DBTextField({
element,
isSelected,
@@ -51,12 +75,16 @@ function DBTextField({
};
// Resolve data value
const displayContent = previewMode
const rawValue = previewMode
? resolveDBTextValue(reportData, element.objectKey, element.fieldPath, parentBandId, { currentBandData })
: parentBandId
? `{${element.fieldPath}}` // Inside band: show field only
: `{${element.objectKey}.${element.fieldPath}}`; // Outside band: show object.field
const displayContent = previewMode
? formatValue(rawValue, element.dataType || 'general')
: rawValue;
// Check if data is resolved
// If inside a band, only fieldPath is required; otherwise both objectKey and fieldPath are required
const isUnresolved = parentBandId
@@ -71,7 +99,8 @@ function DBTextField({
...getElementStyle(element, charWidth, charHeight),
minWidth: `${charWidth}px`,
minHeight: `${charHeight}px`,
whiteSpace
whiteSpace,
textAlign: element.alignment || 'left',
};
return (
@@ -237,6 +237,32 @@ function ObjectInspector({ element, onUpdate, allElements = [] }) {
</div>
</>
)}
<div className="property-row">
<label>Data Type:</label>
<select
value={element.dataType || 'general'}
onChange={(e) => handleChange('dataType', e.target.value)}
>
<option value="general">General</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="time">Time</option>
<option value="datetime">Date &amp; Time</option>
</select>
</div>
<div className="property-row">
<label>Alignment:</label>
<select
value={element.alignment || 'left'}
onChange={(e) => handleChange('alignment', e.target.value)}
>
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
</select>
</div>
</div>
)}
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { DataProvider } from './DataContext';
import { useLocation } from 'react-router-dom';
import { DataProvider, useReportData } from './DataContext';
import { BandProvider } from './BandContext';
import Toolbar from './Toolbar';
import EditorCanvas from './EditorCanvas';
@@ -33,6 +34,38 @@ function ReportEditorContent() {
const [savedReports, setSavedReports] = useState([]);
const [loadingReports, setLoadingReports] = useState(false);
// Auto-load report and vehicle data passed via router state
const location = useLocation();
const { setData } = useReportData();
useEffect(() => {
const { reportName, vehicleData } = location.state || {};
if (!reportName) return;
api.get('/api/reports/').then(res => {
const reports = res.data.results || res.data;
const found = reports.find(r => r.name === reportName);
if (found) {
setReport({
name: found.name,
pageWidth: found.page_width,
pageHeight: found.page_height,
apiEndpoint: found.api_endpoint || '',
elements: found.elements || [],
});
setReportId(found.id);
} else {
// Report doesn't exist yet — pre-fill the name so Save creates it
setReport(prev => ({ ...prev, name: reportName }));
}
if (vehicleData) {
setData(vehicleData);
}
}).catch(err => {
console.error('Failed to auto-load report:', err);
});
}, []);
const handleAddElement = (element) => {
setReport(prev => {
// Check if the new element is positioned inside a band
@@ -113,12 +113,14 @@ export class Element {
* Database-bound text field element
*/
export class DBTextElement extends Element {
constructor({ id, x, y, width = 10, height = 1, objectKey = '', fieldPath = '' }) {
constructor({ id, x, y, width = 10, height = 1, objectKey = '', fieldPath = '', dataType = 'general', alignment = 'left' }) {
super({ id, type: 'dbtext', x, y });
this.width = width;
this.height = height;
this.objectKey = objectKey;
this.fieldPath = fieldPath;
this.dataType = dataType;
this.alignment = alignment;
}
getBounds() {
@@ -148,7 +150,9 @@ export class DBTextElement extends Element {
width: this.width,
height: this.height,
objectKey: this.objectKey,
fieldPath: this.fieldPath
fieldPath: this.fieldPath,
dataType: this.dataType,
alignment: this.alignment
};
}
}
@@ -0,0 +1,62 @@
.report-preview-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.report-preview-modal {
background: #fff;
border-radius: 4px;
display: flex;
flex-direction: column;
max-width: 95vw;
max-height: 95vh;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.report-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid #ddd;
font-weight: 600;
font-size: 14px;
flex-shrink: 0;
}
.report-preview-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
line-height: 1;
padding: 0 4px;
color: #555;
}
.report-preview-close:hover {
color: #000;
}
.report-preview-body {
overflow: auto;
flex: 1;
padding: 8px;
}
.report-preview-error {
padding: 24px;
color: #c00;
font-size: 14px;
}
.report-preview-loading {
padding: 24px;
color: #666;
font-size: 14px;
}
@@ -0,0 +1,73 @@
import React, { useEffect, useState } from 'react';
import { DataProvider, useReportData } from './ReportEditor/DataContext';
import { BandProvider } from './ReportEditor/BandContext';
import EditorCanvas from './ReportEditor/EditorCanvas';
import api from '../services/api';
import './ReportPreviewModal.css';
function PreviewContent({ vehicleData, elements }) {
const { setData } = useReportData();
useEffect(() => {
setData(vehicleData);
}, [vehicleData]);
return (
<BandProvider>
<EditorCanvas
elements={elements}
selectedElementIds={[]}
previewMode={true}
toolMode="select"
borderStyle="single"
onElementSelect={() => {}}
onSelectMultiple={() => {}}
onDeselectAll={() => {}}
onElementUpdate={() => {}}
onElementDelete={() => {}}
onAddElement={() => {}}
/>
</BandProvider>
);
}
export default function ReportPreviewModal({ vehicleData, onClose }) {
const [elements, setElements] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
api.get('/api/reports/')
.then(res => {
const reports = res.data.results || res.data;
const report = reports.find(r => r.name === 'notice_report');
if (report) {
setElements(report.elements || []);
} else {
setError('Report "notice_report" not found. Create it in the Report Editor first.');
}
})
.catch(() => setError('Failed to load report'));
}, []);
return (
<div className="report-preview-overlay" onClick={onClose}>
<div className="report-preview-modal" onClick={e => e.stopPropagation()}>
<div className="report-preview-header">
<span>Notice Report {vehicleData.vehicle.vehicle_number}</span>
<button className="report-preview-close" onClick={onClose}>×</button>
</div>
<div className="report-preview-body">
{error ? (
<div className="report-preview-error">{error}</div>
) : elements === null ? (
<div className="report-preview-loading">Loading...</div>
) : (
<DataProvider>
<PreviewContent vehicleData={vehicleData} elements={elements} />
</DataProvider>
)}
</div>
</div>
</div>
);
}
+2 -1
View File
@@ -6,7 +6,8 @@ function useSerialData() {
const [error, setError] = useState(null);
useEffect(() => {
const eventSource = new EventSource('http://localhost:5000/events');
const serialUrl = process.env.REACT_APP_SERIAL_URL || 'http://localhost:5000';
const eventSource = new EventSource(`${serialUrl}/events`);
eventSource.onopen = () => {
setIsConnected(true);