From ed46812dcdf625b0672462bbf7e1f7f1a03a7f8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A4=80=EC=84=B1=EC=9D=98=20Macbook=20Pro?= Date: Wed, 16 Sep 2026 14:30:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EB=A5=BC=20=EC=9E=90=EB=A6=AC(=ED=94=84?= =?UTF-8?q?=EB=A6=AC=EC=85=8B)=EB=B3=84=EB=A1=9C=20=C2=B7=20=EC=83=9D?= =?UTF-8?q?=EC=82=B0=EA=B4=80=EB=A6=AC=C2=B7=EA=B2=BD=EC=98=81=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=ED=8C=90=20=EC=B6=94=EA=B0=80=20=C2=B7=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EC=9D=B4=EB=A6=84=20=EB=9D=84=EC=96=B4=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 누가 어느 마이페이지를 보는가(지시) · **마스터** — 전부. 위에서 골라 가며 본다(자리마다 무엇이 보이는지 확인해야 하니까) · **그 외** — 프리셋이 **지정된 경우에만** 그 자리 하나 · 미지정이면 마이페이지 자체가 없다 — 탭도 안 뜬다 전에는 `order.view` 만 있으면 누구에게나 영업관리 판이 떴다. 그래서 고객 탭이 감춰진 자리(PM·생산관리·경영관리)에도 **고객 검색 카드가 떴다** — 화면에서 버튼을 감추는 것만으로는 막은 것이 아니라, 서버에서도 막는다(아래). PM 은 아직 판을 안 만들었다. 빈 판을 띄우느니 탭을 안 보이는 편이 낫다. ## 새 판 둘 · **생산관리** — 주문 진행 현황 · 배정 대기 · 납기 경과·임박 · 일정 「배정 대기」는 영업이 요청했고 특장사를 아직 안 정한 건이다. 영업관리의 「배정 요청 대기」와 헷갈리면 안 된다 — 그쪽은 기다리는 자리, 이쪽은 누르는 자리다 · **경영관리** — 영업 성과 · 주문 진행 현황 · 일정. 손대는 카드(배정·고객 서류)는 넣지 않는다 카드마다 권한을 한 번 더 본다. 프리셋 구성은 기능모듈 화면에서 고칠 수 있어 「자리에 딸린 카드」와 실제 권한이 갈릴 수 있다. 쓰지 않을 데이터는 부르지도 않는다. ## 고객 서류함 — 서버에서도 막는다 `customer.view` 없이 **남의 고객**을 보던 구멍을 닫았다(목록·폴더·내려받기 셋 다). 영업은 자기 고객만 보므로 그대로 통과한다 — 범위를 자기 것으로 좁히는 것이 곧 허가다. ## 띄어쓰기 통일(지시) 상담 고객 · 견적 완료 · 계약 완료 · 배정 완료 · 주문 진행 · 인도 완료 · 부가 작업. 따옴표로 온전히 감싼 낱말만 바꿔, 긴 문장과 DB 에서 오는 모듈 이름은 건드리지 않았다. 띄어쓰기를 맞추며 겹친 사전 열쇠 둘(주문 진행·인도 완료)은 하나로 합쳤다. Co-Authored-By: Claude Opus 5 --- backend/src/__tests__/my-page-access.test.ts | 63 ++++++ backend/src/routes/customer-folders.ts | 22 +- frontend/src/components/CustomerFolders.tsx | 4 +- frontend/src/components/OrderDashboard.tsx | 2 +- frontend/src/components/OrderDetail.tsx | 2 +- frontend/src/components/OrderRemoveModal.tsx | 2 +- .../src/components/PaperContractModal.tsx | 2 +- frontend/src/components/QuoteStatusTip.tsx | 8 +- frontend/src/components/SalesPerformance.tsx | 18 +- .../components/dashboard/AdminDashboard.tsx | 212 ++++++++++++++++-- frontend/src/i18n/en.ts | 19 +- frontend/src/lib/salesFunnel.ts | 12 +- frontend/src/pages/AdminPage.tsx | 27 ++- frontend/src/pages/SalesPage.tsx | 4 +- 14 files changed, 336 insertions(+), 61 deletions(-) create mode 100644 backend/src/__tests__/my-page-access.test.ts diff --git a/backend/src/__tests__/my-page-access.test.ts b/backend/src/__tests__/my-page-access.test.ts new file mode 100644 index 00000000..02c26a37 --- /dev/null +++ b/backend/src/__tests__/my-page-access.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +/** + * **마이페이지는 자리(프리셋)에 딸린다**(2026-09-16 지시). + * + * · 마스터는 전부 본다 + * · 그 외에는 **프리셋이 지정된 경우에만** 그 자리 하나 + * · 미지정이면 마이페이지 자체가 없다 + * + * 화면 규칙이라 서버 시험으로는 못 부른다 — 규칙을 **한 함수에 모아 두고** 그 함수를 여기서 본다. + * 화면에서 버튼을 감추는 것만으로는 막은 것이 아니므로, **고객 서류함은 서버에서도** 막는다. + */ +const ROOT = path.resolve(__dirname, '../../..'); +const read = (rel: string) => readFileSync(path.join(ROOT, rel), 'utf8'); +const SRC = read('frontend/src/components/dashboard/AdminDashboard.tsx'); + +/** 화면 파일에서 `dashboardsFor` 를 그대로 떼어 와 돌린다 — 규칙이 바뀌면 여기가 먼저 깨진다 */ +function dashboardsFor(user: { is_master?: boolean; admin_preset?: string | null } | undefined): { code: string }[] { + const codes = [...SRC.matchAll(/\{ code: '([a-z_]+)', label:/g)].map(m => ({ code: m[1]! })); + expect(codes.length, '자리 목록을 읽지 못했다 — 정규식이 코드와 어긋났다').toBeGreaterThan(0); + if (!user) return []; + if (user.is_master) return codes; + return codes.filter(d => d.code === user.admin_preset); +} + +describe('마이페이지는 누가 보는가', () => { + it('🔴 마스터는 모든 자리의 마이페이지를 본다', () => { + const got = dashboardsFor({ is_master: true }).map(d => d.code); + expect(got, '생산관리가 빠졌다').toContain('prod_mgr'); + expect(got, '경영관리가 빠졌다').toContain('exec'); + expect(got, '영업관리가 빠졌다').toContain('sales_mgr'); + }); + + it('🔴 프리셋이 지정된 계정은 그 자리 하나만 본다', () => { + for (const code of ['sales_mgr', 'prod_mgr', 'exec']) { + expect(dashboardsFor({ admin_preset: code }).map(d => d.code)).toEqual([code]); + } + }); + + it('🔴 프리셋 미지정이면 마이페이지가 없다 — 탭도 뜨지 않는다', () => { + expect(dashboardsFor({ admin_preset: null })).toEqual([]); + expect(dashboardsFor({})).toEqual([]); + expect(dashboardsFor(undefined)).toEqual([]); + // 탭 자체가 이 함수로 가려진다 + expect(read('frontend/src/pages/AdminPage.tsx')).toMatch(/hasHome = dashboardsFor\(/); + expect(read('frontend/src/pages/AdminPage.tsx')).toMatch(/key: 'home',\s*label: t\('마이페이지'\), show: perm\.orders && hasHome/); + }); + + it('🔴 아직 만들지 않은 자리(PM)는 빈 판을 띄우지 않는다', () => { + expect(dashboardsFor({ admin_preset: 'pm' })).toEqual([]); + }); + + it('🔴 고객 서류함은 서버에서도 막는다 — 화면에서 감추는 것은 막은 것이 아니다', () => { + const src = read('backend/src/routes/customer-folders.ts'); + expect(src, '남의 고객까지 보는 데 customer.view 를 안 본다').toMatch(/hasPermission\(req, 'customer\.view'\)/); + // 목록·폴더·내려받기 **셋 다** 거쳐야 한다. 하나만 빠져도 주소로 열린다 + expect([...src.matchAll(/if \(!await maySeeOthers\(req, res\)\) return;/g)].length).toBe(3); + // 영업은 자기 고객만 보므로 권한 없이 통과해야 한다 + expect(src).toMatch(/if \(mine\) return true;/); + }); +}); diff --git a/backend/src/routes/customer-folders.ts b/backend/src/routes/customer-folders.ts index da6f3df0..c13571ca 100644 --- a/backend/src/routes/customer-folders.ts +++ b/backend/src/routes/customer-folders.ts @@ -13,7 +13,7 @@ import { noStore } from '../lib/doc-headers.js'; import { createReadStream } from 'node:fs'; import { stat } from 'node:fs/promises'; import { prisma } from '../lib/prisma.js'; -import { rbac, ownQuotesOnly, scopedToMine } from '../middleware/rbac.js'; +import { rbac, ownQuotesOnly, scopedToMine, hasPermission } from '../middleware/rbac.js'; import { VISIBLE } from '../lib/visibility.js'; import { groupCustomers, collectDocs, resolveDocId, optionChips, groupDocsByQuote, folderQuoteKind, @@ -34,6 +34,23 @@ function guard(fn: (req: Request, res: Response) => Promise) { }; } +/** + * **남의 고객까지 보려면 `customer.view` 가 있어야 한다**(2026-09-16). + * + * 영업은 자기 고객만 보므로 이 권한이 필요 없다 — 범위를 자기 것으로 좁히는 것이 곧 허가다. + * 관리자는 다르다. 예전에는 역할(ADMIN)만 보고 열어 줘서, **고객 탭이 감춰진 자리** + * (PM·생산관리·경영관리)도 주소만 알면 남의 고객 서류함을 열 수 있었다 — + * 화면에서 버튼을 감추는 것은 막은 것이 아니다. + */ +async function maySeeOthers(req: Request, res: Response): Promise { + const auth = req.auth!; + const mine = ownQuotesOnly(auth) || scopedToMine(auth, (req.query as { scope?: unknown }).scope); + if (mine) return true; + if (await hasPermission(req, 'customer.view')) return true; + res.status(403).json({ error: { code: 'FORBIDDEN', message: '고객 서류함을 볼 권한이 없습니다.' } }); + return false; +} + /** * **내가 볼 수 있는 견적**의 조건 — 견적 목록(`GET /quotes`)과 **글자 그대로 같아야 한다.** * @@ -102,6 +119,7 @@ async function lastActivity(req: Request, g: CustomerGroup): Promise { // ── GET /customer-folders — 폴더 목록(최근 변경 순) ───────────────────────── customerFoldersRouter.get('/', rbac('ADMIN', 'SALES'), guard(async (req, res) => { + if (!await maySeeOthers(req, res)) return; if (!prisma) { res.status(503).json({ error: { code: 'DB_UNAVAILABLE', message: 'DB 연결이 필요합니다' } }); return; } const { groups, quoteCount } = await visibleGroups(req); @@ -123,6 +141,7 @@ customerFoldersRouter.get('/', rbac('ADMIN', 'SALES'), guard(async (req, res) => // ── GET /customer-folders/:key — 폴더 안 ─────────────────────────────────── customerFoldersRouter.get('/:key', rbac('ADMIN', 'SALES'), guard(async (req, res) => { + if (!await maySeeOthers(req, res)) return; if (!prisma) { res.status(503).json({ error: { code: 'DB_UNAVAILABLE', message: 'DB 연결이 필요합니다' } }); return; } const key = Number(req.params['key']); if (!Number.isInteger(key)) { res.status(400).json({ error: { code: 'BAD_INPUT', message: '잘못된 고객 번호입니다' } }); return; } @@ -200,6 +219,7 @@ customerFoldersRouter.get('/:key', rbac('ADMIN', 'SALES'), guard(async (req, res // ── GET /customer-folders/:key/file/:docId — 열기·내려받기 ───────────────── customerFoldersRouter.get('/:key/file/:docId', rbac('ADMIN', 'SALES'), guard(async (req, res) => { + if (!await maySeeOthers(req, res)) return; if (!prisma) { res.status(503).json({ error: { code: 'DB_UNAVAILABLE', message: 'DB 연결이 필요합니다' } }); return; } const key = Number(req.params['key']); if (!Number.isInteger(key)) { res.status(400).json({ error: { code: 'BAD_INPUT', message: '잘못된 고객 번호입니다' } }); return; } diff --git a/frontend/src/components/CustomerFolders.tsx b/frontend/src/components/CustomerFolders.tsx index 44f379ec..aaed54d1 100644 --- a/frontend/src/components/CustomerFolders.tsx +++ b/frontend/src/components/CustomerFolders.tsx @@ -18,8 +18,8 @@ import { DocLink } from './DocLink' * 어디를 보고 있었는지 잃는다. */ const STATUS_KO: Record = { - draft: '임시저장', confirmed: '견적완료', contracted: '계약완료', - assigned: '배정완료', ordered: '주문진행', completed: '완료', expired: '만료', + draft: '임시저장', confirmed: '견적 완료', contracted: '계약 완료', + assigned: '배정 완료', ordered: '주문 진행', completed: '완료', expired: '만료', } export function CustomerFolders({ mine, initialQuery = '', initialOpenKey = null }: { diff --git a/frontend/src/components/OrderDashboard.tsx b/frontend/src/components/OrderDashboard.tsx index 6cc4af4d..f4df09d0 100644 --- a/frontend/src/components/OrderDashboard.tsx +++ b/frontend/src/components/OrderDashboard.tsx @@ -47,7 +47,7 @@ const TILES: { key: TileKey; label: string; icon: IconName }[] = [ { key: 'assign', label: '배정 대기', icon: 'hourglass' }, { key: 'pending', label: '수락 대기', icon: 'inbox' }, { key: 'active', label: '특장 진행', icon: 'progress' }, - { key: 'addon', label: '부가작업', icon: 'tool' }, + { key: 'addon', label: '부가 작업', icon: 'tool' }, { key: 'done', label: '인도 완료', icon: 'check' }, { key: 'late', label: '납기일 경과', icon: 'alert' }, ] diff --git a/frontend/src/components/OrderDetail.tsx b/frontend/src/components/OrderDetail.tsx index ec7e1631..3b386114 100644 --- a/frontend/src/components/OrderDetail.tsx +++ b/frontend/src/components/OrderDetail.tsx @@ -595,7 +595,7 @@ export function OrderDetail({ orderId, onBack, backLabel = t('배정 주문'), m {/* 부가작업 — 공장 출고 뒤 우리 쪽 작업(관리자 + addon.manage) */} {canAddon && ( )}