feat: adapt leadership dashboard for mobile

This commit is contained in:
inman
2026-09-02 11:49:43 +08:00
parent 1b277cf373
commit 8dddd215b5
7 changed files with 648 additions and 46 deletions

View File

@@ -0,0 +1,60 @@
# Task: Adapt leadership dashboard for mobile sharing
## Identity
- Task ID: 20260902-dashboard-mobile-share-4e91c7
- Mode: Feature
- Branch: codex/20260902-dashboard-mobile-share-4e91c7-dashboard-mobile-share-4e91c7
- Worktree: /Users/inmanx/Documents/lwltAPI-dashboard-mobile-share-4e91c7
- Base commit: 63b387f67998410d02c2c3493c3e1b2ee8f2f108
- Owner: codex
- Status: Ready for integration
## Scope
- Carry the accepted dashboard card correction into this isolated task: unify the first operation-count card with the remaining metrics, keep the selected state clear, and fold the former follow-up state into in-progress.
- Adapt `/operations-dashboard` for phone and portrait-tablet use without changing its leadership-only authorization boundary.
- Make the route visually self-contained for direct sharing with leaders by removing unrelated operator navigation and technical status controls from the dashboard shell.
- Replace the desktop-only task table interaction with a responsive mobile card presentation while preserving the same data, filters, pagination, and task-detail drill-through.
- Add focused regression coverage and verify the real layout at phone, tablet, and desktop viewports.
## Intent And Constraints
- “Standalone sharing” means an authenticated, focused dashboard URL for administrators and team leads; it does not authorize an anonymous or public share link.
- Preserve the existing dashboard API, business-facing terminology, ranking semantics, filter behavior, and dynamic nice-scale bars.
- Keep every mobile control touch-friendly, avoid page-level horizontal overflow, respect device safe areas, and prevent the background dashboard from scrolling while the task-detail overlay is open.
- Preserve all task, account, ERP, deployment, and runtime-data boundaries. Do not access ERP, restart services, mutate runtime data, or deploy this feature.
- Do not alter business adaptation routes, schemas, mappings, extension sources, release artifacts, or canonical project memory from this Feature task.
## Outcome
- Brought the prior dashboard card correction into the feature: all five metrics now share one treatment, the active filter uses a light-green selected state with `aria-pressed`, and no visible follow-up card or filter remains. Waiting-for-input work is counted and queried under in-progress.
- Added a dedicated leadership header and return link, enabled `viewport-fit=cover`, and hid the main operator navigation, automation control, AI status, plugin status, and their popover on the dashboard route. Password and logout controls remain available.
- Added a responsive layout through 920 px: a horizontally swipeable metric strip, one-column fixed-height rankings with larger touch targets, two-column filters, and compact safe-area-aware chrome that fits down to a 320 px viewport.
- Converted task rows into labeled mobile cards without duplicating data or changing the desktop table. Each cell receives a semantic `data-label`; input and output remain visually distinct, and status stays visible at the card edge.
- Made task details a safe-area-aware near-full-screen mobile dialog, froze background scrolling while it is open, and restored scrolling on close.
- Preserved the desktop two-ranking layout, dynamic count scales, filters, pagination, detail drill-through, and leadership authorization. Updated static cache keys and their regression expectation.
## Verification
- `node --check LianSyn-platform/app.js`: passed.
- Focused dashboard regression `LianSyn-platform/app-operations-dashboard.test.mjs`: 4/4 passed.
- `node --test tools/repository-hygiene.test.mjs`: 10/10 passed.
- TypeScript no-emit check: passed.
- Control-plane suite: 153/153 passed.
- Legacy and platform suite: 264/264 passed.
- TypeScript build: passed.
- `git diff --check`: passed.
- Isolated non-persistent browser preview at `http://127.0.0.1:8893/operations-dashboard?mobile-preview=1` used the actual feature HTML, CSS, and JavaScript with business-safe mock data.
- Visual checks passed at 320×740, 390×844, 768×1024, and 1440×900. Document, body, shell, dashboard page, and filter widths matched the viewport at 320, 390, and 768 px; only the intended metric strip had horizontal overflow.
- Browser interaction verified metric selection rendering, filter layout, mobile task cards, task-detail open/close, background scroll locking, and desktop two-column rankings. Browser console error count was zero.
- `check_project_docs.py`: passed.
- `check_doc_drift.py --task-id 20260902-dashboard-mobile-share-4e91c7`: passed with only the owned task record under `.project-docs`.
## Follow-ups
- Integrate this isolated feature commit before validating the normal authenticated `/operations-dashboard` route against live control-plane data. No deployment or public-link behavior is included in this task.
## Promotion Candidates
- None. This is a bounded responsive and presentation change within the accepted leadership-dashboard behavior.

