feat: 워크스페이스 편의 개선 + 매뉴얼에 화면구성·단축키 페이지 추가
워크스페이스(탭) - 탭 전환 시 좌측 사이드바(대메뉴/소메뉴) 강조 자동 동기화 - nav 스크립트에 window.govPortalNav.syncByUrl() 공개, renderSidebar(overrideHref) 확장 - 키보드 단축키(Alt 기반): Alt+1~9 탭 이동, Alt+W 닫기, Alt+[ / Alt+] 이전·다음 - iframe 내부 포커스에서도 동작하도록 같은 출처 문서에 핸들러 부착 - 탭 가운데(휠) 클릭으로 닫기, 잘린 탭 제목 전체 툴팁 매뉴얼 - 신규 페이지 [화면 구성·워크스페이스·단축키] (05_workspace.md, 목차 2번째) - 화면 구성, 탭 사용법·유지 범위, 단축키 표, 이동/도움말 안내 - 개요 페이지에서 새 페이지로 안내 e2e: 워크스페이스(사이드바 동기화·가운데클릭) + 매뉴얼(새 페이지·단축키·검색) 케이스 추가 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ class Manual extends BaseConfig
|
||||
*/
|
||||
public array $pages = [
|
||||
'overview' => ['title' => '시작하기·시스템 개요', 'file' => '00_overview.md'],
|
||||
'workspace' => ['title' => '화면 구성·워크스페이스·단축키', 'file' => '05_workspace.md'],
|
||||
'flow' => ['title' => '핵심 업무 흐름', 'file' => '10_workflow.md'],
|
||||
'order' => ['title' => '발주·입고', 'file' => '20_order_receiving.md'],
|
||||
'inventory' => ['title' => '재고·실사', 'file' => '30_inventory.md'],
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
- 메뉴를 클릭하면 **탭으로 열립니다.** 여러 화면을 열어두고 전환해도 입력하던 내용이 유지됩니다.
|
||||
- 각 화면에서 **"이 화면 설명"(❓) 버튼**을 누르면, 그 화면에 해당하는 매뉴얼이 새 탭으로 열립니다.
|
||||
|
||||
> 탭 사용법, 탭별 새로고침, **키보드 단축키**(Alt+1~9 / Alt+W / Alt+[ / Alt+]) 등 자세한 내용은 좌측 목차 **[화면 구성·워크스페이스·단축키]** 를 참고하세요.
|
||||
|
||||
## 4. 사용자 역할(권한)
|
||||
|
||||
| 역할 | 할 수 있는 일 |
|
||||
|
||||
64
app/Docs/manual/05_workspace.md
Normal file
64
app/Docs/manual/05_workspace.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 화면 구성 · 워크스페이스 · 단축키
|
||||
|
||||
이 시스템은 **여러 작업 화면을 탭으로 열어 두고 오가며** 일할 수 있도록 만들어졌습니다(웹 브라우저의 탭과 비슷합니다). 이 페이지는 화면이 어떻게 구성되는지, 탭을 어떻게 쓰는지, 그리고 빠르게 쓰는 단축키를 설명합니다.
|
||||
|
||||
## 1. 전체 화면 구성
|
||||
|
||||
로그인하면 **워크스페이스**(탭 작업공간)가 기본으로 열립니다. 화면은 크게 네 부분입니다.
|
||||
|
||||
| 영역 | 위치 | 설명 |
|
||||
|---|---|---|
|
||||
| **상단 헤더** | 맨 위 | 로고, **대분류 메뉴**, 오른쪽에 소속 지자체·접속자·관리자·로그아웃 |
|
||||
| **왼쪽 사이드바** | 좌측 | 현재 선택한 대분류의 **소메뉴 목록**. 아래에는 매뉴얼·FAQ 링크 |
|
||||
| **탭바** | 본문 위 | 지금 열어 둔 작업 화면들의 **탭 목록** |
|
||||
| **본문** | 가운데 | 현재 탭의 실제 작업 화면 |
|
||||
|
||||
> **대분류를 클릭하면** 왼쪽 사이드바에 그 안의 소메뉴가 펼쳐집니다. 현재 위치한 메뉴는 왼쪽에서 **▸** 로 표시됩니다.
|
||||
|
||||
## 2. 탭(워크스페이스) 사용법
|
||||
|
||||
- **메뉴를 클릭하면 탭으로 열립니다.** 같은 메뉴를 다시 누르면 새 탭을 또 만들지 않고 **이미 열린 탭으로 이동**합니다.
|
||||
- **탭을 전환해도 작업 내용이 유지됩니다.** A 화면에 무언가 입력하다가 B 화면을 잠깐 보고 와도, A 탭의 입력은 그대로 남아 있습니다.
|
||||
- **탭 ↔ 왼쪽 메뉴 연동:** 탭을 전환하면 왼쪽 사이드바의 강조 위치도 그 탭의 메뉴로 **자동으로 따라갑니다.**
|
||||
- 탭이 많아지면 가로로 스크롤되며, **최대 12개**까지 열 수 있습니다. 12개를 넘기면 가장 오래된 탭이 자동으로 닫힙니다.
|
||||
|
||||
### 탭의 버튼
|
||||
|
||||
| 버튼 | 동작 |
|
||||
|---|---|
|
||||
| **↻** (탭 위) | 그 **탭만** 새로고침합니다. 다른 탭은 그대로 둡니다. |
|
||||
| **×** (탭 위) | 그 탭을 닫습니다. |
|
||||
| 탭을 **가운데(휠) 클릭** | 마우스 휠을 누르면 그 탭이 닫힙니다(브라우저와 동일). |
|
||||
| 탭에 **마우스를 올리면** | 이름이 길어 잘렸을 때 **전체 제목**이 말풍선으로 보입니다. |
|
||||
|
||||
### 탭이 유지되는 범위
|
||||
|
||||
- **브라우저를 새로고침**하거나 **관리자 페이지에 갔다가 돌아와도** 열어 두었던 탭이 **다시 복원**됩니다.
|
||||
- 단, **브라우저 탭(창)을 완전히 닫으면** 작업공간은 초기화됩니다. (이 유지는 "이번 접속 동안"만 적용됩니다.)
|
||||
- 복원되는 것은 **열려 있던 화면 목록**입니다. 관리자 페이지를 거치는 등 작업공간을 완전히 벗어났던 경우, 각 화면은 새로 불러와지므로 **입력 중이던 폼 내용까지 그대로 살아나지는 않습니다.**
|
||||
|
||||
## 3. 키보드 단축키
|
||||
|
||||
자주 쓰는 동작은 키보드로 더 빠르게 할 수 있습니다. 브라우저 기본 단축키와 겹치지 않도록 **Alt 키**를 함께 누르는 방식입니다.
|
||||
|
||||
| 단축키 | 동작 |
|
||||
|---|---|
|
||||
| **Alt + 1 ~ 9** | 왼쪽에서 **n번째 탭**으로 이동 |
|
||||
| **Alt + W** | **현재 탭 닫기** |
|
||||
| **Alt + ]** | **다음 탭**으로 이동 |
|
||||
| **Alt + [** | **이전 탭**으로 이동 |
|
||||
|
||||
> macOS 에서도 동일하게 **Option(⌥)** 키가 Alt 역할을 합니다 (예: ⌥ + 1).
|
||||
>
|
||||
> 참고: `Ctrl/⌘ + W`, `Ctrl/⌘ + 숫자`, `Ctrl + Tab` 은 **브라우저 자체가 먼저 가로채기** 때문에 이 시스템에서 다른 용도로 바꿀 수 없어, 위와 같이 Alt 조합을 사용합니다.
|
||||
|
||||
## 4. 그 밖의 이동
|
||||
|
||||
- **관리자** 버튼(헤더 오른쪽, 관리자 권한일 때) — 메뉴·코드·판매소 등 **관리자 설정 화면**으로 이동합니다. 갔다가 워크스페이스로 돌아오면 열어 두었던 탭이 복원됩니다.
|
||||
- **지자체 선택**(왼쪽 사이드바 아래) — 슈퍼 관리자가 **작업할 지자체를 바꿀 때** 사용합니다.
|
||||
- **로그아웃**(헤더 오른쪽) — 시스템에서 나갑니다.
|
||||
|
||||
## 5. 도움말 보는 법
|
||||
|
||||
- 각 작업 화면의 **"이 화면 설명"(❓) 버튼** — 지금 보고 있는 화면에 해당하는 매뉴얼이 새 탭으로 열립니다.
|
||||
- 이 매뉴얼 왼쪽 위 **검색창** — 모든 매뉴얼 페이지에서 단어를 찾아, 결과를 누르면 해당 페이지의 그 단어 위치로 이동해 **노란색으로 표시**해 줍니다.
|
||||
@@ -124,6 +124,8 @@ if ($effectiveLgIdx) {
|
||||
tabs[k].btn.classList.toggle('active', k === id);
|
||||
});
|
||||
if (empty) empty.style.display = 'none';
|
||||
// 현재 탭에 맞춰 좌측 사이드바(대메뉴/소메뉴) 강조 동기화
|
||||
try { if (window.govPortalNav && tabs[id]) window.govPortalNav.syncByUrl(tabs[id].url); } catch (e) {}
|
||||
persist();
|
||||
}
|
||||
|
||||
@@ -155,11 +157,16 @@ if ($effectiveLgIdx) {
|
||||
frame.className = 'ws-frame';
|
||||
frame.src = withEmbed(url);
|
||||
frame.setAttribute('title', title || '탭');
|
||||
// iframe 내부에 포커스가 있어도 단축키가 동작하도록 같은 출처 문서에 핸들러 부착
|
||||
frame.addEventListener('load', function () {
|
||||
try { frame.contentDocument.addEventListener('keydown', handleShortcut); } catch (e) {}
|
||||
});
|
||||
panels.appendChild(frame);
|
||||
|
||||
var btn = document.createElement('div');
|
||||
btn.className = 'ws-tab';
|
||||
btn.setAttribute('role', 'tab');
|
||||
btn.title = title || '탭'; // 잘린 이름 전체를 툴팁으로
|
||||
var nameSpan = document.createElement('span');
|
||||
nameSpan.className = 't-name';
|
||||
nameSpan.textContent = title || '탭';
|
||||
@@ -177,6 +184,9 @@ if ($effectiveLgIdx) {
|
||||
else if (e.target === refresh) { activate(id); reloadTab(id); }
|
||||
else { activate(id); }
|
||||
});
|
||||
// 가운데(휠) 클릭으로 탭 닫기 — 브라우저 탭과 동일한 동작
|
||||
btn.addEventListener('mousedown', function (e) { if (e.button === 1) e.preventDefault(); });
|
||||
btn.addEventListener('auxclick', function (e) { if (e.button === 1) { e.preventDefault(); closeTab(id); } });
|
||||
bar.appendChild(btn);
|
||||
|
||||
tabs[id] = { url: url, title: title, frame: frame, btn: btn };
|
||||
@@ -185,6 +195,26 @@ if ($effectiveLgIdx) {
|
||||
}
|
||||
window.wsOpenTab = openTab;
|
||||
|
||||
// 키보드 단축키 (브라우저 기본 단축키와 충돌하지 않도록 Alt 기반)
|
||||
// Alt+1~9: 해당 번호 탭으로 · Alt+W: 현재 탭 닫기 · Alt+[ / Alt+]: 이전/다음 탭
|
||||
function handleShortcut(e) {
|
||||
if (!e.altKey || e.ctrlKey || e.metaKey) return;
|
||||
var k = e.key;
|
||||
if (k >= '1' && k <= '9') {
|
||||
var i = parseInt(k, 10) - 1;
|
||||
if (order[i]) { e.preventDefault(); activate(order[i]); }
|
||||
} else if (k === 'w' || k === 'W' || k === 'ㅈ') {
|
||||
if (activeId) { e.preventDefault(); closeTab(activeId); }
|
||||
} else if (k === '[' || k === ']') {
|
||||
if (!activeId || order.length < 2) return;
|
||||
e.preventDefault();
|
||||
var cur = order.indexOf(activeId);
|
||||
var nx = k === ']' ? (cur + 1) % order.length : (cur - 1 + order.length) % order.length;
|
||||
activate(order[nx]);
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleShortcut);
|
||||
|
||||
// 좌측 사이드바 소메뉴 클릭 → 탭으로 열기 (전체 페이지 이동 대신)
|
||||
document.querySelector('.sidebar').addEventListener('click', function (e) {
|
||||
var a = e.target.closest('a[href]');
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
var titleEl = document.getElementById('portalSidebarTitle');
|
||||
|
||||
if (listEl && navData.length) {
|
||||
function renderSidebar(idx) {
|
||||
function renderSidebar(idx, overrideHref) {
|
||||
var parent = navData[idx];
|
||||
if (!parent) return;
|
||||
if (titleEl) titleEl.textContent = parent.name || 'MY MENU';
|
||||
@@ -22,10 +22,13 @@
|
||||
listEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
// overrideHref 가 빈 문자열이면 "이 그룹에 일치 항목 없음" → 첫 항목 강조하지 않음
|
||||
var hasOverride = (typeof overrideHref === 'string');
|
||||
var activeHref = hasOverride ? overrideHref : activeChildHref;
|
||||
items.forEach(function (child, ci) {
|
||||
var li = document.createElement('li');
|
||||
var chHref = (child.href || '').toLowerCase().replace(/^\//, '');
|
||||
var on = activeChildHref ? (chHref === activeChildHref) : (ci === 0);
|
||||
var on = activeHref ? (chHref === activeHref) : (hasOverride ? false : ci === 0);
|
||||
if (child.href) {
|
||||
li.innerHTML = '<a href="' + child.url + '" class="' + (on ? 'active' : '') + '">' +
|
||||
'<span class="menu-ico">' + (on ? '▸' : '·') + '</span>' + child.name + '</a>';
|
||||
@@ -58,6 +61,30 @@
|
||||
|
||||
setActiveTrigger(activeIdx);
|
||||
renderSidebar(activeIdx);
|
||||
|
||||
// 워크스페이스 등 외부에서 "현재 보는 화면(URL)"에 맞춰 사이드바를 동기화하기 위한 공개 API
|
||||
function pathOf(u) {
|
||||
try { var a = new URL(u, location.origin); return (a.pathname + a.search).toLowerCase(); }
|
||||
catch (e) { return (u || '').toLowerCase(); }
|
||||
}
|
||||
window.govPortalNav = {
|
||||
// URL 로 소속 대메뉴/소메뉴를 찾아 사이드바 강조를 갱신. 일치 없으면 false.
|
||||
syncByUrl: function (url) {
|
||||
var target = pathOf(url);
|
||||
for (var p = 0; p < navData.length; p++) {
|
||||
var par = navData[p];
|
||||
var kids = (par.children && par.children.length) ? par.children : (par.href ? [par] : []);
|
||||
for (var i = 0; i < kids.length; i++) {
|
||||
if (kids[i].url && pathOf(kids[i].url) === target) {
|
||||
setActiveTrigger(p);
|
||||
renderSidebar(p, (kids[i].href || '').toLowerCase().replace(/^\//, ''));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var searchInput = document.getElementById('menuSearch') || document.getElementById('menuSearchStrip');
|
||||
|
||||
@@ -33,6 +33,26 @@ test.describe('사용자 매뉴얼', () => {
|
||||
await expect(page.locator('.manual-prose')).toContainText('바코드');
|
||||
});
|
||||
|
||||
test('워크스페이스·단축키 페이지 렌더 + 단축키 표 노출', async ({ page }) => {
|
||||
await login(page, 'user');
|
||||
await page.goto('/bag/manual/workspace');
|
||||
await expect(page.locator('.manual-prose h1')).toContainText('워크스페이스');
|
||||
// 단축키 표 내용 확인
|
||||
await expect(page.locator('.manual-prose')).toContainText('Alt + W');
|
||||
await expect(page.locator('.manual-prose')).toContainText('다음 탭');
|
||||
// 목차에도 새 항목 노출
|
||||
await expect(page.locator('.manual-toc a', { hasText: '워크스페이스' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('매뉴얼 검색이 단축키 내용을 찾음', async ({ page }) => {
|
||||
await login(page, 'user');
|
||||
await page.goto('/bag/manual/overview');
|
||||
await page.locator('#manualSearchInput').fill('단축키');
|
||||
await page.waitForTimeout(700);
|
||||
await expect(page.locator('#manualSearchResults')).toBeVisible();
|
||||
await expect(page.locator('#manualSearchResults')).toContainText('워크스페이스');
|
||||
});
|
||||
|
||||
test('미등록 slug 는 404', async ({ page }) => {
|
||||
await login(page, 'user');
|
||||
const res = await page.goto('/bag/manual/does-not-exist');
|
||||
|
||||
@@ -66,4 +66,32 @@ test.describe('워크스페이스 탭', () => {
|
||||
await page.waitForTimeout(2500);
|
||||
await expect(page.locator('.ws-tab')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('편의: 가운데클릭 닫기·사이드바 동기화', async ({ page }) => {
|
||||
await login(page, 'admin');
|
||||
await page.goto('/admin/select-local-government');
|
||||
await page.evaluate(() => {
|
||||
const r = document.querySelector('input[name="lg_idx"][value="1"]');
|
||||
if (r) { r.checked = true; r.form.submit(); }
|
||||
});
|
||||
await page.waitForTimeout(700);
|
||||
|
||||
await page.goto('/workspace');
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
await expect(page.locator('.ws-tab')).toHaveCount(1);
|
||||
|
||||
// 소메뉴를 탭으로 열기 → 사이드바에서 해당 항목이 active 로 동기화됨
|
||||
const firstMenu = page.locator('.sidebar .my-menu-list a').first();
|
||||
const menuText = (await firstMenu.textContent() || '').trim();
|
||||
await firstMenu.click();
|
||||
await page.waitForTimeout(1500);
|
||||
await expect(page.locator('.ws-tab')).toHaveCount(2);
|
||||
await expect(page.locator('.sidebar .my-menu-list a.active')).toBeVisible();
|
||||
|
||||
// 두 번째 탭 가운데(휠) 클릭으로 닫기
|
||||
await page.locator('.ws-tab').nth(1).click({ button: 'middle' });
|
||||
await page.waitForTimeout(400);
|
||||
await expect(page.locator('.ws-tab')).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user