Skip to content

Commit b9471e1

Browse files
fix(ehr): date validation messages, encounter sort, payment edit prefill
- Reports filters: show inline "Invalid date" message and skip applying the filter until a typed date parses (was red border only). - Clinical forms (immunization etc.) + shared masked date field: show an inline "Invalid date" hint as the user types/blurs, not only on save. - Patient Snapshot Encounter History: sort latest -> oldest by date. - Encounter status save: surface a warning when the patient-scoped status PUT fails instead of swallowing it silently. - Payment edit: refetch the full transaction by id; add a normalizeEditItem hook so the Payment History list-edit form maps collectedAt/paymentMethodType onto paymentDate/paymentMethod (Date + Method opened blank before). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6b90104 commit b9471e1

5 files changed

Lines changed: 137 additions & 19 deletions

File tree

src/vs/workbench/contrib/ciyexEhr/browser/ciyexDateMask.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ export function createUsDateField(
7979
hidden.type = 'hidden';
8080
hidden.value = isoValue || '';
8181

82+
// Inline validation hint shown directly under the field so a typed-but-invalid
83+
// date (e.g. 23/45/2000) surfaces a readable message as the user types, not
84+
// only when they hit Save.
85+
const err = doc.createElement('div');
86+
err.style.cssText = 'font-size:10px;color:#ef4444;margin-top:3px;display:none;';
87+
8288
const sync = () => {
8389
const masked = maskUsDate(visible.value);
8490
if (masked !== visible.value) { visible.value = masked; }
@@ -90,6 +96,8 @@ export function createUsDateField(
9096
// saving the empty ISO value the parser produced.
9197
hidden.dataset.invalid = bad ? '1' : '';
9298
visible.style.borderColor = bad ? '#ef4444' : '';
99+
err.textContent = bad ? 'Invalid date — please enter a real date (MM/DD/YYYY)' : '';
100+
err.style.display = bad ? 'block' : 'none';
93101
};
94102
visible.addEventListener('input', sync);
95103
visible.addEventListener('blur', sync);
@@ -103,6 +111,9 @@ export function createUsDateField(
103111
visible.value = isoToUsDate(picker.value);
104112
hidden.value = picker.value;
105113
hidden.dataset.invalid = '';
114+
visible.style.borderColor = '';
115+
err.textContent = '';
116+
err.style.display = 'none';
106117
});
107118

108119
const icon = doc.createElement('span');
@@ -113,6 +124,7 @@ export function createUsDateField(
113124
wrap.appendChild(hidden);
114125
wrap.appendChild(picker);
115126
wrap.appendChild(icon);
127+
wrap.appendChild(err);
116128
container.appendChild(wrap);
117129
return { hidden, visible, picker };
118130
}

src/vs/workbench/contrib/ciyexEhr/browser/editors/clinicalListEditor.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1707,14 +1707,28 @@ export abstract class ClinicalListEditorBase extends EditorPane {
17071707
visible.maxLength = 10;
17081708
const hidden = DOM.append(wrap, DOM.$('input')) as HTMLInputElement;
17091709
hidden.type = 'hidden';
1710+
// Inline validation hint shown directly under the field. usToIsoDate
1711+
// returns '' for an impossible calendar date (e.g. 23/45/2000), so we
1712+
// flag the field red AND surface a readable message immediately as the
1713+
// user types — not only when they hit Save (QA: date fields silently
1714+
// accept invalid dates with no error message).
1715+
const dateErr = DOM.append(wrap, DOM.$('div'));
1716+
dateErr.style.cssText = 'font-size:10px;color:#ef4444;margin-top:3px;display:none;';
1717+
const syncDateError = () => {
1718+
const iso = usToIso(visible.value);
1719+
const bad = !!visible.value && !iso;
1720+
visible.style.borderColor = bad ? '#ef4444' : '';
1721+
dateErr.textContent = bad ? 'Invalid date — please enter a real date (MM/DD/YYYY)' : '';
1722+
dateErr.style.display = bad ? 'block' : 'none';
1723+
};
17101724
visible.addEventListener('input', () => {
17111725
// Auto-insert slashes and cap the year at 4 digits as the user types.
17121726
const masked = maskUsDate(visible.value);
17131727
if (masked !== visible.value) { visible.value = masked; }
1714-
const iso = usToIso(visible.value);
1715-
hidden.value = iso;
1716-
visible.style.borderColor = visible.value && !iso ? '#ef4444' : '';
1728+
hidden.value = usToIso(visible.value);
1729+
syncDateError();
17171730
});
1731+
visible.addEventListener('blur', syncDateError);
17181732
// Native picker — fully overlaid on top of the icon area so clicking
17191733
// the icon opens the calendar. We hide the native chrome via opacity:0.
17201734
const picker = DOM.append(wrap, DOM.$('input')) as HTMLInputElement;
@@ -1724,6 +1738,9 @@ export abstract class ClinicalListEditorBase extends EditorPane {
17241738
picker.addEventListener('change', () => {
17251739
visible.value = isoToUs(picker.value);
17261740
hidden.value = picker.value;
1741+
visible.style.borderColor = '';
1742+
dateErr.textContent = '';
1743+
dateErr.style.display = 'none';
17271744
});
17281745
// Visible icon (decorative) — sits behind the transparent picker so
17291746
// clicks fall through to the picker. pointer-events:none keeps the

src/vs/workbench/contrib/ciyexEhr/browser/editors/patientSnapshotEditor.ts

Lines changed: 75 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -890,18 +890,41 @@ export class PatientSnapshotEditor extends EditorPane {
890890
// endpoint, since the encounter-form composition save below only carries
891891
// clinical content, not the Encounter's own fields. Mirrors encounterListPane.
892892
const reason = String(values['chiefComplaint'] || values['reason'] || '').trim();
893-
await this.apiService.fetch(`/api/${pid}/encounters/${encounterId}`, {
894-
method: 'PUT',
895-
headers: { 'Content-Type': 'application/json' },
896-
body: JSON.stringify({
897-
visitCategory: values['type'] || undefined,
898-
encounterDate: values['startDate'] || undefined,
899-
endDate: endDate || undefined,
900-
encounterProvider: String(values['provider'] || '').trim() || undefined,
901-
status: statusVal,
902-
reasonForVisit: reason || undefined,
903-
}),
904-
}).catch(() => { /* non-fatal: the composition save below still runs */ });
893+
// Persist the encounter-level status (Signed/Unsigned) here. This is the
894+
// SAME endpoint the Encounters side-menu list reads, so a successful PUT is
895+
// what makes a status change in the snapshot show up there too. Previously
896+
// the result was swallowed with `.catch(() => {})`, so a status PUT that the
897+
// server rejected (4xx/5xx resolves without throwing) failed silently — the
898+
// snapshot showed the new status from its local overlay while the encounter
899+
// list kept the old one (QA: status change not reflected in side-menu). Now
900+
// we check the response and warn the user when the status did not persist.
901+
try {
902+
const stRes = await this.apiService.fetch(`/api/${pid}/encounters/${encounterId}`, {
903+
method: 'PUT',
904+
headers: { 'Content-Type': 'application/json' },
905+
body: JSON.stringify({
906+
visitCategory: values['type'] || undefined,
907+
encounterDate: values['startDate'] || undefined,
908+
endDate: endDate || undefined,
909+
encounterProvider: String(values['provider'] || '').trim() || undefined,
910+
status: statusVal,
911+
reasonForVisit: reason || undefined,
912+
}),
913+
});
914+
if (!stRes.ok) {
915+
this.notificationService.notify({
916+
severity: Severity.Warning,
917+
message: `Encounter status could not be updated (HTTP ${stRes.status}); the clinical note was still saved.`,
918+
});
919+
}
920+
} catch {
921+
// Network failure only — the composition save below still runs so the
922+
// clinical content isn't lost.
923+
this.notificationService.notify({
924+
severity: Severity.Warning,
925+
message: 'Encounter status could not be updated (network error); the clinical note was still saved.',
926+
});
927+
}
905928
}
906929
const headers = { 'Content-Type': 'application/json' };
907930
// Convert the structured-field textareas (diagnoses/procedures/plan/ROS/PE)
@@ -1072,6 +1095,26 @@ export class PatientSnapshotEditor extends EditorPane {
10721095
* Payment form opened with Payment Date and Method blank (Payment Date is
10731096
* required, so the form could not even be saved).
10741097
*/
1098+
/**
1099+
* Re-fetch the full payment transaction by id before opening the Edit form.
1100+
* The Payment History list rows can be a trimmed projection (so the Edit form
1101+
* opened with Date of Service / Reference Type / Receipt Email / allocation
1102+
* fields blank even though the stored transaction carried them — QA: "edit
1103+
* doesn't fetch all the data"). GET /api/payments/transactions/{id} returns the
1104+
* canonical record; merge it over the list row so every field pre-fills. Falls
1105+
* back to the list row if the id is non-numeric or the fetch fails.
1106+
*/
1107+
private async _loadFullPayment(item: Record<string, unknown>): Promise<Record<string, unknown>> {
1108+
const id = String(item.id ?? item.transactionId ?? '').trim();
1109+
if (!/^\d+$/.test(id)) { return item; }
1110+
try {
1111+
const res = await this._fetch(`/api/payments/transactions/${id}`);
1112+
const full = (res && (res.data ?? res)) as Record<string, unknown> | null;
1113+
if (full && typeof full === 'object') { return { ...item, ...full }; }
1114+
} catch { /* fall through with the list row */ }
1115+
return item;
1116+
}
1117+
10751118
private _normalizePaymentForEdit(item: Record<string, unknown>): Record<string, unknown> {
10761119
const dateOnly = (v: unknown): string => { const s = String(v ?? '').trim(); return s ? s.slice(0, 10) : ''; };
10771120
const out: Record<string, unknown> = { ...item };
@@ -1170,6 +1213,15 @@ export class PatientSnapshotEditor extends EditorPane {
11701213
// list view so users can keep managing the collection.
11711214
closeOnSave: initialMode === 'create',
11721215
loadList: () => this._loadEntityList(entity),
1216+
// Payment History opens this dialog in LIST mode; clicking a row's edit
1217+
// pencil seeds the form from the raw transaction row, whose field names
1218+
// (collectedAt / paymentMethodType / …) differ from the form keys
1219+
// (paymentDate / paymentMethod / …). Map them so the edit form pre-fills
1220+
// Payment Date, Method and the allocation breakdown instead of opening
1221+
// blank (QA: payment edit "doesn't fetch all the data").
1222+
normalizeEditItem: entity === 'payment'
1223+
? (row: Record<string, unknown>) => this._normalizePaymentForEdit(row)
1224+
: undefined,
11731225
saveRecord: async (next, existingId) => {
11741226
// Encounters need a two-step save (mirrors EncounterFormEditor):
11751227
// 1. POST /api/{patientId}/encounters to mint a real Encounter id
@@ -1290,7 +1342,7 @@ export class PatientSnapshotEditor extends EditorPane {
12901342
// form (QA issue 2: encounter edit opened with start/end date, provider
12911343
// and vitals all blank).
12921344
const initialItem = entity === 'encounters' ? await this._loadEncounterForEdit(item)
1293-
: entity === 'payment' ? this._normalizePaymentForEdit(item)
1345+
: entity === 'payment' ? this._normalizePaymentForEdit(await this._loadFullPayment(item))
12941346
: item;
12951347
openListAndFormDialog({
12961348
title: reg.title,
@@ -3221,7 +3273,16 @@ export class PatientSnapshotEditor extends EditorPane {
32213273
this._revealVitalsEntry?.();
32223274
}
32233275

3224-
private _renderEncounterClinicalRows(card: HTMLElement, encs: Record<string, unknown>[]): void {
3276+
private _renderEncounterClinicalRows(card: HTMLElement, encsInput: Record<string, unknown>[]): void {
3277+
// Show the most recent encounter first, then older ones (QA: Encounter
3278+
// History should list latest → oldest). Read the date from any of the keys
3279+
// an encounter row can carry; rows with no parseable date sort to the bottom.
3280+
const encDateMs = (e: Record<string, unknown>): number => {
3281+
const raw = e.encounterDate || e.startDate || e.start || e.date || e.periodStart || e.createdAt || '';
3282+
const t = raw ? new Date(String(raw)).getTime() : NaN;
3283+
return isNaN(t) ? -Infinity : t;
3284+
};
3285+
const encs = [...encsInput].sort((a, b) => encDateMs(b) - encDateMs(a));
32253286
if (encs.length === 0) {
32263287
const empty = DOM.append(card, DOM.$('div'));
32273288
empty.textContent = 'No encounters found';

src/vs/workbench/contrib/ciyexEhr/browser/editors/reportsEditor.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1270,9 +1270,21 @@ export class ReportsEditor extends EditorPane {
12701270
visible.maxLength = 10;
12711271
visible.value = isoToUs(this.filterValues[key] || '');
12721272
visible.style.cssText = INPUT_STYLE + 'padding-right:30px;width:130px;';
1273+
// Inline validation message — usToIsoDate returns '' for an impossible
1274+
// calendar date (e.g. 12/45/2000), so we flag the field red AND surface a
1275+
// readable "Invalid date" hint below it instead of silently swallowing the
1276+
// value (QA: report filters accept invalid dates with no error).
1277+
const errEl = DOM.append(wrap, DOM.$('div'));
1278+
errEl.style.cssText = 'position:absolute;top:100%;left:0;margin-top:2px;font-size:10px;color:#ef4444;white-space:nowrap;display:none;z-index:5;';
12731279
visible.addEventListener('input', () => {
12741280
const iso = usToIso(visible.value);
1275-
visible.style.borderColor = visible.value && !iso ? '#ef4444' : '';
1281+
const bad = !!visible.value && !iso;
1282+
visible.style.borderColor = bad ? '#ef4444' : '';
1283+
errEl.textContent = bad ? 'Invalid date — use MM/DD/YYYY' : '';
1284+
errEl.style.display = bad ? 'block' : 'none';
1285+
// Don't reset the page / re-render the table on a half-typed or invalid
1286+
// date — only apply the filter once it parses (or is cleared).
1287+
if (bad) { return; }
12761288
this.filterValues[key] = iso;
12771289
this.currentPage = 0;
12781290
this._render();
@@ -1283,6 +1295,9 @@ export class ReportsEditor extends EditorPane {
12831295
picker.style.cssText = 'position:absolute;top:0;right:0;width:30px;height:100%;opacity:0;cursor:pointer;border:none;background:transparent;color-scheme:dark light;padding:0;margin:0;';
12841296
picker.addEventListener('change', () => {
12851297
visible.value = isoToUs(picker.value);
1298+
visible.style.borderColor = '';
1299+
errEl.textContent = '';
1300+
errEl.style.display = 'none';
12861301
this.filterValues[key] = picker.value;
12871302
this.currentPage = 0;
12881303
this._render();

src/vs/workbench/contrib/ciyexEhr/browser/sidebarActions.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2067,6 +2067,14 @@ export interface IListAndFormDialogOptions {
20672067
* view. Used for focused single-record edits (e.g. the snapshot's edit-pencil)
20682068
* where bouncing back to the full list after saving is unwanted. */
20692069
closeOnSave?: boolean;
2070+
/** Optional transform applied to a stored row before it seeds the EDIT form,
2071+
* so a record whose backend field names differ from the form keys pre-fills
2072+
* correctly. Example: a payment row stores `collectedAt`/`paymentMethodType`
2073+
* but the form fields are keyed `paymentDate`/`paymentMethod`, so without this
2074+
* the list-edit form opened with Payment Date and Method blank (QA: "edit
2075+
* doesn't fetch all the data"). Only runs in edit mode; the row id is taken
2076+
* from the original row for save, so the mapping can't break the PUT target. */
2077+
normalizeEditItem?: (row: Record<string, unknown>) => Record<string, unknown>;
20702078
}
20712079

20722080
/** Field-group sections for the unified list/form popup. Clinical forms (the
@@ -2711,6 +2719,11 @@ export function openListAndFormDialog(opts: IListAndFormDialogOptions): void {
27112719
currentMode = 'form';
27122720
editingItem = existing;
27132721
const isEdit = !!existing;
2722+
// Seed the form from a normalized view of the row when editing, so stored
2723+
// field names that differ from the form keys (e.g. payment `collectedAt` →
2724+
// `paymentDate`) still pre-fill. `editingItem` keeps the original row so the
2725+
// save id is unchanged; only the values used to populate inputs are mapped.
2726+
const seed = isEdit && opts.normalizeEditItem ? opts.normalizeEditItem(existing!) : existing;
27142727
titleEl.textContent = `${isEdit ? 'Edit' : 'New'} ${singular}`;
27152728
subtitleEl.textContent = isEdit ? 'Update the details below' : `Add a new ${singular.toLowerCase()} record`;
27162729
addBtn.style.display = 'none';
@@ -2797,7 +2810,7 @@ export function openListAndFormDialog(opts: IListAndFormDialogOptions): void {
27972810
// configured default — including dynamic defaults like an auto-generated
27982811
// lab order number. Without this the "Order Number" input rendered blank
27992812
// even though the field defines an auto-generated default.
2800-
const provided = existing?.[field.key];
2813+
const provided = seed?.[field.key];
28012814
const initial = (provided !== undefined && provided !== null && provided !== '')
28022815
? String(provided as string | number)
28032816
: (isEdit ? '' : (resolveFieldDefault(field) ?? ''));

0 commit comments

Comments
 (0)