feat: adapt leadership dashboard for mobile
This commit is contained in:
@@ -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.
|
||||
61
LianSyn-platform/app-operations-dashboard.test.mjs
Normal file
61
LianSyn-platform/app-operations-dashboard.test.mjs
Normal 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'\)/);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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\(\)\}`/);
|
||||
|
||||
Reference in New Issue
Block a user