View File

@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const [app, index, styles, taskService] = await Promise.all([
readFile(new URL('./app.js', import.meta.url), 'utf8'),
readFile(new URL('./index.html', import.meta.url), 'utf8'),
readFile(new URL('./styles.css', import.meta.url), 'utf8'),
readFile(new URL('../control-plane/src/task-service.ts', import.meta.url), 'utf8')
]);
test('dashboard summary cards share one visual treatment and expose an accessible selected state', () => {
assert.match(app, /label: '操作次数'[^\n]+tone: 'total'/);
assert.doesNotMatch(app, /tone: 'primary'/);
assert.match(app, /card\.setAttribute\('aria-pressed', String\(isSelected\)\)/);
assert.match(styles, /\.operations-dashboard-summary\s*{[\s\S]*?grid-template-columns: repeat\(5, minmax\(0, 1fr\)\)/);
assert.match(styles, /\.operations-dashboard-stat\.is-active\s*{[\s\S]*?background: var\(--dashboard-accent-soft\);[\s\S]*?transform: none;/);
assert.doesNotMatch(styles, /operations-dashboard-stat-primary/);
});
test('waiting-for-input work is folded into in-progress instead of exposing a follow-up state', () => {
assert.doesNotMatch(index, /<option value="attention">/);
assert.doesNotMatch(app, /label: '待跟进'/);
assert.match(app, /outcomeKind === 'attention'\) return '进行中'/);
assert.match(app, /Number\(summary\.active \|\| 0\) \+ Number\(summary\.attention \|\| 0\)/);
const activeSql = taskService.slice(
taskService.indexOf('const OPERATIONS_ACTIVE_SQL'),
taskService.indexOf('const OPERATIONS_COMPLETED_STATUSES')
);
assert.doesNotMatch(activeSql, /OPERATIONS_ATTENTION_SQL/);
});
test('dashboard route has a focused leadership header and phone-safe navigation shell', () => {
assert.match(index, /content="width=device-width, initial-scale=1, viewport-fit=cover"/);
assert.match(index, /class="operations-dashboard-page-head"[\s\S]*?<h1>平台运行看板<\/h1>/);
assert.match(index, /class="operations-dashboard-back-link" href="\/">返回操作台<\/a>/);
assert.match(
styles,
/body\.operations-dashboard-page-active \.top-nav,[\s\S]*?#statusDetailsPopover\s*{[\s\S]*?display: none !important;/
);
assert.match(
styles,
/@media \(max-width: 920px\)[\s\S]*?body\.operations-dashboard-page-active \.app-shell\s*{[\s\S]*?width: 100%;/
);
});
test('phone dashboard turns metrics and task rows into touch-friendly cards', () => {
assert.match(
styles,
/\.operations-dashboard-summary\s*{[\s\S]*?display: flex;[\s\S]*?overflow-x: auto;[\s\S]*?scroll-snap-type: x proximity;/
);
assert.match(styles, /\.operations-dashboard-business-row,[\s\S]*?min-height: 48px;/);
assert.match(styles, /\.operations-dashboard-table thead\s*{\s*display: none;/);
assert.match(styles, /content: attr\(data-label\);/);
assert.match(styles, /\.operations-dashboard-detail\s*{[\s\S]*?env\(safe-area-inset-top\)/);
for (const label of ['操作时间', '员工名称', '任务类型', '原始输入', '最终输出', '任务结果']) {
assert.match(app, new RegExp(`dataset\\.label = '${label}'`));
}
assert.match(app, /document\.body\.classList\.add\('operations-dashboard-detail-open'\)/);
assert.match(app, /document\.body\.classList\.remove\('operations-dashboard-detail-open'\)/);
});

View File

@@ -1278,13 +1278,13 @@ function operationsDashboardRange() {
function operationsDashboardStatusAppearance(outcomeKind) {
if (outcomeKind === 'failed') return 'state-bad';
if (outcomeKind === 'completed') return 'state-ok';
if (outcomeKind === 'active') return 'state-process';
if (outcomeKind === 'active' || outcomeKind === 'attention') return 'state-process';
return 'state-warn';
}
function operationsDashboardOutcomeLabel(outcomeKind, fallback = '') {
if (outcomeKind === 'completed') return '成功';
if (outcomeKind === 'attention') return '待跟进';
if (outcomeKind === 'attention') return '进行中';
if (outcomeKind === 'failed') return '未成功';
if (outcomeKind === 'cancelled') return '已取消';
if (outcomeKind === 'active') return '进行中';
@@ -1292,7 +1292,7 @@ function operationsDashboardOutcomeLabel(outcomeKind, fallback = '') {
}
function operationsDashboardReadableResult(value, outcomeKind) {
const raw = String(value || '').trim();
const raw = String(value || '').trim().replace(/^待跟进\s*[:]\s*/u, '');
const technicalLanguage = /(?:\b(?:agent|api|dom|frame|iframe|json|native|operation|parser|payload|plugin|projection|requery|schema|stack|trace|write)\b|[a-z][a-z0-9]*(?:_[a-z0-9]+)+|data\.[a-z]|插件|解析服务|技术上下文|错误码|接口|ERP)/i;
if (!raw) {
if (outcomeKind === 'completed') return '工作已经完成。';
@@ -1305,7 +1305,7 @@ function operationsDashboardReadableResult(value, outcomeKind) {
if (/不确定|核验|请勿重复/.test(raw)) return '当前结果需要人工确认,请勿重复提交。';
if (/未找到|不存在|不匹配|mismatch/i.test(raw)) return '未完成:没有找到符合指令条件的业务记录。';
if (/登录|连接|网络|超时/i.test(raw)) return '未完成:业务系统暂时无法完成处理,请稍后再试。';
if (/补充|缺少|必填|日期|年份/.test(raw)) return '待跟进:需要补充完整的业务信息。';
if (/补充|缺少|必填|日期|年份/.test(raw)) return '需要补充完整的业务信息。';
if (/不支持|超出.*范围/.test(raw)) return '未完成:这项要求暂不在当前业务处理范围内。';
if (outcomeKind === 'completed') return '工作已经完成,结果已记录。';
if (outcomeKind === 'attention') return '正在等待补充信息或确认。';
@@ -1363,20 +1363,21 @@ function renderOperationsDashboardSummary() {
container.replaceChildren();
const summary = operationsDashboardData?.summary || {};
const cards = [
{ label: '操作次数', value: summary.total || 0, hint: '全部任务操作', status: 'all', tone: 'primary' },
{ label: '操作次数', value: summary.total || 0, hint: '全部任务操作', status: 'all', tone: 'total' },
{ label: '成功次数', value: summary.completed || 0, hint: '已成功完成', status: 'completed', tone: 'success' },
{ label: '进行中', value: summary.active || 0, hint: '正在处理', status: 'active', tone: 'active' },
{ label: '待跟进', value: summary.attention || 0, hint: '等待补充或确认', status: 'attention', tone: 'attention' },
{ label: '进行中', value: Number(summary.active || 0) + Number(summary.attention || 0), hint: '正在处理', status: 'active', tone: 'active' },
{ label: '未成功', value: summary.failed || 0, hint: '需要继续处理', status: 'failed', tone: 'failed' },
{ label: '操作员工', value: summary.user_count || 0, hint: '参与操作人数', status: '', tone: 'people' }
];
const selectedStatus = String($('#operationsDashboardStatus')?.value || 'all');
for (const item of cards) {
const card = el(item.status ? 'button' : 'article', `operations-dashboard-stat operations-dashboard-stat-${item.tone}${item.status && selectedStatus === item.status ? ' is-active' : ''}`);
const isSelected = Boolean(item.status && selectedStatus === item.status);
const card = el(item.status ? 'button' : 'article', `operations-dashboard-stat operations-dashboard-stat-${item.tone}${isSelected ? ' is-active' : ''}`);
if (item.status) {
card.type = 'button';
card.dataset.operationsStatus = item.status;
card.setAttribute('aria-label', `${item.label}筛选任务`);
card.setAttribute('aria-pressed', String(isSelected));
}
card.append(el('span', '', item.label));
card.append(el('strong', '', String(item.value)));
@@ -1515,8 +1516,10 @@ function renderOperationsDashboardTasks() {
row.setAttribute('aria-label', `查看 ${task.creator.username}${operationsDashboardBusinessLabel(task.business_route_label)}详情`);
const timeCell = el('td', 'operations-dashboard-table-time');
timeCell.dataset.label = '操作时间';
timeCell.append(el('time', '', formatDateTime(task.created_at)));
const actorCell = el('td', 'operations-dashboard-table-actor');
actorCell.dataset.label = '员工名称';
const actor = el('span', 'operations-dashboard-task-person');
actor.append(
el('span', 'operations-dashboard-avatar', String(task.creator.username || '人').slice(0, 1).toUpperCase()),
@@ -1524,14 +1527,18 @@ function renderOperationsDashboardTasks() {
);
actorCell.append(actor);
const typeCell = el('td', 'operations-dashboard-table-type', operationsDashboardBusinessLabel(task.business_route_label));
typeCell.dataset.label = '任务类型';
const inputCell = el('td', 'operations-dashboard-table-copy');
inputCell.dataset.label = '原始输入';
inputCell.append(el('span', '', operationsDashboardReadableInput(task.instruction_preview)));
if (Number(task.input_turn_count || 0) > 1) {
inputCell.append(el('small', '', `${task.input_turn_count} 次沟通`));
}
const outputCell = el('td', 'operations-dashboard-table-copy is-output');
outputCell.dataset.label = '最终输出';
outputCell.append(el('span', '', operationsDashboardReadableResult(task.result_summary, task.outcome_kind)));
const statusCell = el('td', 'operations-dashboard-table-status');
statusCell.dataset.label = '任务结果';
statusCell.append(el('span', `state ${operationsDashboardStatusAppearance(task.outcome_kind)}`, operationsDashboardOutcomeLabel(task.outcome_kind, task.outcome_label)));
row.append(timeCell, actorCell, typeCell, inputCell, outputCell, statusCell);
container.append(row);
@@ -1565,9 +1572,11 @@ function renderOperationsDashboardDetail() {
const detail = operationsDashboardDetail;
if (!detail?.task_id) {
panel.hidden = true;
document.body.classList.remove('operations-dashboard-detail-open');
return;
}
panel.hidden = false;
document.body.classList.add('operations-dashboard-detail-open');
$('#operationsDashboardDetailTitle').textContent = `${detail.creator?.username || '人员'} · ${operationsDashboardBusinessLabel(detail.business_route_label)}`;
const facts = el('div', 'operations-dashboard-detail-facts');
@@ -1652,6 +1661,7 @@ async function loadOperationsDashboardTask(taskId) {
const panel = $('#operationsDashboardDetail');
const container = $('#operationsDashboardDetailBody');
panel.hidden = false;
document.body.classList.add('operations-dashboard-detail-open');
container.replaceChildren(el('p', 'operations-dashboard-empty', '正在打开工作详情…'));
$('#operationsDashboardDetailTitle').textContent = '一次工作的完整过程';
const result = await apiRequest(`/api/operations-dashboard/tasks/${encodeURIComponent(taskId)}`);
@@ -1664,6 +1674,7 @@ function closeOperationsDashboardDetail() {
operationsDashboardSelectedTaskId = '';
operationsDashboardDetail = null;
$('#operationsDashboardDetail').hidden = true;
document.body.classList.remove('operations-dashboard-detail-open');
renderOperationsDashboardTasks();
}

View File

@@ -2,10 +2,10 @@
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#edf2f4">
<title>AI操作台 · LianSyn-platform</title>
<link rel="stylesheet" href="styles.css?v=20260902-dashboard-nice-scale-1">
<link rel="stylesheet" href="styles.css?v=20260902-dashboard-mobile-share-1">
</head>
<body>
<main class="app-shell">
@@ -128,7 +128,16 @@
<section id="operationsDashboardPage" class="operations-dashboard-page" hidden>
<section class="operations-dashboard-panel">
<div id="operationsDashboardSummary" class="operations-dashboard-summary" aria-live="polite"></div>
<header class="operations-dashboard-page-head">
<div>
<p>领导工作台</p>
<h1>平台运行看板</h1>
<span>查看任务、人员与业务结果的最新汇总</span>
</div>
<a class="operations-dashboard-back-link" href="/">返回操作台</a>
</header>
<div id="operationsDashboardSummary" class="operations-dashboard-summary" aria-label="关键指标" aria-live="polite"></div>
<div class="operations-dashboard-ranking-grid">
<section class="operations-dashboard-insight operations-dashboard-business" aria-labelledby="operationsDashboardBusinessesTitle">
@@ -179,7 +188,6 @@
<option value="all">全部结果</option>
<option value="active">进行中</option>
<option value="completed">成功</option>
<option value="attention">待跟进</option>
<option value="failed">未成功</option>
<option value="cancelled">已取消</option>
</select></label>
@@ -217,7 +225,7 @@
</section>
</section>
<aside id="operationsDashboardDetail" class="operations-dashboard-detail" hidden aria-labelledby="operationsDashboardDetailTitle">
<aside id="operationsDashboardDetail" class="operations-dashboard-detail" role="dialog" hidden aria-labelledby="operationsDashboardDetailTitle">
<div class="operations-dashboard-detail-head">
<div>
<p>任务详情</p>
@@ -397,6 +405,6 @@
</section>
</main>
<script src="app.js?v=20260902-dashboard-nice-scale-1"></script>
<script src="app.js?v=20260902-dashboard-mobile-share-1"></script>
</body>
</html>

View File

@@ -3224,7 +3224,7 @@ textarea {
.operations-dashboard-summary {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
}
@@ -3248,15 +3248,17 @@ textarea {
cursor: pointer;
}
.operations-dashboard-stat[data-operations-status]:hover,
.operations-dashboard-stat.is-active {
.operations-dashboard-stat[data-operations-status]:not(.is-active):hover {
border-color: #8daf9f;
box-shadow: 0 9px 24px rgba(32, 79, 67, 0.09);
transform: translateY(-2px);
}
.operations-dashboard-stat.is-active {
border-color: var(--dashboard-accent);
background: var(--dashboard-accent-soft);
box-shadow: inset 0 0 0 1px var(--dashboard-accent), 0 9px 24px rgba(32, 79, 67, 0.09);
transform: none;
}
.operations-dashboard-stat > span {
@@ -3278,29 +3280,9 @@ textarea {
font-size: 11px;
}
.operations-dashboard-stat-primary {
border-color: var(--dashboard-ink);
background: var(--dashboard-ink);
}
.operations-dashboard-stat-primary > span,
.operations-dashboard-stat-primary > small {
color: #bdd0c9;
}
.operations-dashboard-stat-primary > strong {
color: #ffffff;
}
.operations-dashboard-stat.operations-dashboard-stat-primary[data-operations-status]:hover,
.operations-dashboard-stat.operations-dashboard-stat-primary[data-operations-status].is-active {
border-color: var(--dashboard-ink);
background: var(--dashboard-ink);
}
.operations-dashboard-stat-total::before,
.operations-dashboard-stat-success::before,
.operations-dashboard-stat-active::before,
.operations-dashboard-stat-attention::before,
.operations-dashboard-stat-failed::before {
position: absolute;
top: 15px;
@@ -3312,10 +3294,6 @@ textarea {
content: "";
}
.operations-dashboard-stat-attention::before {
background: #b38435;
}
.operations-dashboard-stat-failed::before {
background: #a54f43;
}
@@ -3988,9 +3966,9 @@ textarea {
white-space: nowrap;
}
.operations-dashboard-stat-total::before,
.operations-dashboard-stat-success::before,
.operations-dashboard-stat-active::before,
.operations-dashboard-stat-attention::before,
.operations-dashboard-stat-failed::before {
top: 12px;
right: 12px;
@@ -4410,6 +4388,491 @@ textarea {
}
}
/* Standalone leadership view: keep the shared route focused on the dashboard. */
body.operations-dashboard-page-active .top-nav,
body.operations-dashboard-page-active #automationToggleButton,
body.operations-dashboard-page-active #aiState,
body.operations-dashboard-page-active #bridgeState,
body.operations-dashboard-page-active #statusDetailsPopover {
display: none !important;
}
body.operations-dashboard-page-active .topbar {
display: flex;
justify-content: space-between;
}
body.operations-dashboard-page-active .top-actions {
margin-left: auto;
}
body.operations-dashboard-detail-open .operations-dashboard-page {
overflow: hidden;
}
.operations-dashboard-page {
overscroll-behavior-y: contain;
scrollbar-gutter: stable;
-webkit-overflow-scrolling: touch;
}
.operations-dashboard-page-head {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 2px;
}
.operations-dashboard-page-head > div {
min-width: 0;
display: grid;
gap: 3px;
}
.operations-dashboard-page-head p {
color: var(--dashboard-accent);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
}
.operations-dashboard-page-head h1 {
color: var(--dashboard-ink);
font-size: 22px;
font-weight: 760;
letter-spacing: -0.025em;
}
.operations-dashboard-page-head span {
color: var(--dashboard-muted);
font-size: 12px;
}
.operations-dashboard-back-link {
min-height: 40px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 13px;
border: 1px solid var(--dashboard-line);
border-radius: 7px;
background: #ffffff;
color: var(--dashboard-ink-soft);
font-size: 12px;
font-weight: 750;
text-decoration: none;
}
.operations-dashboard-back-link:hover {
border-color: #9cb4aa;
background: #eef4f1;
}
.operations-dashboard-back-link:focus-visible {
outline: 3px solid rgba(43, 117, 101, 0.24);
outline-offset: 2px;
}
@media (max-width: 920px) {
body.operations-dashboard-page-active .app-shell {
width: 100%;
margin: 0;
padding: 0;
gap: 0;
}
body.operations-dashboard-page-active .topbar {
min-height: 56px;
flex: 0 0 auto;
gap: 7px;
padding: calc(6px + env(safe-area-inset-top)) calc(10px + env(safe-area-inset-right)) 6px calc(10px + env(safe-area-inset-left));
border-top: 0;
border-right: 0;
border-left: 0;
border-radius: 0;
box-shadow: 0 1px 8px rgba(22, 37, 33, 0.06);
}
body.operations-dashboard-page-active .brand-block {
flex: 0 1 auto;
}
body.operations-dashboard-page-active .brand-logo {
width: 128px;
height: 40px;
max-width: 100%;
object-fit: cover;
}
body.operations-dashboard-page-active .top-actions {
width: auto;
display: flex;
flex: 0 0 auto;
flex-wrap: nowrap;
gap: 5px;
margin: 0 0 0 auto;
}
body.operations-dashboard-page-active .top-actions .auth-state {
display: none !important;
}
body.operations-dashboard-page-active .top-actions button {
width: auto;
min-height: 38px;
padding: 0 8px;
font-size: 11px;
}
.operations-dashboard-page {
scrollbar-gutter: auto;
}
.operations-dashboard-panel {
min-height: 100%;
gap: 12px;
padding: 12px 10px calc(18px + env(safe-area-inset-bottom));
border: 0;
border-radius: 0;
box-shadow: none;
}
.operations-dashboard-page-head {
align-items: flex-start;
gap: 10px;
padding: 1px 2px 3px;
}
.operations-dashboard-page-head h1 {
font-size: 21px;
}
.operations-dashboard-page-head span {
display: block;
max-width: 220px;
font-size: 11px;
line-height: 1.45;
}
.operations-dashboard-back-link {
min-height: 40px;
padding: 0 10px;
font-size: 11px;
}
.operations-dashboard-summary {
display: flex;
gap: 8px;
margin: 0 -10px;
padding: 0 10px 4px;
overflow-x: auto;
scroll-padding-inline: 10px;
scroll-snap-type: x proximity;
scrollbar-width: none;
}
.operations-dashboard-summary::-webkit-scrollbar {
display: none;
}
.operations-dashboard-stat {
min-width: 0;
min-height: 94px;
flex: 0 0 clamp(142px, 42vw, 164px);
padding: 12px;
scroll-snap-align: start;
}
.operations-dashboard-stat > strong {
font-size: 28px;
}
.operations-dashboard-ranking-grid .operations-dashboard-insight,
.operations-dashboard-ranking-grid .operations-dashboard-people {
height: 286px;
min-height: 286px;
padding: 12px;
}
.operations-dashboard-ranking-grid .operations-dashboard-section-head {
gap: 10px;
}
.operations-dashboard-business-row,
.operations-dashboard-user-row {
min-height: 48px;
grid-template-columns: 28px minmax(0, 1fr) auto;
gap: 7px;
padding: 7px 8px;
touch-action: manipulation;
}
.operations-dashboard-rank-metrics,
.operations-dashboard-user-metrics {
gap: 7px;
font-size: 10px;
}
.operations-dashboard-workstream {
min-height: 0;
grid-template-rows: repeat(6, auto);
padding: 12px;
}
.operations-dashboard-workstream-head {
width: 100%;
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
.operations-dashboard-workstream-head > .operations-dashboard-update-state {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
.operations-dashboard-refresh {
min-height: 44px;
}
.operations-dashboard-filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px 8px;
padding: 10px;
}
.operations-dashboard-range-presets,
.operations-dashboard-search,
.operations-dashboard-submit {
grid-column: 1 / -1;
}
.operations-dashboard-range-presets {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-self: auto;
gap: 4px;
}
.operations-dashboard-range-presets button,
.operations-dashboard-filters input,
.operations-dashboard-filters select,
.operations-dashboard-submit {
min-height: 44px;
}
.operations-dashboard-filters input,
.operations-dashboard-filters select {
min-width: 0;
font-size: 16px;
}
.operations-dashboard-table-meta {
width: 100%;
display: flex;
align-items: center;
flex-direction: row;
}
.operations-dashboard-table-meta > span:last-child {
display: none;
}
.operations-dashboard-table-wrap {
min-height: 0;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.operations-dashboard-table,
.operations-dashboard-table tbody {
width: 100%;
min-width: 0;
display: block;
}
.operations-dashboard-table thead {
display: none;
}
.operations-dashboard-table tbody {
display: grid;
gap: 9px;
}
.operations-dashboard-table-row {
position: relative;
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 11px 12px;
padding: 12px;
overflow: hidden;
border: 1px solid var(--dashboard-line);
border-radius: 9px;
background: #ffffff;
box-shadow: 0 2px 8px rgba(23, 55, 47, 0.04);
touch-action: manipulation;
}
.operations-dashboard-table-row:hover,
.operations-dashboard-table-row:focus-visible,
.operations-dashboard-table-row.is-selected {
border-color: #8fb2a4;
background: #eef5f1;
box-shadow: inset 3px 0 0 var(--dashboard-accent), 0 3px 10px rgba(23, 55, 47, 0.07);
}
.operations-dashboard-table-row td,
.operations-dashboard-table-row:last-child td {
min-width: 0;
height: auto;
display: grid;
align-content: start;
gap: 4px;
padding: 0;
overflow: visible;
border: 0;
background: transparent;
}
.operations-dashboard-table-row:hover td,
.operations-dashboard-table-row:focus-visible td,
.operations-dashboard-table-row.is-selected td {
background: transparent;
}
.operations-dashboard-table-row td::before {
color: var(--dashboard-muted);
content: attr(data-label);
font-size: 9px;
font-weight: 750;
letter-spacing: 0.04em;
}
.operations-dashboard-table-row td:nth-child(1) {
grid-column: 1;
grid-row: 1;
}
.operations-dashboard-table-row td:nth-child(2) {
grid-column: 1;
grid-row: 2;
}
.operations-dashboard-table-row td:nth-child(3) {
grid-column: 2;
grid-row: 2;
}
.operations-dashboard-table-row td:nth-child(4),
.operations-dashboard-table-row td:nth-child(5) {
grid-column: 1 / -1;
padding: 9px 10px;
border-radius: 7px;
background: #f4f6f3;
}
.operations-dashboard-table-row td:nth-child(4) {
grid-row: 3;
}
.operations-dashboard-table-row td:nth-child(5) {
grid-row: 4;
background: #eaf3ef;
}
.operations-dashboard-table-row td:nth-child(6) {
grid-column: 2;
grid-row: 1;
align-self: start;
justify-self: end;
}
.operations-dashboard-table-row td:nth-child(6)::before {
display: none;
}
.operations-dashboard-table-type {
color: var(--dashboard-ink-soft);
white-space: normal;
}
.operations-dashboard-table-copy > span {
font-size: 12px;
-webkit-line-clamp: 3;
}
.operations-dashboard-table-empty {
display: block;
}
.operations-dashboard-table-empty td {
display: block;
padding: 18px 12px;
border: 1px solid var(--dashboard-line);
border-radius: 9px;
background: #ffffff;
}
.operations-dashboard-table-empty td::before {
display: none;
}
.operations-dashboard-pagination {
align-items: center;
}
.operations-dashboard-pagination .history-pagination-actions {
flex: 1 1 auto;
justify-content: flex-end;
}
.operations-dashboard-pagination button {
min-height: 44px;
}
.operations-dashboard-detail {
inset: calc(8px + env(safe-area-inset-top)) calc(8px + env(safe-area-inset-right)) calc(8px + env(safe-area-inset-bottom)) calc(8px + env(safe-area-inset-left));
width: auto;
gap: 12px;
padding: 14px;
border-radius: 12px;
box-shadow: 0 18px 64px rgba(23, 55, 47, 0.28);
}
.operations-dashboard-detail-head {
align-items: center;
gap: 10px;
padding-bottom: 12px;
}
.operations-dashboard-detail-head h2 {
font-size: 18px;
}
.operations-dashboard-detail-head button {
min-height: 44px;
}
.operations-dashboard-detail-body {
padding: 0 2px calc(4px + env(safe-area-inset-bottom)) 0;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.operations-dashboard-detail-facts {
grid-template-columns: minmax(0, 1fr);
}
}
.history-page .workbench-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
}

View File

@@ -693,7 +693,6 @@ const OPERATIONS_FAILED_SQL = `t.status IN (
const OPERATIONS_ACTIVE_SQL = `t.archived_at IS NULL
AND t.status <> 'cancelled'
AND NOT (${OPERATIONS_COMPLETED_SQL})
AND NOT (${OPERATIONS_ATTENTION_SQL})
AND NOT (${OPERATIONS_FAILED_SQL})`;
const OPERATIONS_COMPLETED_STATUSES = new Set(['completed', 'dry_run']);

View File

@@ -1225,8 +1225,8 @@ test('operator page has a login gate and uses the durable task API', async () =>
const inpage = await readFile(new URL('../../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8');
assert.match(index, /id="loginPanel"/);
assert.match(index, /id="workbench"[^>]*hidden/);
assert.match(index, /styles\.css\?v=20260902-dashboard-nice-scale-1/);
assert.match(index, /app\.js\?v=20260902-dashboard-nice-scale-1/);
assert.match(index, /styles\.css\?v=20260902-dashboard-mobile-share-1/);
assert.match(index, /app\.js\?v=20260902-dashboard-mobile-share-1/);
assert.match(index, /id="statusDetailsPopover"/);
assert.match(index, /id="statusDetailsRefresh"/);
assert.match(app, /apiRequest\(`\/api\/tasks\?\$\{params\.toString\(\)\}`/);