feat: add mobile order readiness checks
This commit is contained in:
@@ -244,6 +244,79 @@
|
||||
margin-bottom: 13px;
|
||||
}
|
||||
|
||||
.mobile-panel-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mobile-panel-heading h3 {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.mobile-panel-heading span {
|
||||
color: #64748b;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mobile-panel-heading > strong {
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.mobile-panel-heading > strong.ready {
|
||||
background: #ecfdf5;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.mobile-panel-heading > strong.missing {
|
||||
background: #fff7ed;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.mobile-progress {
|
||||
background: #e2e8f0;
|
||||
border-radius: 999px;
|
||||
height: 8px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobile-progress span {
|
||||
background: #2563eb;
|
||||
display: block;
|
||||
height: 100%;
|
||||
transition: width 160ms ease;
|
||||
}
|
||||
|
||||
.mobile-readiness-box {
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fed7aa;
|
||||
border-radius: 7px;
|
||||
color: #7c2d12;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin-bottom: 13px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.mobile-readiness-box.warning {
|
||||
background: #f8fafc;
|
||||
border-color: #cbd5e1;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.mobile-readiness-box ul {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.mobile-question-list,
|
||||
.mobile-history-list {
|
||||
display: grid;
|
||||
|
||||
@@ -16,10 +16,56 @@ const formatNumber = (value, digits = 0) => {
|
||||
};
|
||||
|
||||
const buildInitialAnswers = (questions = []) => questions.reduce((acc, question) => {
|
||||
acc[question.key] = question.inputType === 'boolean' ? false : '';
|
||||
acc[question.key] = question.inputType === 'boolean' && question.required ? null : '';
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const isPositiveNumber = (value) => {
|
||||
const number = Number(String(value ?? '').replace(',', '.'));
|
||||
return Number.isFinite(number) && number > 0;
|
||||
};
|
||||
|
||||
const isQuestionAnswered = (question, value) => {
|
||||
if (!question.required) return true;
|
||||
if (question.inputType === 'boolean') return value === true || value === false;
|
||||
if (question.inputType === 'number') return isPositiveNumber(value);
|
||||
return String(value ?? '').trim().length > 0;
|
||||
};
|
||||
|
||||
export const buildMobileReadiness = ({ questions = [], answers = {}, measuredData = {}, attachments = [] }) => {
|
||||
const requiredQuestions = questions.filter((question) => question.required);
|
||||
const missingRequired = requiredQuestions
|
||||
.filter((question) => !isQuestionAnswered(question, answers[question.key]))
|
||||
.map((question) => question.label);
|
||||
const missing = [...missingRequired];
|
||||
const suggestions = [];
|
||||
|
||||
if (!isPositiveNumber(measuredData.area)) {
|
||||
missing.push('Areal');
|
||||
}
|
||||
|
||||
if (!String(measuredData.accessNotes || '').trim()) {
|
||||
suggestions.push('Adgangsnoter');
|
||||
}
|
||||
|
||||
if (!attachments.length) {
|
||||
suggestions.push('Billeder eller dokumentation');
|
||||
}
|
||||
|
||||
const completedRequired = requiredQuestions.length - missingRequired.length;
|
||||
const totalRequired = requiredQuestions.length + 1;
|
||||
const completed = completedRequired + (isPositiveNumber(measuredData.area) ? 1 : 0);
|
||||
|
||||
return {
|
||||
ready: missing.length === 0,
|
||||
missing,
|
||||
suggestions,
|
||||
completed,
|
||||
total: totalRequired,
|
||||
percent: totalRequired > 0 ? Math.round((completed / totalRequired) * 100) : 100
|
||||
};
|
||||
};
|
||||
|
||||
const MobileOrders = ({ apiBaseUrl = '' }) => {
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [selectedCaseNumber, setSelectedCaseNumber] = useState(null);
|
||||
@@ -44,6 +90,13 @@ const MobileOrders = ({ apiBaseUrl = '' }) => {
|
||||
[orders, selectedCaseNumber, detail]
|
||||
);
|
||||
|
||||
const readiness = useMemo(() => buildMobileReadiness({
|
||||
questions: detail?.checklist?.questions || [],
|
||||
answers,
|
||||
measuredData,
|
||||
attachments: detail?.attachments || []
|
||||
}), [answers, detail, measuredData]);
|
||||
|
||||
const apiPath = (path) => `${apiBaseUrl}${path}`;
|
||||
|
||||
const loadOrders = async () => {
|
||||
@@ -194,6 +247,11 @@ const MobileOrders = ({ apiBaseUrl = '' }) => {
|
||||
};
|
||||
|
||||
const createDraft = async () => {
|
||||
if (!readiness.ready) {
|
||||
setError(`Mangler før desktop-review: ${readiness.missing.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let intake = latestIntakeId ? { id: latestIntakeId } : null;
|
||||
if (!intake) {
|
||||
intake = await saveIntake('ready_for_review');
|
||||
@@ -227,6 +285,27 @@ const MobileOrders = ({ apiBaseUrl = '' }) => {
|
||||
const renderQuestion = (question) => {
|
||||
const value = answers[question.key];
|
||||
if (question.inputType === 'boolean') {
|
||||
if (question.required) {
|
||||
return (
|
||||
<label className="mobile-field" key={question.key}>
|
||||
<span>{question.label}</span>
|
||||
<select
|
||||
value={value === true ? 'yes' : value === false ? 'no' : ''}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value === ''
|
||||
? null
|
||||
: event.target.value === 'yes';
|
||||
updateAnswer(question, nextValue);
|
||||
}}
|
||||
>
|
||||
<option value="">Vælg</option>
|
||||
<option value="yes">Ja</option>
|
||||
<option value="no">Nej</option>
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="mobile-toggle" key={question.key}>
|
||||
<input
|
||||
@@ -352,7 +431,34 @@ const MobileOrders = ({ apiBaseUrl = '' }) => {
|
||||
|
||||
<div className="mobile-work-grid">
|
||||
<div className="mobile-panel">
|
||||
<h3>Feltcheckliste</h3>
|
||||
<div className="mobile-panel-heading">
|
||||
<div>
|
||||
<h3>Feltcheckliste</h3>
|
||||
<span>{readiness.completed}/{readiness.total} klar</span>
|
||||
</div>
|
||||
<strong className={readiness.ready ? 'ready' : 'missing'}>
|
||||
{readiness.ready ? 'Klar' : `${readiness.percent}%`}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="mobile-progress" aria-label={`Feltcheckliste ${readiness.percent}% udfyldt`}>
|
||||
<span style={{ width: `${readiness.percent}%` }} />
|
||||
</div>
|
||||
{!readiness.ready && (
|
||||
<div className="mobile-readiness-box">
|
||||
<strong>Mangler før desktop-review</strong>
|
||||
<ul>
|
||||
{readiness.missing.map((item) => <li key={item}>{item}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{readiness.ready && readiness.suggestions.length > 0 && (
|
||||
<div className="mobile-readiness-box warning">
|
||||
<strong>Godt at få med</strong>
|
||||
<ul>
|
||||
{readiness.suggestions.map((item) => <li key={item}>{item}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="mobile-question-list">
|
||||
{(detail.checklist?.questions || []).map(renderQuestion)}
|
||||
</div>
|
||||
@@ -395,7 +501,15 @@ const MobileOrders = ({ apiBaseUrl = '' }) => {
|
||||
|
||||
<div className="mobile-actions">
|
||||
<button type="button" onClick={() => saveIntake('draft')} disabled={saving}>Gem</button>
|
||||
<button type="button" className="primary" onClick={createDraft} disabled={saving}>Lav tilbudskladde</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
onClick={createDraft}
|
||||
disabled={saving || !readiness.ready}
|
||||
title={!readiness.ready ? `Mangler: ${readiness.missing.join(', ')}` : undefined}
|
||||
>
|
||||
Lav tilbudskladde
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
35
frontend/src/components/MobileOrders.test.js
Normal file
35
frontend/src/components/MobileOrders.test.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { buildMobileReadiness } from './MobileOrders';
|
||||
|
||||
describe('MobileOrders readiness', () => {
|
||||
const questions = [
|
||||
{ key: 'slope', label: 'Fald kontrolleret', inputType: 'boolean', required: true },
|
||||
{ key: 'drains', label: 'Afløb optalt', inputType: 'number', required: true },
|
||||
{ key: 'notes', label: 'Ekstra note', inputType: 'select', required: false }
|
||||
];
|
||||
|
||||
test('blocks desktop review until required checklist items and area are complete', () => {
|
||||
const readiness = buildMobileReadiness({
|
||||
questions,
|
||||
answers: { drains: '' },
|
||||
measuredData: { area: '' },
|
||||
attachments: []
|
||||
});
|
||||
|
||||
expect(readiness.ready).toBe(false);
|
||||
expect(readiness.missing).toEqual(['Fald kontrolleret', 'Afløb optalt', 'Areal']);
|
||||
expect(readiness.percent).toBe(0);
|
||||
});
|
||||
|
||||
test('allows review when required checks and area are complete', () => {
|
||||
const readiness = buildMobileReadiness({
|
||||
questions,
|
||||
answers: { slope: false, drains: '2' },
|
||||
measuredData: { area: '124,5', accessNotes: 'Stillads fra indkørsel' },
|
||||
attachments: [{ id: 1 }]
|
||||
});
|
||||
|
||||
expect(readiness.ready).toBe(true);
|
||||
expect(readiness.missing).toEqual([]);
|
||||
expect(readiness.percent).toBe(100);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user