Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/design-lab/VehicleDesignLab.tsx
T

859 lines
28 KiB
TypeScript

import {
IconBarChartVStroked,
IconBox,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconClock,
IconClose,
IconCopy,
IconFilter,
IconHelpCircle,
IconHome,
IconMore,
IconRefresh,
IconRoute,
IconSearch
} from '@douyinfe/semi-icons';
import {
type CSSProperties,
type FormEvent,
type KeyboardEvent,
type PointerEvent,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState
} from 'react';
type DirectoryView = 'all' | 'online' | 'offline' | 'multi';
type VehicleStatus = 'online' | 'offline';
type ProtocolKind = 'JT/T 808' | 'GB/T 32960' | '宇通 MQTT';
type VehicleRecord = {
plate: string;
vin: string;
brand: string;
terminal: string;
status: VehicleStatus;
updatedAt: string;
protocols: ProtocolKind[];
department: string;
owner: string;
customer: string;
};
const vehicles: VehicleRecord[] = [
{
plate: '粤AG18312',
vin: 'LB9A32A24R0LS1426',
brand: 'G7s',
terminal: '13307795425',
status: 'online',
updatedAt: '07-03 20:12',
protocols: ['JT/T 808', 'GB/T 32960'],
department: '华南运营部',
owner: '陈思远',
customer: '羚牛示范车队'
},
{
plate: '川AHTW01',
vin: 'LNXNEGRR7SR318212',
brand: 'Hyundai',
terminal: '暂无终端手机号',
status: 'online',
updatedAt: '07-03 20:12',
protocols: ['GB/T 32960'],
department: '西南交付部',
owner: '王凌',
customer: '川渝联合车队'
},
{
plate: '豫A88888',
vin: 'LMRKH9AC2R1004087',
brand: '宇通',
terminal: '暂无终端手机号',
status: 'online',
updatedAt: '07-03 20:11',
protocols: ['宇通 MQTT'],
department: '中原运营部',
owner: '刘真',
customer: '宇通测试车队'
},
{
plate: '粤AFF7936',
vin: 'LB9A32A24P0LS1230',
brand: '广安车联',
terminal: '13307795426',
status: 'offline',
updatedAt: '07-03 19:58',
protocols: ['JT/T 808'],
department: '华南运营部',
owner: '陈思远',
customer: '羚牛示范车队'
}
];
const navigation = [
{ key: 'monitor', label: '全局监控', icon: IconHome },
{ key: 'vehicles', label: '车辆查询', icon: IconSearch, active: true },
{ key: 'tracks', label: '轨迹回放', icon: IconRoute },
{ key: 'history', label: '历史数据', icon: IconClock },
{ key: 'statistics', label: '里程查询', icon: IconBarChartVStroked }
];
const governanceNavigation = [
{ key: 'alerts', label: '事件中心', icon: IconClock },
{ key: 'access', label: '接入管理', icon: IconBox },
{ key: 'users', label: '账号管理', icon: IconMore }
];
const viewOptions: Array<{ key: DirectoryView; label: string }> = [
{ key: 'all', label: '全部车辆' },
{ key: 'online', label: '当前在线' },
{ key: 'offline', label: '当前离线' },
{ key: 'multi', label: '多源车辆' }
];
function ProtocolTag({ protocol }: { protocol: ProtocolKind }) {
const tone = protocol === 'JT/T 808' ? 'cyan' : protocol === 'GB/T 32960' ? 'blue' : 'violet';
return <span className={`lab-protocol-tag is-${tone}`}>{protocol}</span>;
}
function StatusTag({ status }: { status: VehicleStatus }) {
return <span className={`lab-status-tag is-${status}`}>{status === 'online' ? '在线' : '离线'}</span>;
}
function Sidebar() {
const navigate = (key: string) => {
if (key !== 'vehicles') window.location.href = `/platform-design-lab.html#${key}`;
};
return (
<aside className="lab-sidebar" aria-label="车辆数据中台导航">
<div className="lab-brand">
<img src="/brand-logo.svg" alt="羚牛智能" />
</div>
<nav>
<p>车辆工作台</p>
{navigation.map((item) => {
const Icon = item.icon;
return (
<button className={item.active ? 'is-active' : ''} type="button" key={item.label} onClick={() => navigate(item.key)}>
<Icon aria-hidden="true" />
<span>{item.label}</span>
</button>
);
})}
<p>平台治理</p>
{governanceNavigation.map((item) => {
const Icon = item.icon;
return (
<button type="button" key={item.label} onClick={() => navigate(item.key)}>
<Icon aria-hidden="true" />
<span>{item.label}</span>
</button>
);
})}
</nav>
<button className="lab-collapse" type="button">
<IconChevronLeft aria-hidden="true" />
<span>收起</span>
</button>
</aside>
);
}
function TopBar({ onHelp }: { onHelp: () => void }) {
return (
<header className="lab-topbar">
<div className="lab-topbar-title">
<img src="/brand-mark.svg" alt="" aria-hidden="true" />
<span>
<strong>车辆查询</strong>
<small>按车牌或 VIN 查询车辆档案和实时遥测。</small>
</span>
</div>
<div className="lab-topbar-actions">
<button type="button" className="lab-help-button" onClick={onHelp}>
<IconHelpCircle aria-hidden="true" />
<span>页面帮助</span>
</button>
<button type="button" className="lab-account-button" aria-label="账号菜单">
<span className="lab-avatar">I</span>
<b>local-developer</b>
<em>管理员</em>
<IconChevronDown aria-hidden="true" />
</button>
</div>
</header>
);
}
type CommandSurfaceProps = {
keyword: string;
view: DirectoryView;
department: string;
owner: string;
customer: string;
status: string;
onKeywordChange: (value: string) => void;
onViewChange: (view: DirectoryView) => void;
onDepartmentChange: (value: string) => void;
onOwnerChange: (value: string) => void;
onCustomerChange: (value: string) => void;
onStatusChange: (value: string) => void;
onSubmit: (event: FormEvent) => void;
};
function CommandSurface({
keyword,
view,
department,
owner,
customer,
status,
onKeywordChange,
onViewChange,
onDepartmentChange,
onOwnerChange,
onCustomerChange,
onStatusChange,
onSubmit
}: CommandSurfaceProps) {
return (
<section className="lab-command-surface" aria-label="车辆查询命令栏">
<div className="lab-command-primary">
<form className="lab-search-form" onSubmit={onSubmit}>
<IconSearch aria-hidden="true" />
<input
type="search"
value={keyword}
onChange={(event) => onKeywordChange(event.target.value)}
placeholder="输入车牌 / VIN / 终端手机号"
aria-label="输入车牌、VIN 或终端手机号"
/>
<button type="submit">
<span>查询车辆</span>
<IconChevronRight aria-hidden="true" />
</button>
</form>
<div className="lab-view-tabs" role="tablist" aria-label="车辆目录视图">
{viewOptions.map((option) => (
<button
key={option.key}
type="button"
role="tab"
aria-selected={view === option.key}
className={view === option.key ? 'is-active' : ''}
onClick={() => onViewChange(option.key)}
>
{option.label}
</button>
))}
</div>
<button className="lab-batch-button" type="button">
<IconBox aria-hidden="true" />
<span>批量同步主档</span>
</button>
</div>
<div className="lab-filter-row">
<label>
<span>部门</span>
<select value={department} onChange={(event) => onDepartmentChange(event.target.value)}>
<option value="">全部部门</option>
<option>华南运营部</option>
<option>西南交付部</option>
<option>中原运营部</option>
</select>
</label>
<label>
<span>业务负责人</span>
<select value={owner} onChange={(event) => onOwnerChange(event.target.value)}>
<option value="">全部负责人</option>
<option>陈思远</option>
<option>王凌</option>
<option>刘真</option>
</select>
</label>
<label>
<span>客户</span>
<select value={customer} onChange={(event) => onCustomerChange(event.target.value)}>
<option value="">全部客户</option>
<option>羚牛示范车队</option>
<option>川渝联合车队</option>
<option>宇通测试车队</option>
</select>
</label>
<label>
<span>状态</span>
<select value={status} onChange={(event) => onStatusChange(event.target.value)}>
<option value="">全部状态</option>
<option value="online">在线</option>
<option value="offline">离线</option>
</select>
</label>
</div>
</section>
);
}
function MobileCommandSurface({
keyword,
onKeywordChange,
onSubmit,
activeFilterCount,
onOpenFilters,
onBatch
}: {
keyword: string;
onKeywordChange: (value: string) => void;
onSubmit: (event: FormEvent) => void;
activeFilterCount: number;
onOpenFilters: () => void;
onBatch: () => void;
}) {
return (
<>
<form className="lab-mobile-search" onSubmit={onSubmit}>
<IconSearch aria-hidden="true" />
<input
type="search"
value={keyword}
onChange={(event) => onKeywordChange(event.target.value)}
placeholder="车牌 / VIN / 终端手机号"
aria-label="输入车牌、VIN 或终端手机号"
/>
<button type="submit">搜索</button>
</form>
<section className="lab-mobile-scope" aria-label="车辆范围">
<button className="lab-mobile-filter" type="button" onClick={onOpenFilters} aria-label="打开车辆筛选">
<IconFilter aria-hidden="true" />
{activeFilterCount > 0 ? <b>{activeFilterCount}</b> : null}
</button>
<span>
<strong>车辆范围</strong>
<small>4 辆授权车辆</small>
</span>
<button className="lab-mobile-modify" type="button" onClick={onOpenFilters}>
修改
<IconChevronDown aria-hidden="true" />
</button>
<button className="lab-mobile-batch" type="button" onClick={onBatch}>
<IconBox aria-hidden="true" />
<span>批量同步</span>
</button>
</section>
</>
);
}
function MetricRail({ records }: { records: VehicleRecord[] }) {
const online = records.filter((vehicle) => vehicle.status === 'online').length;
const multi = records.filter((vehicle) => vehicle.protocols.length > 1).length;
const metrics = [
{ label: '授权车辆', value: records.length, tone: 'primary' },
{ label: '本页在线', value: online, tone: 'success' },
{ label: '本页多源', value: multi, tone: 'primary' }
];
return (
<section className="lab-metric-rail" aria-label="车辆目录摘要">
{metrics.map((metric) => (
<div key={metric.label}>
<span>{metric.label}</span>
<strong className={`is-${metric.tone}`}>{metric.value}<small></small></strong>
</div>
))}
<aside>
<span>
<strong>授权车辆目录</strong>
<small>按照最新上报时间排序,分页浏览全部授权车辆</small>
</span>
<button type="button"> 1 / 1 </button>
<button type="button" className="lab-refresh-button" aria-label="刷新车辆目录">
<IconRefresh aria-hidden="true" />
<span>刷新</span>
</button>
</aside>
</section>
);
}
function DesktopDirectory({
records,
selectedVin,
onSelect
}: {
records: VehicleRecord[];
selectedVin?: string;
onSelect: (vehicle: VehicleRecord) => void;
}) {
const selectByKeyboard = (event: KeyboardEvent<HTMLTableRowElement>, vehicle: VehicleRecord) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onSelect(vehicle);
};
return (
<table className="lab-vehicle-table">
<thead>
<tr>
<th>车辆</th>
<th>品牌 / 终端</th>
<th>实时状态</th>
<th>协议来源</th>
<th>最后上报时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{records.map((vehicle) => (
<tr
key={vehicle.vin}
tabIndex={0}
aria-selected={vehicle.vin === selectedVin}
className={vehicle.vin === selectedVin ? 'is-selected' : ''}
onClick={() => onSelect(vehicle)}
onKeyDown={(event) => selectByKeyboard(event, vehicle)}
>
<td>
<strong>{vehicle.plate}</strong>
<small>{vehicle.vin}</small>
</td>
<td>
<strong>{vehicle.brand}</strong>
<small>{vehicle.terminal}</small>
</td>
<td><StatusTag status={vehicle.status} /></td>
<td>
<span className="lab-protocol-list">
{vehicle.protocols.map((protocol) => <ProtocolTag key={protocol} protocol={protocol} />)}
</span>
</td>
<td>{vehicle.updatedAt}:12</td>
<td>
<button type="button" onClick={(event) => { event.stopPropagation(); onSelect(vehicle); }}>
查看档案
<IconChevronRight aria-hidden="true" />
</button>
</td>
</tr>
))}
</tbody>
</table>
);
}
function MobileDirectory({
records,
selectedVin,
onSelect
}: {
records: VehicleRecord[];
selectedVin?: string;
onSelect: (vehicle: VehicleRecord) => void;
}) {
return (
<ul className="lab-mobile-directory" aria-label="车辆目录">
{records.map((vehicle) => (
<li key={vehicle.vin}>
<button
type="button"
className={vehicle.vin === selectedVin ? 'is-selected' : ''}
aria-pressed={vehicle.vin === selectedVin}
onClick={() => onSelect(vehicle)}
>
<span className={`lab-status-dot is-${vehicle.status}`} aria-hidden="true" />
<span className="lab-mobile-identity">
<strong>{vehicle.plate}</strong>
<small>{vehicle.vin}</small>
<span className="lab-protocol-list">
{vehicle.protocols.map((protocol) => <ProtocolTag key={protocol} protocol={protocol} />)}
</span>
</span>
<span className="lab-mobile-state">
<StatusTag status={vehicle.status} />
<small>{vehicle.updatedAt} 更新</small>
</span>
<IconChevronRight className="lab-mobile-row-chevron" aria-hidden="true" />
</button>
</li>
))}
</ul>
);
}
function VehicleInspector({
vehicle,
mobile,
sheetOffset,
onClose,
onCopyVin,
onDragStart,
onDragMove,
onDragEnd
}: {
vehicle: VehicleRecord;
mobile?: boolean;
sheetOffset?: number;
onClose: () => void;
onCopyVin: () => void;
onDragStart?: (event: PointerEvent<HTMLButtonElement>) => void;
onDragMove?: (event: PointerEvent<HTMLButtonElement>) => void;
onDragEnd?: (event: PointerEvent<HTMLButtonElement>) => void;
}) {
const style = mobile
? ({ '--lab-sheet-offset': `${sheetOffset ?? 0}px` } as CSSProperties)
: undefined;
return (
<aside
className={mobile ? 'lab-mobile-inspector' : 'lab-desktop-inspector'}
aria-label={`${vehicle.plate} 车辆档案`}
style={style}
>
{mobile ? (
<button
type="button"
className="lab-sheet-handle"
aria-label="拖动或关闭车辆详情"
onPointerDown={onDragStart}
onPointerMove={onDragMove}
onPointerUp={onDragEnd}
onPointerCancel={onDragEnd}
>
<span />
</button>
) : null}
<header>
<span>
<small>{mobile ? '车辆详情' : '车辆档案'}</small>
<strong>{vehicle.plate}</strong>
</span>
<button type="button" onClick={onClose} aria-label="关闭车辆档案">
<IconClose aria-hidden="true" />
{mobile ? <span>关闭</span> : null}
</button>
</header>
<div className="lab-inspector-status">
<StatusTag status={vehicle.status} />
<small>最近更新 {vehicle.updatedAt}</small>
</div>
<dl>
<div>
<dt>VIN</dt>
<dd>
<span>{vehicle.vin}</span>
<button type="button" onClick={onCopyVin} aria-label="复制 VIN"><IconCopy aria-hidden="true" /></button>
</dd>
</div>
<div><dt>品牌 / 车型</dt><dd>{vehicle.brand}</dd></div>
<div><dt>终端编号</dt><dd>{vehicle.terminal}</dd></div>
<div><dt>业务负责人</dt><dd>{vehicle.owner}</dd></div>
<div><dt>所属部门</dt><dd>{vehicle.department}</dd></div>
<div><dt>授权状态</dt><dd>已授权</dd></div>
</dl>
<section>
<strong>协议来源</strong>
{vehicle.protocols.map((protocol) => (
<div className="lab-inspector-protocol" key={protocol}>
<span className="lab-protocol-dot" aria-hidden="true" />
<span>
<b>{protocol}</b>
<small>最后上报 {vehicle.updatedAt}:12</small>
</span>
</div>
))}
</section>
<button type="button" className="lab-inspector-primary">查看完整档案<IconChevronRight aria-hidden="true" /></button>
</aside>
);
}
function MobileFilterSheet({
view,
status,
department,
onViewChange,
onStatusChange,
onDepartmentChange,
onReset,
onClose
}: {
view: DirectoryView;
status: string;
department: string;
onViewChange: (view: DirectoryView) => void;
onStatusChange: (value: string) => void;
onDepartmentChange: (value: string) => void;
onReset: () => void;
onClose: () => void;
}) {
return (
<div className="lab-sheet-backdrop" role="presentation" onMouseDown={(event) => {
if (event.currentTarget === event.target) onClose();
}}>
<section className="lab-filter-sheet" role="dialog" aria-modal="true" aria-labelledby="lab-filter-title">
<header>
<span><strong id="lab-filter-title">筛选车辆</strong><small>范围、状态与业务归属</small></span>
<button type="button" onClick={onClose} aria-label="关闭筛选"><IconClose aria-hidden="true" /></button>
</header>
<fieldset>
<legend>车辆范围</legend>
<div className="lab-filter-options">
{viewOptions.map((option) => (
<button
key={option.key}
type="button"
className={view === option.key ? 'is-active' : ''}
onClick={() => onViewChange(option.key)}
>
{option.label}
</button>
))}
</div>
</fieldset>
<label>
<span>在线状态</span>
<select value={status} onChange={(event) => onStatusChange(event.target.value)}>
<option value="">全部状态</option>
<option value="online">在线</option>
<option value="offline">离线</option>
</select>
</label>
<label>
<span>所属部门</span>
<select value={department} onChange={(event) => onDepartmentChange(event.target.value)}>
<option value="">全部部门</option>
<option>华南运营部</option>
<option>西南交付部</option>
<option>中原运营部</option>
</select>
</label>
<footer>
<button type="button" onClick={onReset}>重置</button>
<button type="button" className="is-primary" onClick={onClose}>查看结果</button>
</footer>
</section>
</div>
);
}
function BottomNavigation() {
const items = [
{ key: 'monitor', label: '全局监控', icon: IconHome },
{ key: 'vehicles', label: '车辆查询', icon: IconSearch, active: true },
{ key: 'tracks', label: '轨迹回放', icon: IconRoute },
{ key: 'statistics', label: '里程查询', icon: IconBarChartVStroked },
{ key: 'alerts', label: '更多', icon: IconMore }
];
return (
<nav className="lab-bottom-nav" aria-label="移动端主导航">
{items.map((item) => {
const Icon = item.icon;
return (
<button
type="button"
className={item.active ? 'is-active' : ''}
key={item.label}
onClick={() => item.key !== 'vehicles' && (window.location.href = `/platform-design-lab.html#${item.key}`)}
>
<Icon aria-hidden="true" />
<span>{item.label}</span>
</button>
);
})}
</nav>
);
}
export function VehicleDesignLab() {
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword.trim().toLowerCase());
const [view, setView] = useState<DirectoryView>('all');
const [department, setDepartment] = useState('');
const [owner, setOwner] = useState('');
const [customer, setCustomer] = useState('');
const [status, setStatus] = useState('');
const [selectedVin, setSelectedVin] = useState(vehicles[0].vin);
const [filterOpen, setFilterOpen] = useState(false);
const [notice, setNotice] = useState('');
const [sheetOffset, setSheetOffset] = useState(0);
const dragStartRef = useRef<number | undefined>(undefined);
const filteredVehicles = useMemo(() => vehicles.filter((vehicle) => {
const matchesKeyword = !deferredKeyword || [
vehicle.plate,
vehicle.vin,
vehicle.brand,
vehicle.terminal
].some((value) => value.toLowerCase().includes(deferredKeyword));
const matchesView = view === 'all'
|| view === 'online' && vehicle.status === 'online'
|| view === 'offline' && vehicle.status === 'offline'
|| view === 'multi' && vehicle.protocols.length > 1;
return matchesKeyword
&& matchesView
&& (!department || vehicle.department === department)
&& (!owner || vehicle.owner === owner)
&& (!customer || vehicle.customer === customer)
&& (!status || vehicle.status === status);
}), [customer, deferredKeyword, department, owner, status, view]);
const selectedVehicle = vehicles.find((vehicle) => vehicle.vin === selectedVin);
const activeFilterCount = [view !== 'all', !!department, !!owner, !!customer, !!status].filter(Boolean).length;
useEffect(() => {
if (!notice) return;
const timeout = window.setTimeout(() => setNotice(''), 2400);
return () => window.clearTimeout(timeout);
}, [notice]);
useEffect(() => {
if (!filterOpen) return;
const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === 'Escape') setFilterOpen(false);
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [filterOpen]);
const submitSearch = (event: FormEvent) => {
event.preventDefault();
setNotice(`已找到 ${filteredVehicles.length} 辆车辆`);
};
const resetFilters = () => {
setView('all');
setDepartment('');
setOwner('');
setCustomer('');
setStatus('');
};
const copySelectedVin = async () => {
if (!selectedVehicle) return;
try {
await navigator.clipboard.writeText(selectedVehicle.vin);
setNotice('VIN 已复制');
} catch {
setNotice('当前浏览器未开放剪贴板权限');
}
};
const onDragStart = (event: PointerEvent<HTMLButtonElement>) => {
dragStartRef.current = event.clientY;
event.currentTarget.setPointerCapture(event.pointerId);
};
const onDragMove = (event: PointerEvent<HTMLButtonElement>) => {
if (dragStartRef.current === undefined) return;
setSheetOffset(Math.max(0, event.clientY - dragStartRef.current));
};
const onDragEnd = (event: PointerEvent<HTMLButtonElement>) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
dragStartRef.current = undefined;
if (sheetOffset > 110) setSelectedVin('');
setSheetOffset(0);
};
return (
<div className={`vehicle-design-lab${selectedVehicle ? ' has-inspector' : ''}`}>
<Sidebar />
<div className="lab-stage">
<TopBar onHelp={() => setNotice('帮助:搜索车辆后选择一行查看档案')} />
<main>
<CommandSurface
keyword={keyword}
view={view}
department={department}
owner={owner}
customer={customer}
status={status}
onKeywordChange={setKeyword}
onViewChange={setView}
onDepartmentChange={setDepartment}
onOwnerChange={setOwner}
onCustomerChange={setCustomer}
onStatusChange={setStatus}
onSubmit={submitSearch}
/>
<MobileCommandSurface
keyword={keyword}
onKeywordChange={setKeyword}
onSubmit={submitSearch}
activeFilterCount={activeFilterCount}
onOpenFilters={() => setFilterOpen(true)}
onBatch={() => setNotice('已创建 4 辆车辆的主档同步预览')}
/>
<MetricRail records={vehicles} />
<div className="lab-directory-layout">
<section className="lab-directory-surface" aria-label="授权车辆目录">
<header className="lab-directory-header">
<span>
<strong>授权车辆目录</strong>
<small>按照最新上报时间排序</small>
</span>
<em>{filteredVehicles.length} </em>
</header>
{filteredVehicles.length > 0 ? (
<>
<DesktopDirectory records={filteredVehicles} selectedVin={selectedVin} onSelect={(vehicle) => setSelectedVin(vehicle.vin)} />
<MobileDirectory records={filteredVehicles} selectedVin={selectedVin} onSelect={(vehicle) => setSelectedVin(vehicle.vin)} />
</>
) : (
<div className="lab-empty-state">
<IconSearch aria-hidden="true" />
<strong>没有匹配车辆</strong>
<span>调整关键词或清除筛选后重试。</span>
<button type="button" onClick={resetFilters}>清除筛选</button>
</div>
)}
<footer className="lab-directory-footer">
<span> {filteredVehicles.length} · 本页 {filteredVehicles.length} </span>
<div>
<button type="button" disabled aria-label="上一页"><IconChevronLeft aria-hidden="true" /></button>
<span>1 / 1</span>
<button type="button" disabled aria-label="下一页"><IconChevronRight aria-hidden="true" /></button>
</div>
</footer>
</section>
{selectedVehicle ? (
<VehicleInspector
vehicle={selectedVehicle}
onClose={() => setSelectedVin('')}
onCopyVin={copySelectedVin}
/>
) : null}
</div>
</main>
<BottomNavigation />
{selectedVehicle ? (
<VehicleInspector
mobile
vehicle={selectedVehicle}
sheetOffset={sheetOffset}
onClose={() => setSelectedVin('')}
onCopyVin={copySelectedVin}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
/>
) : null}
{filterOpen ? (
<MobileFilterSheet
view={view}
status={status}
department={department}
onViewChange={setView}
onStatusChange={setStatus}
onDepartmentChange={setDepartment}
onReset={resetFilters}
onClose={() => setFilterOpen(false)}
/>
) : null}
{notice ? <div className="lab-notice" role="status">{notice}</div> : null}
</div>
</div>
);
}