feat: add mobile order readiness checks

This commit is contained in:
alexpolo1
2026-07-07 21:55:54 +02:00
parent 46c0025e49
commit c5fd46eba0
3 changed files with 225 additions and 3 deletions

View File

@@ -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;

View File

@@ -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 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>

View 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);
});
});