Add clinical device registry, browser wedge scan, and local agent API.
Deploy Ladill Care / deploy (push) Successful in 1m39s

Branch-scoped Care devices with hashed agent tokens, patient barcode lookup/labels, and provenance-aware vitals from a minimal device agent (Pro for agent hardware; wedge free).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-17 14:28:46 +00:00
co-authored by Cursor
parent 1da90203e8
commit e0a7a64d38
37 changed files with 1970 additions and 10 deletions
@@ -417,6 +417,7 @@
<div><dt class="text-slate-500">Pulse</dt><dd class="font-medium">{{ $vitals->pulse ?? '—' }}</dd></div>
<div><dt class="text-slate-500">Temp</dt><dd class="font-medium">{{ $vitals->temperature ?? '—' }}°C</dd></div>
<div><dt class="text-slate-500">SpO₂</dt><dd class="font-medium">{{ $vitals->spo2 ? $vitals->spo2.'%' : '—' }}</dd></div>
<div><dt class="text-slate-500">Source</dt><dd class="font-medium">{{ config('care.vital_sources.'.$vitals->source, $vitals->source ?? 'manual') }}@if ($vitals->device_id) · device #{{ $vitals->device_id }}@endif</dd></div>
</dl>
@endforeach
</section>
@@ -0,0 +1,82 @@
<x-app-layout title="Add device">
<div class="mx-auto max-w-lg space-y-4">
<div>
<a href="{{ route('care.devices.index') }}" class="text-sm text-slate-500 hover:text-slate-800"> Devices</a>
<h1 class="mt-2 text-xl font-semibold text-slate-900">Register device</h1>
<p class="mt-1 text-sm text-slate-500">Inventory is branch-scoped. Agent devices get a token shown once after create.</p>
</div>
<form method="POST" action="{{ route('care.devices.store') }}" class="space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
@csrf
<div>
<label class="block text-sm font-medium text-slate-700">Name</label>
<input type="text" name="name" value="{{ old('name') }}" required placeholder="Nurse station scanner"
class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Type</label>
<select name="type" id="device-type" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($deviceTypes as $value => $label)
@php
$needsPro = in_array($value, $agentTypes, true);
@endphp
<option value="{{ $value }}"
data-mode="{{ $defaultModes[$value] ?? 'manual' }}"
data-pro="{{ $needsPro ? '1' : '0' }}"
@selected(old('type', 'barcode_scanner') === $value)
@disabled($needsPro && ! $hasAgentDevices)>
{{ $label }}@if ($needsPro && ! $hasAgentDevices) (Pro)@endif
</option>
@endforeach
</select>
@if (! $hasAgentDevices)
<p class="mt-1 text-xs text-amber-700">Clinical agent devices need Care Pro. USB barcode/QR scanners work on Free.</p>
@endif
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Connection mode</label>
<select name="connection_mode" id="connection-mode" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($connectionModes as $value => $label)
<option value="{{ $value }}" @selected(old('connection_mode', 'browser_wedge') === $value)>{{ $label }}</option>
@endforeach
</select>
<p class="mt-1 text-xs text-slate-500" id="connection-hint">USB scanners act as a keyboard in the browser. Serial/BLE needs the local agent.</p>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Branch</label>
<select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value="">All branches</option>
@foreach ($branches as $branch)
<option value="{{ $branch->id }}" @selected(old('branch_id') == $branch->id)>{{ $branch->name }}</option>
@endforeach
</select>
</div>
<button type="submit" class="btn-primary w-full">Register device</button>
</form>
</div>
<script>
(() => {
const typeEl = document.getElementById('device-type');
const modeEl = document.getElementById('connection-mode');
const hintEl = document.getElementById('connection-hint');
const hints = {
browser_wedge: 'Works in the browser today as a USB keyboard wedge. No local agent required.',
agent: 'Needs the Care Device Agent on a local machine (serial / BLE / vendor SDK).',
web_bluetooth: 'Experimental Web Bluetooth — prefer the local agent for clinical devices.',
manual: 'Manual / inventory only — no automated capture path.',
};
function sync() {
const opt = typeEl.selectedOptions[0];
if (!opt) return;
const mode = opt.dataset.mode || 'manual';
modeEl.value = mode;
hintEl.textContent = hints[mode] || hints.manual;
}
typeEl.addEventListener('change', sync);
modeEl.addEventListener('change', () => {
hintEl.textContent = hints[modeEl.value] || hints.manual;
});
sync();
})();
</script>
</x-app-layout>
@@ -0,0 +1,89 @@
<x-app-layout title="Edit device">
<div class="mx-auto max-w-lg space-y-4">
<div>
<a href="{{ route('care.devices.index') }}" class="text-sm text-slate-500 hover:text-slate-800"> Devices</a>
<h1 class="mt-2 text-xl font-semibold text-slate-900">{{ $device->name }}</h1>
<p class="mt-1 text-sm text-slate-500">{{ $connectionHint }}</p>
</div>
@if ($plainToken)
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
<p class="font-medium">Device token (copy now shown once)</p>
<code class="mt-2 block break-all rounded-lg bg-white px-3 py-2 font-mono text-xs text-slate-800">{{ $plainToken }}</code>
<p class="mt-2 text-xs text-amber-800">Send as <code class="font-mono">X-Care-Device-Token</code> or <code class="font-mono">Authorization: Bearer </code> from the Care Device Agent.</p>
</div>
@elseif ($device->hasToken())
<div class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
<p>A device token is active (hash stored). Regenerate to rotate; the previous token stops working immediately.</p>
</div>
@endif
<form method="POST" action="{{ route('care.devices.update', $device) }}" class="space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
@csrf @method('PUT')
<div>
<label class="block text-sm font-medium text-slate-700">Name</label>
<input type="text" name="name" value="{{ old('name', $device->name) }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Type</label>
<select name="type" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($deviceTypes as $value => $label)
@php $needsPro = in_array($value, $agentTypes, true); @endphp
<option value="{{ $value }}" @selected(old('type', $device->type) === $value)
@disabled($needsPro && ! $hasAgentDevices && $device->type !== $value)>
{{ $label }}
</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Connection mode</label>
<select name="connection_mode" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($connectionModes as $value => $label)
<option value="{{ $value }}" @selected(old('connection_mode', $device->connection_mode) === $value)>{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Status</label>
<select name="status" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($deviceStatuses as $value => $label)
<option value="{{ $value }}" @selected(old('status', $device->status) === $value)>{{ $label }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Branch</label>
<select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value="">All branches</option>
@foreach ($branches as $branch)
<option value="{{ $branch->id }}" @selected(old('branch_id', $device->branch_id) == $branch->id)>{{ $branch->name }}</option>
@endforeach
</select>
</div>
<div class="flex gap-2">
<button type="submit" class="btn-primary">Save</button>
<a href="{{ route('care.devices.index') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm text-slate-600">Cancel</a>
</div>
</form>
<div class="flex flex-wrap gap-2">
<form method="POST" action="{{ route('care.devices.token.regenerate', $device) }}">
@csrf
<button type="submit" class="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">
{{ $device->hasToken() ? 'Regenerate token' : 'Issue agent token' }}
</button>
</form>
@if ($device->hasToken())
<form method="POST" action="{{ route('care.devices.token.revoke', $device) }}">
@csrf @method('DELETE')
<button type="submit" class="rounded-lg border border-rose-200 bg-white px-4 py-2 text-sm text-rose-700 hover:bg-rose-50">Revoke token</button>
</form>
@endif
<form method="POST" action="{{ route('care.devices.destroy', $device) }}" onsubmit="return confirm('Remove this device?')">
@csrf @method('DELETE')
<button type="submit" class="rounded-lg border border-rose-200 bg-white px-4 py-2 text-sm text-rose-700 hover:bg-rose-50">Delete device</button>
</form>
</div>
</div>
</x-app-layout>
@@ -0,0 +1,71 @@
<x-app-layout title="Devices">
<div class="space-y-6">
<x-care.page-hero
badge="Scanners · Vitals · Lab agents"
title="Devices"
description="Branch inventory for barcode scanners (browser wedge) and agent-connected clinical devices. USB scanners work in the browser; serial/BLE hardware needs the Care Device Agent."
:stats="[
['value' => number_format($heroStats['total']), 'label' => 'Devices'],
['value' => number_format($heroStats['online']), 'label' => 'Online'],
['value' => number_format($heroStats['agent']), 'label' => 'Agent-linked'],
]">
@if ($canManage)
<x-slot name="actions">
<a href="{{ route('care.devices.create') }}" class="btn-primary">Add device</a>
</x-slot>
@endif
</x-care.page-hero>
<div class="rounded-2xl border border-sky-100 bg-sky-50 px-4 py-3 text-sm text-sky-900">
<p class="font-medium">How devices connect</p>
<ul class="mt-1 list-disc space-y-1 pl-5 text-sky-800/90">
<li><strong>Barcode / QR scanners</strong> USB keyboard wedge works in Patients and Lab today. Free on all plans.</li>
<li><strong>Thermometers, BP, SpO₂, scales, analyzers</strong> need a local Care Device Agent (Pro / Enterprise). See docs/devices.md.</li>
</ul>
</div>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
<tr>
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">Type</th>
<th class="px-4 py-3">Connection</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Branch</th>
<th class="px-4 py-3">Last seen</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
@forelse ($devices as $device)
<tr>
<td class="px-4 py-3 font-medium text-slate-900">{{ $device->name }}</td>
<td class="px-4 py-3 text-slate-600">{{ $deviceTypes[$device->type] ?? $device->type }}</td>
<td class="px-4 py-3 text-slate-600">{{ $connectionModes[$device->connection_mode] ?? $device->connection_mode }}</td>
<td class="px-4 py-3">
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {{ $device->isOnline() ? 'bg-emerald-100 text-emerald-800' : 'bg-slate-100 text-slate-600' }}">
{{ $device->isOnline() ? 'Online' : ucfirst($device->status) }}
</span>
</td>
<td class="px-4 py-3 text-slate-600">{{ $device->branch?->name ?? 'All branches' }}</td>
<td class="px-4 py-3 text-slate-500">{{ $device->last_seen_at?->diffForHumans() ?? 'Never' }}</td>
<td class="px-4 py-3 text-right">
@if ($canManage)
<a href="{{ route('care.devices.edit', $device) }}" class="text-sky-600 hover:text-sky-700">Edit</a>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-4 py-8 text-center text-slate-500">No devices registered yet.</td>
</tr>
@endforelse
</tbody>
</table>
@if ($devices->hasPages())
<div class="border-t border-slate-100 px-5 py-3">{{ $devices->links() }}</div>
@endif
</div>
</div>
</x-app-layout>
@@ -7,7 +7,17 @@
</div>
<div class="flex flex-wrap gap-2">
@if ($canManage && $investigation->status === \App\Models\InvestigationRequest::STATUS_PENDING)
<form method="POST" action="{{ route('care.lab.requests.collect-sample', $investigation) }}">@csrf<button class="btn-primary">Collect sample</button></form>
<form method="POST" action="{{ route('care.lab.requests.collect-sample', $investigation) }}" class="flex flex-wrap items-end gap-2">
@csrf
<div>
<label for="sample_barcode" class="block text-xs font-medium text-slate-600">Sample barcode</label>
<input type="text" id="sample_barcode" name="sample_barcode" autocomplete="off"
placeholder="Scan or type barcode…"
class="mt-1 w-48 rounded-lg border-slate-300 font-mono text-sm"
autofocus>
</div>
<button class="btn-primary">Collect sample</button>
</form>
@endif
@if ($canManage && $investigation->status === \App\Models\InvestigationRequest::STATUS_SAMPLE_COLLECTED)
<form method="POST" action="{{ route('care.lab.requests.start', $investigation) }}">@csrf<button class="btn-primary">Start processing</button></form>
@@ -45,6 +45,22 @@
@endif
</form>
<div class="rounded-2xl border border-slate-200 bg-white p-4"
x-data="patientScan({ lookupUrl: @js(route('care.patients.scan')) })"
@keydown.window="onGlobalKeydown($event)">
<label for="patient-barcode-scan" class="text-sm font-medium text-slate-700">Barcode / QR scan</label>
<p class="mt-0.5 text-xs text-slate-400">USB scanners work as a keyboard wedge. Scan a patient ID label or type the number and press Enter.</p>
<div class="mt-2 flex flex-wrap gap-2">
<input type="text" id="patient-barcode-scan" x-ref="scanInput" x-model="scanCode"
@keydown.enter.prevent="submitScan()"
placeholder="Scan patient ID…" autocomplete="off"
class="min-w-[220px] flex-1 rounded-lg border-slate-300 font-mono text-sm">
<button type="button" class="btn-primary" @click="submitScan()">Open patient</button>
</div>
<p x-show="scanError" x-text="scanError" class="mt-2 text-sm text-rose-600" x-cloak></p>
<p x-show="scanOk" x-text="scanOk" class="mt-2 text-sm text-emerald-600" x-cloak></p>
</div>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
@@ -85,4 +101,64 @@
<div>{{ $patients->links() }}</div>
</div>
<script>
function patientScan(config) {
return {
lookupUrl: config.lookupUrl,
scanCode: '',
scanError: '',
scanOk: '',
scanBuffer: '',
scanTimer: null,
submitScan() {
const code = (this.scanCode || '').trim();
this.scanError = '';
this.scanOk = '';
if (!code) return;
fetch(this.lookupUrl + '?' + new URLSearchParams({ code }), {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
}).then(async (res) => {
const data = await res.json().catch(() => ({}));
if (!res.ok) {
this.scanError = data.message || 'No patient found for that barcode.';
return;
}
this.scanOk = 'Opening ' + (data.patient?.name || 'patient') + '…';
window.location.href = data.patient.url;
}).catch(() => {
this.scanError = 'Lookup failed. Try again.';
});
},
onGlobalKeydown(e) {
if (this.shouldIgnoreScan(e)) return;
if (e.key === 'Enter') {
const code = (this.scanBuffer || '').trim();
this.scanBuffer = '';
if (code.length >= 3) {
e.preventDefault();
this.scanCode = code;
this.submitScan();
}
return;
}
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
this.scanBuffer += e.key;
clearTimeout(this.scanTimer);
this.scanTimer = setTimeout(() => { this.scanBuffer = ''; }, 120);
}
},
shouldIgnoreScan(e) {
const tag = (e.target?.tagName || '').toLowerCase();
if (tag === 'textarea') return true;
if (tag === 'input' && e.target?.id !== 'patient-barcode-scan') return true;
if (e.target?.isContentEditable) return true;
return false;
},
};
}
</script>
</x-app-layout>
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Patient label · {{ $patient->patient_number }}</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
color: #0f172a;
background: #f1f5f9;
}
.toolbar {
display: flex;
gap: 0.75rem;
padding: 1rem;
background: #fff;
border-bottom: 1px solid #e2e8f0;
}
.toolbar a, .toolbar button {
font-family: system-ui, sans-serif;
font-size: 0.875rem;
padding: 0.5rem 0.9rem;
border-radius: 0.5rem;
border: 1px solid #cbd5e1;
background: #fff;
cursor: pointer;
text-decoration: none;
color: #334155;
}
.toolbar button.primary {
background: #0ea5e9;
border-color: #0284c7;
color: #fff;
}
.sheet {
display: flex;
justify-content: center;
padding: 2rem 1rem;
}
.label {
width: 90mm;
min-height: 40mm;
background: #fff;
border: 1px solid #94a3b8;
padding: 4mm 5mm;
}
.org { font-size: 9pt; color: #64748b; margin: 0 0 2mm; }
.id { font-size: 16pt; font-weight: 700; letter-spacing: 0.02em; margin: 0; }
.name { font-size: 12pt; margin: 2mm 0 0; font-family: system-ui, sans-serif; }
.meta { font-size: 9pt; color: #475569; margin: 1.5mm 0 0; font-family: system-ui, sans-serif; }
@media print {
body { background: #fff; }
.toolbar { display: none !important; }
.sheet { padding: 0; }
.label { border: none; }
}
</style>
</head>
<body>
<div class="toolbar">
<button type="button" class="primary" onclick="window.print()">Print label</button>
<a href="{{ route('care.patients.show', $patient) }}">Back to patient</a>
</div>
<div class="sheet">
<div class="label">
<p class="org">{{ $organization->name }}</p>
<p class="id">{{ $patient->patient_number }}</p>
<p class="name">{{ $patient->fullName() }}</p>
<p class="meta">
{{ $genders[$patient->gender] ?? '' }}
@if ($patient->date_of_birth)
· {{ $patient->date_of_birth->format('d M Y') }}
@endif
@if ($patient->branch)
· {{ $patient->branch->name }}
@endif
</p>
</div>
</div>
</body>
</html>
@@ -14,6 +14,7 @@
</div>
@if ($canManage)
<div class="flex gap-2">
<a href="{{ route('care.patients.label', $patient) }}" target="_blank" class="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Print ID label</a>
<a href="{{ route('care.patients.edit', $patient) }}" class="btn-primary">Edit</a>
<x-confirm-dialog
:name="'archive-patient-'.$patient->id"
@@ -28,6 +29,10 @@
</x-slot:trigger>
</x-confirm-dialog>
</div>
@else
<div class="flex gap-2">
<a href="{{ route('care.patients.label', $patient) }}" target="_blank" class="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Print ID label</a>
</div>
@endif
</div>
+19 -2
View File
@@ -48,8 +48,8 @@
</div>
</x-settings.card>
@if ($canViewBranches || $canViewTeam)
<x-settings.card title="Branches & team" description="Locations and staff access for this facility. Multi-branch management requires Care Pro.">
@if ($canViewBranches || $canViewTeam || $canViewDevices)
<x-settings.card title="Branches & team" description="Locations, staff access, and clinical hardware for this facility. Multi-branch management requires Care Pro.">
<ul class="space-y-3">
@if ($canViewBranches)
<li>
@@ -84,6 +84,23 @@
</a>
</li>
@endif
@if ($canViewDevices)
<li>
<a href="{{ route('care.devices.index') }}"
class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700">
<span>
Devices
@if (! $hasClinicalDevicesFeature)
<span class="ml-2 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-700">Agent · Pro</span>
@endif
<span class="mt-0.5 block text-xs text-slate-500">
Barcode scanners (browser) · agent-connected vitals / lab hardware
</span>
</span>
<span class="text-slate-400">&rarr;</span>
</a>
</li>
@endif
</ul>
</x-settings.card>
@endif
+2 -1
View File
@@ -97,7 +97,8 @@
$settingsActive = (request()->routeIs('care.settings*') && ! request()->routeIs('care.pro.*'))
|| request()->routeIs('care.branches.*')
|| request()->routeIs('care.members.*')
|| request()->routeIs('care.integrations*');
|| request()->routeIs('care.integrations*')
|| request()->routeIs('care.devices.*');
@endphp
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">