Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions backend/src/__tests__/my-page-access.test.ts
Original file line number Diff line number Diff line change
@@ -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;/);
});
});
22 changes: 21 additions & 1 deletion backend/src/routes/customer-folders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,6 +34,23 @@ function guard(fn: (req: Request, res: Response) => Promise<void>) {
};
}

/**
* **남의 고객까지 보려면 `customer.view` 가 있어야 한다**(2026-09-16).
*
* 영업은 자기 고객만 보므로 이 권한이 필요 없다 — 범위를 자기 것으로 좁히는 것이 곧 허가다.
* 관리자는 다르다. 예전에는 역할(ADMIN)만 보고 열어 줘서, **고객 탭이 감춰진 자리**
* (PM·생산관리·경영관리)도 주소만 알면 남의 고객 서류함을 열 수 있었다 —
* 화면에서 버튼을 감추는 것은 막은 것이 아니다.
*/
async function maySeeOthers(req: Request, res: Response): Promise<boolean> {
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`)과 **글자 그대로 같아야 한다.**
*
Expand Down Expand Up @@ -102,6 +119,7 @@ async function lastActivity(req: Request, g: CustomerGroup): Promise<Date> {

// ── 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);
Expand All @@ -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; }
Expand Down Expand Up @@ -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; }
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/CustomerFolders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import { DocLink } from './DocLink'
* 어디를 보고 있었는지 잃는다.
*/
const STATUS_KO: Record<string, string> = {
draft: '임시저장', confirmed: '견적완료', contracted: '계약완료',
assigned: '배정완료', ordered: '주문진행', completed: '완료', expired: '만료',
draft: '임시저장', confirmed: '견적 완료', contracted: '계약 완료',
assigned: '배정 완료', ordered: '주문 진행', completed: '완료', expired: '만료',
}

export function CustomerFolders({ mine, initialQuery = '', initialOpenKey = null }: {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/OrderDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
]
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/OrderDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ export function OrderDetail({ orderId, onBack, backLabel = t('배정 주문'), m
{/* 부가작업 — 공장 출고 뒤 우리 쪽 작업(관리자 + addon.manage) */}
{canAddon && (
<button style={tab === 'addon' ? det.tabActive : det.tabBtn} onClick={() => setTab('addon')}>
{t('부가작업')}
{t('부가 작업')}
</button>
)}
<button style={tab === 'spec' ? det.tabActive : det.tabBtn} onClick={() => setTab('spec')}>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/OrderRemoveModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function OrderRemoveModal({ orderId, onClose, onDone }: {
경계가 아예 달라진다. **문장을 통째로** 사전에 넣고 강조는 포기했다.
잃는 것은 굵은 글씨 둘, 얻는 것은 말이 되는 영어다.
*/}
<div style={s.desc}>{tf('목록에서 빠집니다. 견적은 {0}로 돌아가 다시 배정할 수 있고, 주문 기록과 그동안의 서류는 지워지지 않습니다.', t('계약완료'))}</div>
<div style={s.desc}>{tf('목록에서 빠집니다. 견적은 {0}로 돌아가 다시 배정할 수 있고, 주문 기록과 그동안의 서류는 지워지지 않습니다.', t('계약 완료'))}</div>
<label style={s.label}>{t('삭제 사유')}<span style={s.req}> {t('· 필수')}</span></label>
<textarea
style={s.input} rows={2} value={reason} maxLength={500}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/PaperContractModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function PaperContractModal({ label, loading, error, onSubmit, onClose }:
<div style={s.box} onClick={e => e.stopPropagation()}>
<div style={s.title}>{label} — 서명본 등록</div>
<div style={s.desc}>
{tf('종이로 체결한 계약서를 올립니다. 등록하면 전자서명 없이 {0}가 되어 제작 배정을 할 수 있습니다. 견적서·계약서는 이 시점의 내용으로 고정되어 더 이상 고칠 수 없습니다.', t('계약완료'))}
{tf('종이로 체결한 계약서를 올립니다. 등록하면 전자서명 없이 {0}가 되어 제작 배정을 할 수 있습니다. 견적서·계약서는 이 시점의 내용으로 고정되어 더 이상 고칠 수 없습니다.', t('계약 완료'))}
</div>
<label style={s.label}>{t('계약서 스캔본')}<span style={s.req}> {t('· 필수')}</span></label>
<input
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/QuoteStatusTip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import { t } from '../i18n'

export const QUOTE_STATUS_FLOW = [
{ key: 'draft', label: '임시저장', desc: '작성 중인 견적' },
{ key: 'confirmed', label: '견적완료', desc: '견적서 생성 완료' },
{ key: 'contracted', label: '계약완료', desc: '전자서명 완료' },
{ key: 'assigned', label: '배정완료', desc: '관리자가 특장사 배정' },
{ key: 'ordered', label: '주문진행', desc: '특장사 수락 · 제작 진행' },
{ key: 'confirmed', label: '견적 완료', desc: '견적서 생성 완료' },
{ key: 'contracted', label: '계약 완료', desc: '전자서명 완료' },
{ key: 'assigned', label: '배정 완료', desc: '관리자가 특장사 배정' },
{ key: 'ordered', label: '주문 진행', desc: '특장사 수락 · 제작 진행' },
{ key: 'completed', label: '완료', desc: '특장사 전 공정 완료' },
] as const

Expand Down
18 changes: 9 additions & 9 deletions frontend/src/components/SalesPerformance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,9 @@ export function SalesPerformance({ showUserFilter, userOptions = [] }: Props) {
<Funnel st={total} />
<div style={s.groups}>
<MetricGroup title={t('금액')}>
<Metric label={t('견적완료')} value={won(total.amount.confirmed)} />
<Metric label={t('계약완료')} value={won(total.amount.contracted)} />
<Metric label={t('인도완료')} value={won(total.amount.completed)} strong />
<Metric label={t('견적 완료')} value={won(total.amount.confirmed)} />
<Metric label={t('계약 완료')} value={won(total.amount.contracted)} />
<Metric label={t('인도 완료')} value={won(total.amount.completed)} strong />
</MetricGroup>
</div>
</section>
Expand All @@ -182,9 +182,9 @@ export function SalesPerformance({ showUserFilter, userOptions = [] }: Props) {

<div style={s.groups}>
<MetricGroup title={t('금액')}>
<Metric label={t('견적완료')} value={won(st.amount.confirmed)} />
<Metric label={t('계약완료')} value={won(st.amount.contracted)} />
<Metric label={t('인도완료')} value={won(st.amount.completed)} strong />
<Metric label={t('견적 완료')} value={won(st.amount.confirmed)} />
<Metric label={t('계약 완료')} value={won(st.amount.contracted)} />
<Metric label={t('인도 완료')} value={won(st.amount.completed)} strong />
</MetricGroup>

<MetricGroup title={t('활동')}>
Expand All @@ -202,9 +202,9 @@ export function SalesPerformance({ showUserFilter, userOptions = [] }: Props) {
</MetricGroup>

<MetricGroup title={t('소요')}>
<Lead label={t('견적완료')} v={st.lead.to_confirmed} />
<Lead label={t('계약완료')} v={st.lead.to_contracted} />
<Lead label={t('인도완료')} v={st.lead.to_completed} />
<Lead label={t('견적 완료')} v={st.lead.to_confirmed} />
<Lead label={t('계약 완료')} v={st.lead.to_contracted} />
<Lead label={t('인도 완료')} v={st.lead.to_completed} />
</MetricGroup>
</div>
</section>
Expand Down
Loading
Loading