diff --git a/backend/src/__tests__/dev-auto-login.test.ts b/backend/src/__tests__/dev-auto-login.test.ts new file mode 100644 index 0000000..27652df --- /dev/null +++ b/backend/src/__tests__/dev-auto-login.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, afterAll } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +/** + * **로컬 자동 로그인이 운영으로 새지 않는가.** + * + * 비밀번호 없이 들어오는 길을 하나 냈다(로컬 개발용). 이런 길은 **조용히 열린 채로 배포되는 것**이 + * 유일한 실패 방식이다 — 화면에 아무 표시도 없고, 오류도 안 나고, 눈으로는 절대 안 보인다. + * 그래서 막는 겹을 하나하나 못 박는다. + * + * ① 운영에서는 **라우트를 아예 만들지 않는다**(권한 검사를 끄는 것이 아니다) + * ② 그래도 붙었다면 **이 기계에서 온 요청만** 받는다 + * ③ 배포가 실제로 `NODE_ENV=production` 으로 띄운다 — ①이 걸리려면 이게 참이어야 한다 + * ④ 화면 쪽 코드는 개발 빌드에만 들어간다 + */ +const ROOT = path.resolve(__dirname, '../../..'); +const read = (rel: string) => readFileSync(path.join(ROOT, rel), 'utf8'); + +const before = process.env['NODE_ENV']; +afterAll(() => { + if (before === undefined) delete process.env['NODE_ENV']; + else process.env['NODE_ENV'] = before; +}); + +describe('로컬 자동 로그인은 운영에 없다', () => { + it('🔴 NODE_ENV=production 이면 꺼진다', async () => { + const { devAutoLoginEnabled } = await import('../routes/dev-auth.js'); + process.env['NODE_ENV'] = 'production'; + expect(devAutoLoginEnabled(), '운영에서 자동 로그인이 켜져 있다').toBe(false); + for (const v of ['development', 'test', '']) { + process.env['NODE_ENV'] = v; + expect(devAutoLoginEnabled()).toBe(true); + } + delete process.env['NODE_ENV']; + expect(devAutoLoginEnabled(), '값이 없으면 개발로 본다').toBe(true); + }); + + it('🔴 운영으로 띄운 앱에는 /api/v1/dev/login 이 아예 없다', async () => { + process.env['NODE_ENV'] = 'production'; + const request = (await import('supertest')).default; + const { createApp } = await import('../app.js'); + const res = await request(createApp()).get('/api/v1/dev/login?email=master@local'); + expect(res.status, '운영 앱에서 자동 로그인 경로가 열렸다').toBe(404); + expect(res.headers['set-cookie'], '운영 앱이 세션 쿠키를 심었다').toBeUndefined(); + }); + + it('🔴 라우트가 붙어 있어도 밖에서 온 요청은 받지 않는다', () => { + const src = read('backend/src/routes/dev-auth.ts'); + expect(src, '로컬 확인이 사라졌다').toMatch(/127\.0\.0\.1/); + expect(src, '로컬이 아니면 404 로 덮어야 한다').toMatch(/res\.status\(404\)/); + // 두 조건을 **모두** 본다 — 운영이면서 로컬인 요청(서버에 들어가서 부르는 것)도 막는다 + expect(src).toMatch(/devAutoLoginEnabled\(\)\s*&&/); + }); + + it('🔴 배포는 NODE_ENV=production 으로 띄운다 — 이게 거짓이면 위 검사가 다 무의미하다', () => { + expect(read('deploy/remote-deploy.sh')).toMatch(/^NODE_ENV=production/m); + }); + + it('🔴 화면 쪽 자동 로그인은 개발 빌드에만 들어간다', () => { + const src = read('frontend/src/pages/LoginPage.tsx'); + // import.meta.env.DEV 는 운영 빌드에서 false 로 굳어 이 블록이 통째로 사라진다 + expect(src).toMatch(/if \(!import\.meta\.env\.DEV/); + expect(src, '호스트 확인까지 겹쳐 둔다').toMatch(/hostname !== 'localhost'/); + }); +}); diff --git a/backend/src/__tests__/order-dashboard.test.ts b/backend/src/__tests__/order-dashboard.test.ts index 3386c77..aa38ec7 100644 --- a/backend/src/__tests__/order-dashboard.test.ts +++ b/backend/src/__tests__/order-dashboard.test.ts @@ -206,8 +206,13 @@ describe.runIf(!!process.env['DATABASE_URL'] || true)('주문 목록 응답에 expect((await request(app).patch(`/api/v1/quotes/${q.id}/assign`).set('Cookie', admin).send({ maker_org_id: 'ORG_BRAIN' })).status).toBe(200); const o = await prisma!.order.findFirstOrThrow({ where: { quote_id: q.id } }); expect((await request(app).patch(`/api/v1/orders/${o.id}/accept`).set('Cookie', maker).send({ delivery_due: await businessDue() })).status).toBe(200); - const res = await request(app).get('/api/v1/orders').set('Cookie', admin); - const row = (res.body.data as ApiOrder[]).find(x => x.id === o.id)!; + // 전체 목록 조회라 다른 시험 파일이 같은 순간 자기 데이터를 지우면 드물게 500 이 난다 — 한 번 더 묻는다(운영에는 행 삭제가 없다) + let rows: ApiOrder[] | null = null; + for (let i = 0; i < 3 && !rows; i++) { + const res = await request(app).get('/api/v1/orders').set('Cookie', admin); + if (res.status === 200) rows = res.body.data as ApiOrder[]; + } + const row = rows!.find(x => x.id === o.id)!; expect(row.steps?.lanes?.vehicle?.code).toBe('car_arrived'); expect(row.steps?.lanes?.body?.code).toBe('build_started'); expect(row.steps?.lanes?.tuning).toBeNull(); diff --git a/backend/src/__tests__/sales-stats.test.ts b/backend/src/__tests__/sales-stats.test.ts new file mode 100644 index 0000000..7e76b3f --- /dev/null +++ b/backend/src/__tests__/sales-stats.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, afterAll } from 'vitest'; + +/** + * **영업 성과를 세는 규칙**(2026-09-16 전면 재정비). + * + * 세 가지가 실제로 틀려 있었고, 셋 다 화면에서는 그럴듯해 보였다. + * ① 배정을 취소해 계약완료로 되돌린 건이 **배정완료에 그대로 남았다**(제보) + * ② 한 고객에게 견적을 여러 번 내면 **고객이 여러 명으로** 세어졌다(제보) + * ③ 기간을 견적 **만든 날**로 걸러서, 8월에 만들어 9월에 계약한 건이 「9월 계약」에 없었다 + * + * 숫자는 틀려도 오류가 안 난다. 그래서 여기서 못 박는다. + */ +const { prisma } = await import('../lib/prisma.js'); +const { salesStats, salesStatsFull, attentionList } = await import('../services/sales-stats.js'); + +const live = !!prisma; +const MODEL = 'PV5_OPENBED'; +/** + * **시험마다 계정을 새로 만든다.** 한 계정을 나눠 쓰면 앞 시험이 만든 견적이 + * 뒤 시험의 합계에 섞여, 코드가 멀쩡해도 숫자가 안 맞는다(실제로 두 번 걸렸다). + */ +const USERS: string[] = []; +let seq = 0; +async function user(): Promise { + const email = `stats-${++seq}-${Date.now()}@example.invalid`; + USERS.push(email); + await prisma!.user.create({ + data: { email, name: '성과시험', role: 'SALES', extra_roles: [], org_code: 'ORG_HQ', active: true, status: 'active', password_hash: 'x' }, + }); + return email; +} +const quotes: number[] = []; +const customers: number[] = []; + +const day = (iso: string) => new Date(`${iso}T03:00:00Z`); // 한국 정오 — 경계 시비를 피한다 + +async function customer(name: string, phone?: string): Promise { + const c = await prisma!.customer.create({ data: { name, ...(phone ? { phone } : {}) } }); + customers.push(c.id); + return c.id; +} + +/** 견적 하나 — 상태·담당·고객·생성일과 전이 이력을 한 번에 심는다 */ +async function quote(o: { + user: string; customerId?: number; status: string; price?: number; createdAt?: Date; + /** 상태 전이 이력 — [바뀐 상태, 날짜] */ + log?: [string, Date][]; + hidden?: boolean; +}): Promise { + const q = await prisma!.quote.create({ + data: { + model_code: MODEL, selections: {}, inputs: {}, + status: o.status as never, sales_user_id: o.user, + ...(o.customerId ? { customer_id: o.customerId } : {}), + final_price: o.price ?? 1_000_000, + ...(o.createdAt ? { created_at: o.createdAt } : {}), + ...(o.hidden ? { hidden_at: new Date(), hidden_by: 'vitest' } : {}), + }, + select: { id: true }, + }); + quotes.push(q.id); + for (const [status, at] of o.log ?? []) { + await prisma!.quoteChangeLog.create({ + data: { quote_id: q.id, section: 'status', field: 'status', new_value: status, changed_by: 'vitest', changed_at: at }, + }); + } + return q.id; +} + +afterAll(async () => { + if (!prisma) return; + await prisma.quoteChangeLog.deleteMany({ where: { quote_id: { in: quotes } } }); + await prisma.quote.deleteMany({ where: { id: { in: quotes } } }); + await prisma.customer.deleteMany({ where: { id: { in: customers } } }); + await prisma.user.deleteMany({ where: { email: { in: USERS } } }); +}); + +describe.runIf(live)('영업 성과 집계', () => { + it('🔴 배정을 취소해 계약완료로 돌아온 건은 배정완료에서 빠진다 — 지금 상태로 센다', async () => { + const A = await user(); + const c = await customer('성과시험_배정취소'); + await quote({ + user: A, customerId: c, status: 'contracted', + log: [['contracted', day('2026-09-02')], ['assigned', day('2026-09-03')], ['contracted', day('2026-09-04')]], + }); + const [st] = await salesStats({ salesUser: A }); + expect(st!.reached.assigned, '되돌린 건이 배정완료에 남았다').toBe(0); + expect(st!.reached.contracted).toBe(1); + }, 30_000); + + it('🔴 한 고객에게 여러 건을 내도 견적완료 고객은 1명 — 건수는 그대로 센다', async () => { + const B = await user(); + const c = await customer('성과시험_한고객여러건'); + for (const n of [1, 2, 3]) { + await quote({ user: B, customerId: c, status: 'confirmed', price: n * 1_000_000, log: [['confirmed', day('2026-09-05')]] }); + } + const [st] = await salesStats({ salesUser: B }); + expect(st!.reached.confirmed, '건수는 세 건이어야 한다').toBe(3); + expect(st!.customers.confirmed, '고객은 한 명이어야 한다').toBe(1); + expect(st!.customers.draft, '상담고객도 한 명').toBe(1); + // 금액도 고객당 한 건 — 같은 차를 세 번 견적내면 세 배가 되면 안 된다 + expect(st!.amount.confirmed).toBe(3_000_000); + }, 30_000); + + it('🔴 같은 사람인데 고객 행이 둘이면 한 명으로 묶는다 — 서류함과 같은 규칙', async () => { + const A = await user(); + const one = await customer('성과시험_중복인물', '010-7777-0001'); + const two = await customer('성과시험_중복인물', '010-7777-0001'); + await quote({ user: A, customerId: one, status: 'confirmed', log: [['confirmed', day('2026-09-06')]] }); + await quote({ user: A, customerId: two, status: 'confirmed', log: [['confirmed', day('2026-09-06')]] }); + const [st] = await salesStats({ salesUser: A }); + expect(st!.customers.confirmed, '고객 행이 둘이라고 두 명으로 세면 안 된다').toBe(1); + }, 30_000); + + it('🔴 기간은 「그 단계에 도달한 날」로 가른다 — 지난달 만들어 이번 달 계약한 건', async () => { + const B = await user(); + const c = await customer('성과시험_기간'); + await quote({ + user: B, customerId: c, status: 'contracted', createdAt: day('2026-08-20'), + log: [['confirmed', day('2026-08-21')], ['contracted', day('2026-09-10')]], + }); + const sep = { salesUser: B, from: day('2026-09-01'), to: new Date('2026-09-30T23:59:59.999+09:00') }; + const [st] = await salesStats(sep); + expect(st!.reached.contracted, '9월에 계약한 건이 9월 집계에 없다').toBe(1); + expect(st!.reached.draft, '8월에 만든 견적은 9월 활동이 아니다').toBe(0); + expect(st!.customers.contracted).toBe(1); + }, 30_000); + + it('🔴 이름 없는 임시저장은 상담고객이 아니다 — 건수로만 센다', async () => { + const A = await user(); + await quote({ user: A, status: 'draft' }); + await quote({ user: A, status: 'draft' }); + const [st] = await salesStats({ salesUser: A }); + expect(st!.reached.draft, '견적 두 건은 두 건').toBe(2); + expect(st!.customers.draft, '고객 정보가 없는데 두 명을 만난 것이 된다').toBe(0); + }, 30_000); + + it('🔴 전이 이력이 없는 옛 건 — 끝이 열린 기간이면 만든 날로 갈음하고, 지난 달을 볼 땐 세지 않는다', async () => { + const A = await user(); + const c = await customer('성과시험_이력없음'); + const madeToday = new Date(); + await quote({ user: A, customerId: c, status: 'contracted', createdAt: madeToday }); // 상태 전이 이력 없음 + + const from = new Date(madeToday.getFullYear(), madeToday.getMonth(), 1); + const open = await salesStats({ salesUser: A, from }); + expect(open[0]!.reached.contracted, '계약완료인데 이번 달 계약이 0 이 된다').toBe(1); + + // 끝이 닫힌 지난 기간 — 언제 계약했는지 모르므로 지어내지 않는다 + const closed = await salesStats({ salesUser: A, from: day('2026-01-01'), to: day('2026-01-31') }); + expect(closed[0]?.reached.contracted ?? 0).toBe(0); + }, 30_000); + + it('🔴 전체 합계는 서버가 낸다 — 한 고객을 둘이 맡아도 두 명이 되지 않는다', async () => { + const A = await user(); + const B = await user(); + const c = await customer('성과시험_공동고객'); + const totalCustomers = async () => (await salesStatsFull({})).total.customers.confirmed; + + const before = await totalCustomers(); + await quote({ user: A, customerId: c, status: 'confirmed', log: [['confirmed', day('2026-09-07')]] }); + expect(await totalCustomers(), '새 고객 한 명이 늘어야 한다').toBe(before + 1); + + // 같은 고객을 다른 영업도 맡았다 — 계정별로는 각자 1명이지만 **합계는 그대로 1명**이다 + await quote({ user: B, customerId: c, status: 'confirmed', log: [['confirmed', day('2026-09-07')]] }); + expect(await totalCustomers(), '합계를 더해서 만들면 한 사람이 두 명이 된다').toBe(before + 1); + + const { rows } = await salesStatsFull({}); + const mine = rows.filter(r => r.sales_user_id === A || r.sales_user_id === B); + expect(mine.map(r => r.customers.confirmed), '계정별로는 각자 한 명').toEqual([1, 1]); + }, 30_000); + + it('🔴 숨긴 견적은 성과에도, 「처리 필요 견적」에도 나오지 않는다', async () => { + const A = await user(); + const c = await customer('성과시험_숨김'); + await quote({ user: A, customerId: c, status: 'draft', createdAt: day('2026-01-02'), hidden: true }); + const [st] = await salesStats({ salesUser: A, from: day('2026-01-01'), to: day('2026-01-31') }); + expect(st?.reached.draft ?? 0, '숨긴 견적이 성과에 잡혔다').toBe(0); + const att = await attentionList(A); + expect(att.some(x => x.customer === '성과시험_숨김'), '숨긴 견적이 오늘 할 일에 떴다').toBe(false); + }, 30_000); +}); diff --git a/backend/src/app.ts b/backend/src/app.ts index 3495d9c..f9d5f80 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -13,6 +13,7 @@ import { addonRouter } from './routes/addon.js'; import { docsRouter } from './routes/docs.js'; import { pushRouter } from './routes/push.js'; import { authRouter } from './routes/auth.js'; +import { devAuthRouter, devAutoLoginEnabled } from './routes/dev-auth.js'; import { usersRouter } from './routes/users.js'; import { accessControlRouter } from './routes/access-control.js'; import { weightConstantsRouter } from './routes/weight-constants.js'; @@ -87,6 +88,16 @@ export function createApp() { app.use('/api/external', externalRouter); app.use('/api/v1/auth', authRouter); + + /* + + * 로컬 전용 자동 로그인 — **운영이 아닐 때만 라우트를 만든다**(routes/dev-auth.ts). + + * 운영은 NODE_ENV=production 으로 뜨므로 이 경로가 존재하지 않는다. + + */ + + if (devAutoLoginEnabled()) app.use('/api/v1/dev', devAuthRouter); app.use('/api/v1/users', usersRouter); app.use('/api/v1/access-control', accessControlRouter); app.use('/api/v1/weight-constants', weightConstantsRouter); diff --git a/backend/src/routes/dev-auth.ts b/backend/src/routes/dev-auth.ts new file mode 100644 index 0000000..5d199dd --- /dev/null +++ b/backend/src/routes/dev-auth.ts @@ -0,0 +1,38 @@ +import { Router } from 'express'; +import type { Request, Response } from 'express'; +import { prisma } from '../lib/prisma.js'; +import { issueSession } from '../lib/session.js'; + +/** + * **로컬 전용 자동 로그인** — 개발할 때 비밀번호를 치지 않고 바로 들어간다. + * + * ⚠️ **운영에는 이 경로가 아예 없다.** `app.ts` 가 운영이 아닐 때만 붙인다 — 권한 검사를 끄는 것이 아니라 + * **라우트를 만들지 않는다.** 운영은 `NODE_ENV=production` 으로 뜬다(deploy/remote-deploy.sh). + * ⚠️ 그래도 한 겹 더 막는다: 요청이 이 기계(127.0.0.1/::1)에서 온 것이 아니면 404. + * 운영에서 이 파일이 실수로 붙더라도 **밖에서는 열리지 않는다**(그 경우에도 프록시 뒤라 열리지 않게 두 조건을 모두 본다). + */ +export const devAuthRouter = Router(); + +/** 운영이 아닐 때만 — 값이 없으면 개발로 본다(config.ts 와 같은 기준) */ +export const devAutoLoginEnabled = (): boolean => (process.env['NODE_ENV'] ?? 'development') !== 'production'; + +function localOnly(req: Request, res: Response): boolean { + const ip = req.ip ?? ''; + const ok = devAutoLoginEnabled() && (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'); + if (!ok) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Not found' } }); return false; } + return true; +} + +// ── GET /dev/login?email=…&to=… — 쿠키를 심고 화면으로 보낸다 ──────────────── +devAuthRouter.get('/login', async (req: Request, res: Response): Promise => { + if (!localOnly(req, res)) return; + if (!prisma) { res.status(503).json({ error: { code: 'DB_UNAVAILABLE', message: 'DB 연결 필요' } }); return; } + const email = String(req.query['email'] ?? 'master@local'); + const user = await prisma.user.findUnique({ where: { email } }); + if (!user || !user.active) { + res.status(404).json({ error: { code: 'NOT_FOUND', message: `로컬 계정 ${email} 이 없습니다` } }); return; + } + issueSession(res, { email: user.email, role: user.role, org_code: user.org_code }, true); + const to = String(req.query['to'] ?? 'http://localhost:5173/admin'); + res.redirect(to.startsWith('http://localhost') ? to : 'http://localhost:5173/admin'); +}); diff --git a/backend/src/routes/stats.ts b/backend/src/routes/stats.ts index e43c0d4..ce2ea29 100644 --- a/backend/src/routes/stats.ts +++ b/backend/src/routes/stats.ts @@ -8,7 +8,7 @@ import { Router } from 'express'; import type { Request } from 'express'; import { rbac, requirePermission, hasPermission } from '../middleware/rbac.js'; -import { salesStats, attentionList } from '../services/sales-stats.js'; +import { salesStatsFull, attentionList } from '../services/sales-stats.js'; export const statsRouter = Router(); @@ -26,21 +26,33 @@ async function targetUser(req: Request): Promise { return q || undefined; } -function dateOf(v: unknown): Date | undefined { +/** + * 날짜 한 칸 — **한국 시각 기준**으로 읽는다. + * + * `new Date('2026-09-01')` 은 UTC 자정이라 한국에서는 **그날 오전 9시**다. 그대로 쓰면 + * 시작일 새벽에 만든 견적이 빠지고, 종료일은 그날 오전 9시에서 잘려 하루가 통째로 날아갔다. + * 그래서 날짜만 온 값은 시작=그날 00:00(KST), 끝=그날 23:59:59.999(KST) 로 편다. + */ +const KST = '+09:00'; +function dateOf(v: unknown, edge: 'start' | 'end'): Date | undefined { if (typeof v !== 'string' || !v.trim()) return undefined; - const d = new Date(v); + const s = v.trim(); + const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(s); + const d = new Date(dateOnly ? `${s}T${edge === 'start' ? '00:00:00.000' : '23:59:59.999'}${KST}` : s); return Number.isNaN(d.getTime()) ? undefined : d; } // ── GET /stats/sales — 계정별 깔때기·금액·활동량·속도 ────────────────────── statsRouter.get('/sales', rbac('ADMIN', 'SALES'), requirePermission('stats.own'), async (req: Request, res): Promise => { try { - const data = await salesStats({ - from: dateOf(req.query['from']), - to: dateOf(req.query['to']), + // 합계는 **서버가 낸다.** 화면에서 계정별 줄을 더하면 고객 수가 어긋난다 + // (한 고객을 두 영업이 나눠 맡으면 각자 1명이라 합이 2명이 된다). + const { rows, total } = await salesStatsFull({ + from: dateOf(req.query['from'], 'start'), + to: dateOf(req.query['to'], 'end'), salesUser: await targetUser(req), }); - res.json({ data }); + res.json({ data: rows, total }); } catch (e) { console.error('[GET /stats/sales]', e); res.status(500).json({ error: { code: 'INTERNAL', message: '성과 집계 중 오류가 발생했습니다.' } }); diff --git a/backend/src/services/sales-stats.ts b/backend/src/services/sales-stats.ts index ea831ee..36f2fa2 100644 --- a/backend/src/services/sales-stats.ts +++ b/backend/src/services/sales-stats.ts @@ -6,12 +6,29 @@ * 금액 = 건수만 보면 소액 견적을 많이 낸 쪽이 유리해진다 * 누수 = 오늘 당장 전화할 목록 — 성과 측정보다 이쪽이 매출에 가깝다 * + * ── 세는 규칙(2026-09-16 전면 재정비) ──────────────────────────────────────── + * + * ① **지금 상태로 센다.** 예전에는 「한 번이라도 그 단계를 밟았는가」로 셌다. 그래서 + * **배정을 취소해 계약완료로 되돌린 건이 배정완료에 그대로 남았다**(제보). 되돌리는 + * 길은 넷이다 — 배정 취소 · 배정 거부 · 특장사 거부 · 주문 삭제. 하나씩 예외를 두면 + * 다음에 생기는 길에서 또 샌다. 그래서 **견적의 현재 상태**만 본다. 되돌리면 그 자리에서 빠진다. + * + * ② **고객 수(명)와 건수(건)를 따로 낸다.** 한 고객에게 견적을 다섯 번 고쳐 내보내면 + * 건수는 5이지만 상담한 고객은 한 명이다. 임시저장은 활동량이라 건수로 두고, + * 견적완료부터는 **고객 수**를 함께 낸다. 같은 사람인지는 서류함과 **같은 규칙** + * (이름+생년월일 / 이름+휴대폰)으로 본다 — 두 화면의 고객 수가 갈리면 안 된다. + * + * ③ **기간은 「그 단계에 도달한 날」로 가른다.** 예전에는 견적을 만든 날로 걸러서, + * 8월에 만들어 9월에 계약한 건이 「9월 계약」에 잡히지 않았다. 「이번 달 계약」은 + * 이번 달에 계약된 건이어야 한다. 도달 시각을 모르는 옛 건은 **세지 않는다** — + * 없는 시간을 지어내지 않는다(기간을 안 주면 전부 센다). + * * ⚠️ 전이 시각은 quote_change_log(section='status')에 쌓인다. 그 기록을 남기기 전에 - * 진행된 견적은 곁에 있는 시각으로 메우고(계약완료=서명완료, 배정=주문배정), - * 그래도 없으면 **속도 계산에서 제외**한다. 없는 시간을 지어내지 않는다. + * 진행된 견적은 곁에 있는 시각으로 메운다(계약완료=서명완료, 배정=주문배정). */ import { prisma } from '../lib/prisma.js'; import { VISIBLE } from '../lib/visibility.js'; +import { groupCustomers, type FolderCustomer } from './customer-folders.js'; import { STATUS_SECTION, stageTimesOf } from './quote-status.js'; const DAY = 24 * 60 * 60 * 1000; @@ -22,11 +39,22 @@ export type FunnelStage = (typeof FUNNEL)[number]; export interface SalesStat { sales_user_id: string; - /** 각 단계에 **도달한 적 있는** 견적 수(현재 상태가 아니라 누적 도달) */ + /** + * 단계별 **건수** — 견적의 **현재 상태**가 그 단계 이상인 것. + * 되돌린 건(배정 취소·거부·주문 삭제)은 그 자리에서 빠진다. + */ reached: Record; - /** 금액 — 확정·계약·인도완료 기준(실구매가 합) */ + /** + * 단계별 **고객 수** — 같은 조건을 고객으로 묶어 센다(한 고객에게 여러 건이어도 1명). + * `customers.draft` 가 「상담고객」 = 견적이 나간 고객 수다. + */ + customers: Record; + /** + * 금액 — 견적완료는 **고객당 가장 최근 견적 한 건**만 더한다(같은 차를 세 번 견적내면 세 배가 된다). + * 계약·인도는 건마다 따로 된 거래라 그대로 더한다. + */ amount: { confirmed: number; contracted: number; completed: number }; - /** 활동량 */ + /** 활동량 — 기간 안에 **실제로 한 일**(만든 견적·보낸 메일·요청한 서명·고친 횟수) */ activity: { quotes: number; emailed: number; sign_requested: number; edits: number }; /** 평균 소요일 — 계산 가능한 건만(표본 수를 함께 준다) */ lead: { @@ -48,95 +76,199 @@ function avgDays(list: number[]): { days: number | null; n: number } { return { days: Math.round((list.reduce((a, b) => a + b, 0) / list.length) * 10) / 10, n: list.length }; } +const zeroStages = (): Record => + ({ draft: 0, confirmed: 0, contracted: 0, assigned: 0, ordered: 0, completed: 0 }); + +function blankStat(user: string): SalesStat { + return { + sales_user_id: user, + reached: zeroStages(), + customers: zeroStages(), + amount: { confirmed: 0, contracted: 0, completed: 0 }, + activity: { quotes: 0, emailed: 0, sign_requested: 0, edits: 0 }, + lead: { to_confirmed: { days: null, n: 0 }, to_contracted: { days: null, n: 0 }, to_completed: { days: null, n: 0 } }, + signing: { email_sent: 0, email_done: 0, kakao_sent: 0, kakao_done: 0 }, + edits_per_quote: null, + }; +} + +/** 계정 한 벌 — 고객 중복을 걸러야 해서 집계 중에는 Set·Map 을 곁에 든다 */ +interface Acc { + stat: SalesStat; + lead: { c: number[]; k: number[]; d: number[] }; + seen: Record>; + /** 견적완료 금액 — 고객 열쇠별 **가장 최근 견적** 한 건 */ + confirmAmt: Map; +} + +const blank = (user: string): Acc => ({ + stat: blankStat(user), + lead: { c: [], k: [], d: [] }, + seen: { draft: new Set(), confirmed: new Set(), contracted: new Set(), assigned: new Set(), ordered: new Set(), completed: new Set() }, + confirmAmt: new Map(), +}); + +/** 모은 것을 화면이 읽는 모양으로 굳힌다 — 고객 수·견적완료 금액은 여기서 비로소 정해진다 */ +function seal({ stat, lead, seen, confirmAmt }: Acc): SalesStat { + for (const stage of FUNNEL) stat.customers[stage] = seen[stage].size; + stat.amount.confirmed = [...confirmAmt.values()].reduce((a, b) => a + b.price, 0); + stat.lead = { + to_confirmed: avgDays(lead.c), + to_contracted: avgDays(lead.k), + to_completed: avgDays(lead.d), + }; + stat.edits_per_quote = stat.activity.quotes ? Math.round((stat.activity.edits / stat.activity.quotes) * 10) / 10 : null; + return stat; +} + +/** 계정별 한 줄씩. 합계가 필요하면 `salesStatsFull` 을 쓴다 */ export async function salesStats(range: StatsRange): Promise { - if (!prisma) return []; + return (await salesStatsFull(range)).rows; +} + +/** 전체 합계를 쓰는 이름 — 화면에 「전체 집계」로 나간다 */ +export const TOTAL_ROW = '(전체)'; + +/** + * 계정별 + **전체 합계**. + * + * ⚠️ 합계를 화면에서 더해서 만들면 안 된다 — **고객 수는 더할 수 없다.** + * 한 고객을 두 영업이 나눠 맡으면 각자 1명이라 합이 2명이 된다. + * 그래서 합계도 같은 자리에서 **고객 열쇠로 다시 묶어** 낸다. + */ +export async function salesStatsFull(range: StatsRange): Promise<{ rows: SalesStat[]; total: SalesStat }> { + if (!prisma) return { rows: [], total: blankStat(TOTAL_ROW) }; // 숨긴 견적은 성과에서 뺀다 — 안 쓰기로 한 견적이 퍼널에 계속 잡히면 숫자를 못 믿는다 const where: Record = { ...VISIBLE }; if (range.salesUser) where['sales_user_id'] = range.salesUser; - if (range.from || range.to) { - where['created_at'] = { ...(range.from ? { gte: range.from } : {}), ...(range.to ? { lte: range.to } : {}) }; - } + // 만들기 전에는 어떤 단계에도 닿을 수 없다 — 끝 날짜 뒤에 만든 견적은 볼 것도 없다 + if (range.to) where['created_at'] = { lte: range.to }; const quotes = await prisma.quote.findMany({ where, select: { - id: true, sales_user_id: true, status: true, final_price: true, created_at: true, - docs_emailed_at: true, + id: true, customer_id: true, sales_user_id: true, status: true, final_price: true, + created_at: true, updated_at: true, docs_emailed_at: true, contracts: { select: { status: true, sent_at: true, completed_at: true, signing_method: true } }, order: { select: { assigned_at: true } }, change_logs: { select: { section: true, new_value: true, changed_at: true } }, }, }); - const byUser = new Map(); - const blank = (user: string) => ({ - sales_user_id: user, - reached: { draft: 0, confirmed: 0, contracted: 0, assigned: 0, ordered: 0, completed: 0 }, - amount: { confirmed: 0, contracted: 0, completed: 0 }, - activity: { quotes: 0, emailed: 0, sign_requested: 0, edits: 0 }, - lead: { to_confirmed: { days: null, n: 0 }, to_contracted: { days: null, n: 0 }, to_completed: { days: null, n: 0 } }, - signing: { email_sent: 0, email_done: 0, kakao_sent: 0, kakao_done: 0 }, - edits_per_quote: null, - _lead: { c: [], k: [], d: [] }, - } as SalesStat & { _lead: { c: number[]; k: number[]; d: number[] } }); + /* + * 같은 사람 묶기 — **서류함과 같은 규칙**(이름+생년월일 / 이름+휴대폰)을 그대로 쓴다. + * 숨긴 고객 행도 묶음에 넣는다. 빼면 그 행에 달린 견적이 다른 사람으로 세어져 + * 고객 수가 도로 부풀려진다. + */ + const custIds = [...new Set(quotes.map(q => q.customer_id).filter((v): v is number => v !== null))]; + const folderOf = new Map(); + if (custIds.length > 0) { + const rows = await prisma.customer.findMany({ + where: { id: { in: custIds } }, + select: { id: true, name: true, reg_no: true, phone: true, updated_at: true }, + }); + for (const g of groupCustomers(rows as FolderCustomer[])) for (const id of g.ids) folderOf.set(id, g.key); + } + /** + * 고객 열쇠 — 금액을 고객당 한 건으로 줄일 때 쓴다. + * 고객이 안 붙은 견적은 묶을 근거가 없어 각자 한 건으로 둔다(**고객 수에는 넣지 않는다**). + */ + const keyOf = (q: { id: number; customer_id: number | null }) => + q.customer_id === null ? `q${q.id}` : `c${folderOf.get(q.customer_id) ?? q.customer_id}`; + /** 고객 수에 넣을 열쇠 — **고객 정보가 붙은 견적만.** 이름도 없는 임시저장은 만난 사람이 아니다 */ + const personOf = (q: { customer_id: number | null }) => + q.customer_id === null ? null : `c${folderOf.get(q.customer_id) ?? q.customer_id}`; + + /** 기간 안에 일어난 일인가. 기간을 안 주면 전부 센다 */ + const noRange = !range.from && !range.to; + const inRange = (at: Date | null | undefined): boolean => { + if (noRange) return true; + if (!at) return false; + if (range.from && at < range.from) return false; + if (range.to && at > range.to) return false; + return true; + }; + /** + * 끝이 **열려 있는** 기간인가(오늘까지) — 「이번 달」이 그렇다. + * 열려 있으면, 기간 안에 만든 견적이 지금 어느 단계든 **그 단계도 기간 안**에서 일어난 것이다 + * (견적을 만들기 전에는 계약할 수 없고, 오늘 이후는 아직 오지 않았다). + */ + const openEnd = !range.to || range.to.getTime() >= Date.now(); + /** + * 그 단계에 도달한 **날짜**. 전이 이력을 남기기 전에 진행된 옛 건은 기록이 없다 — + * 끝이 열린 기간에서는 **만든 날**로 갈음한다. 그러지 않으면 상태는 「계약완료」인데 + * 이번 달 계약이 한 건도 없는 것처럼 보인다(실제 데이터에서 78건이 그렇게 사라졌다). + * 지난 달을 따로 볼 때는(끝이 닫힌 기간) 갈음하지 않는다 — 없는 시간을 지어내지 않는다. + */ + const whenOf = (at: Date | null, created: Date): Date | null => at ?? (openEnd ? created : null); + + const byUser = new Map(); + const totalAcc = blank(TOTAL_ROW); for (const q of quotes) { const user = q.sales_user_id ?? '(미지정)'; if (!byUser.has(user)) byUser.set(user, blank(user)); - const st = byUser.get(user)!; + const accs = [byUser.get(user)!, totalAcc]; const statusLogs = q.change_logs.filter((l) => l.section === STATUS_SECTION); const latestContract = q.contracts[0]; - const t = stageTimesOf(q, statusLogs, { + const at = stageTimesOf(q, statusLogs, { contracted: latestContract?.completed_at ?? null, assigned: q.order?.assigned_at ?? null, }); - /* - * **「완료」는 지금 완료인 것만** 센다(2026-09-14). 완료는 되돌릴 수 있다 — 고객 인도를 되돌리거나, - * 예전 흐름에서 특장사 출고 때 완료로 올라갔다가 고객 인도 전이라 되돌린 건. 이력에 한 번 찍혔다고 - * 완료로 세면 부가작업에서 멈춘 주문이 영업 성과·마이페이지에 「완료」로 뜬다(제보). - */ - if (q.status !== 'completed') t.completed = null; - - // 도달 = 그 단계 시각이 있거나, 지금 그 단계를 이미 지나왔거나 - const idxNow = FUNNEL.indexOf(q.status as FunnelStage); - FUNNEL.forEach((stage, i) => { - const reached = i === 0 || t[stage] !== null || (idxNow >= 0 && idxNow >= i); - if (reached) st.reached[stage] += 1; - }); + const key = keyOf(q); + const person = personOf(q); const price = q.final_price ?? 0; - if (idxNow >= 1) st.amount.confirmed += price; - if (idxNow >= 2) st.amount.contracted += price; - if (idxNow >= 5) st.amount.completed += price; - - st.activity.quotes += 1; - if (q.docs_emailed_at) st.activity.emailed += 1; - if (q.contracts.some((c) => c.sent_at)) st.activity.sign_requested += 1; - st.activity.edits += q.change_logs.filter((l) => l.section !== STATUS_SECTION).length; - - for (const c of q.contracts) { - if (!c.sent_at) continue; - const kakao = c.signing_method === 'KAKAO'; - if (kakao) st.signing.kakao_sent += 1; else st.signing.email_sent += 1; - if (c.status === 'COMPLETED') { if (kakao) st.signing.kakao_done += 1; else st.signing.email_done += 1; } - } + /** 지금 어느 단계인가. 만료·취소(expired)는 깔때기 밖이라 -1 이 된다 */ + const idxNow = (FUNNEL as readonly string[]).indexOf(q.status); + /** 그 단계를 지금도 지나와 있고, 그 단계에 도달한 날이 기간 안인가 */ + const counted = (stage: FunnelStage, i: number) => + // 임시저장은 「만든 것」 자체다 — 만료된 견적도 만들기는 했다 + (i === 0 || idxNow >= i) && inRange(whenOf(at[stage], q.created_at)); + + const edits = q.change_logs.filter((l) => l.section !== STATUS_SECTION && inRange(l.changed_at)).length; + const signRequested = q.contracts.some((c) => c.sent_at && inRange(c.sent_at)); + + // 계정 한 줄과 전체 합계에 **같은 셈**을 넣는다 — 합계를 따로 더하면 고객 수가 어긋난다 + for (const acc of accs) { + const st = acc.stat; + FUNNEL.forEach((stage, i) => { + if (!counted(stage, i)) return; + st.reached[stage] += 1; + if (person) acc.seen[stage].add(person); + }); - if (t.confirmed) st._lead.c.push((t.confirmed.getTime() - t.draft.getTime()) / DAY); - if (t.contracted) st._lead.k.push((t.contracted.getTime() - t.draft.getTime()) / DAY); - if (t.completed) st._lead.d.push((t.completed.getTime() - t.draft.getTime()) / DAY); + if (counted('confirmed', 1)) { + // 같은 고객이면 가장 최근에 손댄 견적 한 건만 — 견적을 고쳐 다시 낼수록 늘어나면 안 된다 + const prev = acc.confirmAmt.get(key); + const mine = q.updated_at.getTime(); + if (!prev || mine > prev.at) acc.confirmAmt.set(key, { price, at: mine }); + } + if (counted('contracted', 2)) st.amount.contracted += price; + if (counted('completed', 5)) st.amount.completed += price; + + if (inRange(q.created_at)) st.activity.quotes += 1; + if (q.docs_emailed_at && inRange(q.docs_emailed_at)) st.activity.emailed += 1; + if (signRequested) st.activity.sign_requested += 1; + st.activity.edits += edits; + + for (const c of q.contracts) { + if (!c.sent_at || !inRange(c.sent_at)) continue; + const kakao = c.signing_method === 'KAKAO'; + if (kakao) st.signing.kakao_sent += 1; else st.signing.email_sent += 1; + if (c.status === 'COMPLETED') { if (kakao) st.signing.kakao_done += 1; else st.signing.email_done += 1; } + } + + // 소요일 — 그 단계를 이번에 센 건만. 「이번 달 계약된 건은 평균 며칠 걸렸나」 + if (counted('confirmed', 1) && at.confirmed) acc.lead.c.push((at.confirmed.getTime() - at.draft.getTime()) / DAY); + if (counted('contracted', 2) && at.contracted) acc.lead.k.push((at.contracted.getTime() - at.draft.getTime()) / DAY); + if (counted('completed', 5) && at.completed) acc.lead.d.push((at.completed.getTime() - at.draft.getTime()) / DAY); + } } - return [...byUser.values()].map(({ _lead, ...s }) => ({ - ...s, - lead: { - to_confirmed: avgDays(_lead.c), - to_contracted: avgDays(_lead.k), - to_completed: avgDays(_lead.d), - }, - edits_per_quote: s.activity.quotes ? Math.round((s.activity.edits / s.activity.quotes) * 10) / 10 : null, - })); + return { rows: [...byUser.values()].map(seal), total: seal(totalAcc) }; } // ── 지금 붙어야 할 건 ─────────────────────────────────────────────────────── @@ -162,12 +294,15 @@ export const ATTENTION_DAYS = { sign_pending: 3, not_requested: 3, draft_stale: * sign_pending 서명 요청은 갔는데 아직 안 끝남 * not_requested 견적서까지 만들어 놓고 서명 요청을 안 함 * draft_stale 임시저장으로 방치 + * + * ⚠️ 숨긴 견적은 빼야 한다 — 안 쓰기로 하고 감춘 임시저장이 「오늘 할 일」에 계속 떴다(2026-09-16). */ export async function attentionList(salesUser?: string): Promise { if (!prisma) return []; const now = Date.now(); const quotes = await prisma.quote.findMany({ where: { + ...VISIBLE, ...(salesUser ? { sales_user_id: salesUser } : {}), status: { in: ['draft', 'confirmed', 'contracted'] }, }, diff --git a/frontend/src/api/stats.ts b/frontend/src/api/stats.ts index 944fa10..76c4e81 100644 --- a/frontend/src/api/stats.ts +++ b/frontend/src/api/stats.ts @@ -5,7 +5,11 @@ export type FunnelStage = (typeof FUNNEL)[number] export interface SalesStat { sales_user_id: string + /** 단계별 **건수** — 견적의 **현재 상태** 기준(되돌린 건은 그 자리에서 빠진다) */ reached: Record + /** 단계별 **고객 수** — 한 고객에게 여러 건이 나가도 1명. `customers.draft` = 상담고객 */ + customers: Record + /** 견적완료 금액은 고객당 가장 최근 견적 한 건만 더한 값이다 */ amount: { confirmed: number; contracted: number; completed: number } activity: { quotes: number; emailed: number; sign_requested: number; edits: number } lead: { @@ -38,12 +42,22 @@ async function get(url: string): Promise { return (await res.json() as { data: T }).data } -export function fetchSalesStats(p: { from?: string; to?: string; salesUser?: string } = {}): Promise { +/** 계정별 줄 + **서버가 낸 전체 합계**. 합계를 화면에서 더하면 고객 수가 어긋난다 */ +export interface SalesStatsRes { rows: SalesStat[]; total: SalesStat } + +export async function fetchSalesStats(p: { from?: string; to?: string; salesUser?: string } = {}): Promise { const qs = new URLSearchParams() if (p.from) qs.set('from', p.from) if (p.to) qs.set('to', p.to) if (p.salesUser) qs.set('sales_user', p.salesUser) - return get(`/api/v1/stats/sales${qs.toString() ? '?' + qs : ''}`) + const url = `/api/v1/stats/sales${qs.toString() ? '?' + qs : ''}` + const res = await fetch(url, { credentials: 'include' }) + if (!res.ok) { + const b = await res.json().catch(() => ({})) as { error?: { message?: string } } + throw new Error(b.error?.message ?? `조회 실패: ${res.status}`) + } + const body = await res.json() as { data: SalesStat[]; total: SalesStat } + return { rows: body.data, total: body.total } } export function fetchAttention(salesUser?: string): Promise { diff --git a/frontend/src/components/CustomerFolders.tsx b/frontend/src/components/CustomerFolders.tsx index 6ef0f39..44f379e 100644 --- a/frontend/src/components/CustomerFolders.tsx +++ b/frontend/src/components/CustomerFolders.tsx @@ -22,7 +22,11 @@ const STATUS_KO: Record = { assigned: '배정완료', ordered: '주문진행', completed: '완료', expired: '만료', } -export function CustomerFolders({ mine }: { +export function CustomerFolders({ mine, initialQuery = '', initialOpenKey = null }: { + /** 대시보드 「고객 검색」 카드에서 넘어올 때 — 그 이름으로 바로 좁혀서 연다(2026-09-16) */ + initialQuery?: string + /** 그 고객을 **펼친 채로** 연다 — 검색에서 이미 사람을 고르고 들어왔으면 한 번 더 누르게 하지 않는다 */ + initialOpenKey?: number | null /** * 영업 화면에서 참으로 준다 — 겸직(영업+관리자) 계정이라도 남의 고객은 안 본다. */ @@ -30,9 +34,9 @@ export function CustomerFolders({ mine }: { } = {}) { const [rows, setRows] = useState(null) const [err, setErr] = useState('') - const [q, setQ] = useState('') + const [q, setQ] = useState(initialQuery) /** 펼쳐 둔 고객 — 하나만 연다. 여럿 열면 화면이 길어져 훑는 뜻이 사라진다 */ - const [openKey, setOpenKey] = useState(null) + const [openKey, setOpenKey] = useState(initialOpenKey) useEffect(() => { fetchFolders(mine) diff --git a/frontend/src/components/SalesPerformance.tsx b/frontend/src/components/SalesPerformance.tsx index b727664..7af271e 100644 --- a/frontend/src/components/SalesPerformance.tsx +++ b/frontend/src/components/SalesPerformance.tsx @@ -3,9 +3,10 @@ import { DateField } from './ui/DateField' import { t , tf} from '../i18n' import { useScreenRefresh } from '../contexts/RefreshContext' import { - fetchSalesStats, fetchAttention, FUNNEL, - type SalesStat, type AttentionItem, type FunnelStage, + fetchSalesStats, fetchAttention, + type SalesStat, type AttentionItem, } from '../api/stats' +import { FUNNEL_STEPS } from '../lib/salesFunnel' import { BTN } from '../styles/buttons' import { useIsMobile } from '../hooks/useIsMobile' @@ -18,11 +19,6 @@ import { useIsMobile } from '../hooks/useIsMobile' * 위에서부터 「지금 붙어야 할 건 → 깔때기 → 금액·속도」 순인 이유: * 성과 숫자를 보러 왔더라도 **오늘 할 일**이 먼저 눈에 들어와야 쓸모가 있다. */ -const STAGE_KO: Record = { - draft: '임시저장', confirmed: '견적완료', contracted: '계약완료', - assigned: '배정완료', ordered: '주문진행', completed: '완료', -} - const KIND_KO: Record = { sign_pending: { label: '서명 미완료', why: '서명 요청 후 미완료 상태입니다', tone: 'warn' }, not_requested: { label: '서명 요청 필요', why: '견적서 생성 후 서명 요청 이력이 없습니다', tone: 'warn' }, @@ -46,6 +42,8 @@ export function SalesPerformance({ showUserFilter, userOptions = [] }: Props) { const [to, setTo] = useState('') const [user, setUser] = useState('') const [stats, setStats] = useState(null) + /** 전체 합계 — **서버가 낸다.** 계정별 줄을 더하면 고객 수가 어긋난다(한 고객을 둘이 맡으면 2명이 된다) */ + const [total, setTotal] = useState(null) const [attention, setAttention] = useState(null) const [err, setErr] = useState('') /** @@ -61,27 +59,13 @@ export function SalesPerformance({ showUserFilter, userOptions = [] }: Props) { fetchSalesStats({ from: from || undefined, to: to || undefined, salesUser: user || undefined }), fetchAttention(user || undefined), ]) - .then(([s, a]) => { setStats(s); setAttention(a) }) + .then(([s, a]) => { setStats(s.rows); setTotal(s.total); setAttention(a) }) .catch(e => setErr(e instanceof Error ? e.message : t('조회 실패'))) .finally(() => setLoading(false)) } useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps useScreenRefresh(load) - // 여러 계정을 합친 값 — 관리자가 전체를 볼 때 맨 위에 둔다 - const total = stats?.reduce((acc, s) => { - FUNNEL.forEach(k => { acc.reached[k] += s.reached[k] }) - acc.amount.confirmed += s.amount.confirmed - acc.amount.contracted += s.amount.contracted - acc.amount.completed += s.amount.completed - acc.activity.quotes += s.activity.quotes - return acc - }, { - reached: { draft: 0, confirmed: 0, contracted: 0, assigned: 0, ordered: 0, completed: 0 } as Record, - amount: { confirmed: 0, contracted: 0, completed: 0 }, - activity: { quotes: 0 }, - }) - return (
@@ -176,8 +160,9 @@ export function SalesPerformance({ showUserFilter, userOptions = [] }: Props) { <> {total && stats.length > 1 && (
-
{t('전체 집계')}
- + {/* 만든 견적 수는 깔때기에서 뺐다(단위가 달라 이탈률로 읽혔다) — 이름 옆에 적는다 */} +
{t('전체 집계')} {tf('견적 {0}건', total.activity.quotes)}
+
@@ -193,7 +178,7 @@ export function SalesPerformance({ showUserFilter, userOptions = [] }: Props) { {stats.map(st => (
{st.sales_user_id} {tf('견적 {0}건', st.activity.quotes)}
- +
@@ -236,15 +221,21 @@ function signRate(st: SalesStat): string { return sent ? `${Math.round((done / sent) * 100)}%` : '—' } -/** 깔때기 — 단계별 도달 수와 **앞 단계 대비** 전환율 */ -function Funnel({ reached }: { reached: Record }) { +/** + * 깔때기 — 단계별 수와 **앞 단계 대비** 전환율. + * + * ⚠️ 단위가 섞여 있다(상담고객·견적완료는 **명**, 나머지는 **건**). 숫자 옆에 단위를 + * 반드시 적는다 — 없으면 「고객 3」과 「계약 3」이 같은 것으로 읽힌다. + */ +function Funnel({ st }: { st: SalesStat }) { return (
- {FUNNEL.map((stage, i) => { - const prev = i > 0 ? reached[FUNNEL[i - 1]!] : 0 - const r = i > 0 ? rate(reached[stage], prev) : null + {FUNNEL_STEPS.map((step, i) => { + const n = step.get(st) + const prev = i > 0 ? FUNNEL_STEPS[i - 1]!.get(st) : 0 + const r = i > 0 ? rate(n, prev) : null return ( -
+
{i > 0 && (
›
@@ -253,8 +244,8 @@ function Funnel({ reached }: { reached: Record }) {
)}
-
{t(STAGE_KO[stage])}
-
{reached[stage]}
+
{t(step.label)}
+
{n}{t(step.unit)}
) @@ -379,6 +370,8 @@ const s: Record = { lineHeight: 1.25, overflowWrap: 'anywhere' as const, // 더 좁아지면 접히더라도 옆 칸을 침범하지 않는다 }, stepNum: { fontSize: 'clamp(13px, 3.1cqi, 20px)', fontWeight: 700, color: 'var(--dark)', marginTop: 2 }, + // 단위는 숫자보다 작게 — 있어야 읽히고, 커지면 숫자를 가린다 + stepUnit: { fontSize: '0.62em', fontWeight: 400, color: 'var(--muted)', marginLeft: 1 }, arrow: { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '0 2px', flexShrink: 0, diff --git a/frontend/src/components/dashboard/AdminDashboard.tsx b/frontend/src/components/dashboard/AdminDashboard.tsx new file mode 100644 index 0000000..a85e8b1 --- /dev/null +++ b/frontend/src/components/dashboard/AdminDashboard.tsx @@ -0,0 +1,603 @@ +import { Fragment, useEffect, useMemo, useRef, useState } from 'react' +import { t, tf } from '../../i18n' +import { fetchOrders } from '../../api/orders' +import { fetchQuotes } from '../../api/quotes' +import { fetchSalesStats, type SalesStat } from '../../api/stats' +import { fetchFolders, type ApiFolderRow } from '../../api/customerFolders' +import type { ApiOrder, ApiQuote } from '@shared/types/index' +import { buildDashboard } from '../../lib/orderDashboard' +import { DASH_STEPS } from '../../lib/salesFunnel' +import { shownDate } from '@shared/process/actual' +import { CustomerFolders } from '../CustomerFolders' +import { useEscapeClose } from '../../lib/escClose' +import { useIsMobile } from '../../hooks/useIsMobile' + +/** + * **관리자 첫 화면** — 자리(역할 프리셋)에 맞는 카드를 모아 둔 곳(2026-09-16 지시). + * + * 위젯처럼 자유롭게 옮기는 판은 아직 만들지 않는다. **무엇이 보이는가**가 먼저이고, + * 어디에 두는가는 한 달 써 보고 정한다. 그래서 카드는 각자 자기 데이터를 불러오는 **독립된 조각**으로 둔다 — + * 나중에 격자·배치 저장만 얹으면 그대로 위젯이 된다. + * + * 배치 규칙(2026-09-16 지시 — 「비어 보이지 않게」): + * · 성과·진행 현황·일정은 **한 줄을 통째로** 쓴다. 숫자 칸은 남는 폭을 나눠 가진다 + * · 목록이 드는 카드(배정 요청·고객 검색)는 **칸 높이를 못 박고 목록만 구른다** — + * 0건이든 100건이든 카드 크기가 변하면 그 아래가 들썩인다 + * + * 지금은 **영업관리** 한 벌이다(PM·생산관리·경영관리는 다음). + */ +export function AdminDashboard({ onGo }: { + /** 카드를 눌렀을 때 옮겨 갈 탭 — 대시보드는 요약만 보여 주고 일은 원래 화면에서 한다 */ + onGo: (tab: 'perf' | 'kanban') => void +}) { + const isMobile = useIsMobile() + const [orders, setOrders] = useState(null) + const [contracted, setContracted] = useState(null) + const [folders, setFolders] = useState(null) + const [err, setErr] = useState('') + + useEffect(() => { + let alive = true + Promise.all([ + fetchOrders({ board: 'admin' }), + fetchQuotes({ status: 'contracted' }), + fetchFolders().catch(() => [] as ApiFolderRow[]), + ]) + .then(([o, q, f]) => { if (alive) { setOrders(o); setContracted(q); setFolders(f) } }) + .catch(e => { if (alive) setErr(e instanceof Error ? e.message : t('대시보드를 불러오지 못했습니다')) }) + return () => { alive = false } + }, []) + + return ( +
+ {err &&
{err}
} +
+ onGo('perf')} /> + onGo('kanban')} /> + + + +
+
+ ) +} + +/** + * 이번 달의 시작일(YYYY-MM-DD). + * ⚠️ `toISOString()` 을 거치면 안 된다 — UTC 로 바뀌며 한국 새벽에는 **지난달 말일**이 나온다. + */ +function monthStart(): string { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01` +} + +/** + * 카드 한 장 — 제목 줄과 본문. 누를 수 있는 카드는 제목 오른쪽에 「열기」. + * + * ⚠️ **설명 문구를 달지 않는다**(2026-09-16 지시). 카드마다 한 줄씩 붙이면 제목 줄이 + * 문장으로 뒤덮여, 정작 봐야 할 숫자가 뒤로 밀린다. 설명이 필요할 만큼 헷갈리는 칸이면 + * 이름을 고칠 일이지 옆에 주석을 다는 일이 아니다. + */ +function Card({ title, extra, full, onGo, children }: { + title: string + /** 제목 오른쪽에 붙는 조작 — 지금은 성과 카드의 기간 토글뿐이다 */ + extra?: React.ReactNode + /** 한 줄을 통째로 쓰는 카드 */ + full?: boolean + onGo?: () => void + children: React.ReactNode +}) { + return ( +
+
+ {title} + {extra} + + {onGo && } +
+ {children} +
+ ) +} + +/** 기간 — 이번 달과 전체를 오가며 본다 */ +type Scope = 'month' | 'all' + +const won = (n: number) => `₩${Math.round(n).toLocaleString('ko-KR')}` + + +/** 칸 하나 — 이름 · 큰 숫자 · (있으면) 아래 한 줄 */ +interface Tile { + key: string + label: string + n: number + /** 명 · 건 */ + unit: string + /** 숫자 아래 한 줄(금액·건수). 자리를 비워도 줄 높이는 지킨다 */ + sub?: string + warn?: boolean + /** + * 앞 칸과 이 칸 사이에 무엇을 둘지. + * arrow = 이어지는 단계(상담 → 계약 → 주문 → 인도) + * divider = 성격이 다른 칸(납기일 경과는 「다음 단계」가 아니라 경보다) + */ + sep?: 'arrow' | 'divider' +} + +/** + * 칸을 한 줄로 늘어놓는다 — **꺾쇠는 칸과 칸 사이 정가운데, 숫자 줄 높이에 맞춘다.** + * + * 예전에는 flex 로 늘어놓고 꺾쇠를 `alignSelf: center` 로 뒀다. 그러면 꺾쇠가 + * 「이름 + 숫자 + 아래 줄」 전체의 한가운데에 서서, 정작 맞춰야 할 **숫자 줄과 어긋났다**(제보). + * 그래서 격자로 바꾸고 줄(이름/숫자/아래)과 칸을 좌표로 못 박는다 — 꺾쇠는 숫자 줄에만 놓인다. + */ +function TileRow({ tiles, isMobile, mobileCols, big }: { + tiles: Tile[]; isMobile: boolean; mobileCols: number; big?: boolean +}) { + // ⚠️ 칸 변수를 t 로 두지 말 것 — 번역 함수 t() 를 가려 그 블록만 한국어가 남는다 + const numStyle = (x: Tile) => (x.warn && x.n > 0 ? s.numValWarn : big ? s.perfVal : s.numVal) + + if (isMobile) { + return ( +
+ {tiles.map(x => ( +
+
{t(x.label)}
+
{x.n}{t(x.unit)}
+ {x.sub !== undefined &&
{x.sub}
} +
+ ))} +
+ ) + } + // '1fr auto 1fr auto …' — 홀수 칸이 내용, 짝수 칸이 칸 사이 + const cols = tiles.map(() => '1fr').join(' auto ') + return ( +
+ {tiles.map((x, i) => ( + + {i > 0 && x.sep === 'arrow' &&
›
} + {i > 0 && x.sep === 'divider' &&
} + {/* 가운데 정렬이라야 꺾쇠가 **두 숫자의 정확히 한가운데**에 선다 — 왼쪽으로 붙이면 + 칸마다 글자 너비가 달라 꺾쇠가 뒷 숫자에 딸려 붙은 것처럼 보인다(제보) */} +
{t(x.label)}
+
+ {x.n}{t(x.unit)} +
+
{x.sub ?? ''}
+ + ))} +
+ ) +} + +/** + * 영업 성과 — **상담고객 › 계약완료 › 주문진행 › 인도완료**(2026-09-16 지시). + * + * 계약 앞에 상담고객을 두는 이유는, 계약만 보면 **얼마나 많은 사람을 만났는지**가 사라지기 때문이다. + * 상담고객은 **명**이고 나머지는 **건**이다 — 한 고객에게 견적을 다섯 번 내도 만난 사람은 하나다. + * + * 기간은 **이번 달 / 전체**를 오간다. 이번 달만 보면 「우리가 지금까지 판 것」이 안 보이고, + * 전체만 보면 이번 달이 되고 있는지 알 수 없다 — 둘 다 필요하고, 한 번 부른 것은 들고 있다가 다시 쓴다. + * + * ⚠️ 이 카드가 **자기 것을 직접 부른다.** 기간을 바꾸면 다시 불러야 하는데, 부모가 들고 있으면 + * 부모가 기간을 알아야 한다 — 카드는 독립된 조각으로 둔다(파일 머리말 참조). + */ +function PerfCard({ isMobile, onGo }: { isMobile: boolean; onGo: () => void }) { + const [scope, setScope] = useState('month') + const [shown, setShown] = useState(null) + /** 이미 부른 기간은 다시 부르지 않는다 — 토글을 오갈 때마다 기다리게 하지 않는다 */ + const cache = useRef(new Map()) + + useEffect(() => { + const hit = cache.current.get(scope) + if (hit) { setShown(hit); return } + let alive = true + setShown(null) + fetchSalesStats(scope === 'month' ? { from: monthStart() } : {}) + .then(r => { cache.current.set(scope, r.total); if (alive) setShown(r.total) }) + .catch(() => { /* 카드 하나가 안 떠도 나머지는 보여야 한다 */ }) + return () => { alive = false } + }, [scope]) + + const sub = (key: string, st: SalesStat): string => { + if (key === 'consult') return tf('견적 {0}건', st.reached.draft) + if (key === 'contracted') return won(st.amount.contracted) + if (key === 'completed') return won(st.amount.completed) + return '' + } + const tiles: Tile[] = shown ? DASH_STEPS.map((step, i) => ({ + key: step.key, label: step.label, unit: step.unit, + n: step.get(shown), sub: sub(step.key, shown), + ...(i > 0 ? { sep: 'arrow' as const } : {}), + })) : [] + return ( + } + > + {!shown ?
{t('불러오는 중…')}
+ : } +
+ ) +} + +/** 기간 토글 — 두 칸짜리 스위치. 지금 보고 있는 쪽이 눌린 채로 남는다 */ +function ScopeToggle({ scope, onChange }: { scope: Scope; onChange: (v: Scope) => void }) { + const opts: [Scope, string][] = [['month', '이번 달'], ['all', '전체']] + return ( + + {opts.map(([key, label]) => ( + + ))} + + ) +} + +/** 지금 어디까지 왔나 — 「주문 진행」 현황판의 요약 칸과 같은 셈(lib/orderDashboard) */ +function ProgressCard({ orders, contracted, isMobile, onGo }: { + orders: ApiOrder[] | null; contracted: ApiQuote[] | null; isMobile: boolean; onGo: () => void +}) { + const dash = useMemo( + () => (orders && contracted ? buildDashboard(orders, contracted, new Date(), true) : null), + [orders, contracted], + ) + /* + * 앞 다섯은 **이어지는 단계**라 꺾쇠로 잇는다. + * 「납기일 경과」는 다음 단계가 아니라 **어디에 있든 늦은 건**이라, 꺾쇠 대신 세로선으로 끊는다 — + * 꺾쇠로 이으면 인도 완료 다음에 납기일 경과가 오는 것처럼 읽힌다. + */ + const tiles: Tile[] = dash ? ([ + { key: 'assign', label: '배정 대기', n: dash.assign.length }, + { key: 'pending', label: '수락 대기', n: dash.pending.length, sep: 'arrow' }, + { key: 'active', label: '특장 진행', n: dash.active.length, sep: 'arrow' }, + { key: 'addon', label: '부가작업', n: dash.addon.length, sep: 'arrow' }, + { key: 'done', label: '인도 완료', n: dash.done.length, sep: 'arrow' }, + { key: 'late', label: '납기일 경과', n: dash.late.length, warn: true, sep: 'divider' }, + ] as Omit[]).map(x => ({ ...x, unit: '건' })) : [] + return ( + + {!dash ?
{t('불러오는 중…')}
+ : } +
+ ) +} + +/** + * 영업이 아직 「배정 요청」을 누르지 않은 건. + * + * **여기서 할 일은 없다** — 요청은 영업이 누르는 것이라, 관리자는 누구에게 전화할지만 알면 된다(2026-09-16 지시). + * 그래서 「열기」도 없고, 대신 담당 영업을 나란히 적는다. + * 칸 높이는 못 박고 목록만 구른다 — 0건일 때와 100건일 때 카드 크기가 달라지면 아래가 들썩인다. + */ +function AssignRequestCard({ contracted }: { contracted: ApiQuote[] | null }) { + const waiting = (contracted ?? []).filter(q => !q.assign_requested_at) + const days = (iso?: string | null) => + iso ? Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 86400000)) : 0 + return ( + +
+ 0 ? s.numValWarn : s.numVal}>{waiting.length}{t('건')} +
+
+ {!contracted ?
{t('불러오는 중…')}
+ : waiting.length === 0 ?
{t('영업의 배정 요청을 기다리는 건이 없습니다.')}
: ( +
    + {/* 오래 묵은 것부터 — 먼저 전화할 순서 */} + {[...waiting].sort((a, b) => days(b.created_at) - days(a.created_at)).map(q => ( +
  • + {q.quote_no ?? `#${q.id}`} + {q.customer?.name ?? '—'} + {q.sales_user_id ?? '—'} + {tf('{0}일째', days(q.created_at))} +
  • + ))} +
+ )} +
+
+ ) +} + +/** + * 고객 이름으로 그 고객의 모든 것(견적·계약서·서명본·서류)을 한 팝업에서. + * + * 치는 동안 **DB 에 있는 고객이 아래로 뜨고 거기서 고른다**(2026-09-16 지시) — + * 이름을 정확히 기억하지 못해도 되고, 없는 사람을 찾다 헛수고하지 않는다. + */ +function CustomerSearchCard({ folders }: { folders: ApiFolderRow[] | null }) { + const [q, setQ] = useState('') + const [picked, setPicked] = useState(null) + const hits = useMemo(() => { + const k = q.trim().toLowerCase() + const rows = folders ?? [] + if (!k) return rows // 안 쳤으면 최근 순 그대로 — 빈 칸을 남기지 않는다 + return rows.filter(r => [r.name, r.phone, r.reg_no].some(v => (v ?? '').toLowerCase().includes(k))) + }, [folders, q]) + + return ( + {tf('{0}명', hits.length)} : null}> +
+ setQ(e.target.value)} + /> +
+
+ {!folders ?
{t('불러오는 중…')}
+ : hits.length === 0 ?
{t('해당하는 고객이 없습니다.')}
: ( +
    + {hits.map(r => ( +
  • + +
  • + ))} +
+ )} +
+ {picked && setPicked(null)} />} +
+ ) +} + +function FolderModal({ row, onClose }: { row: ApiFolderRow; onClose: () => void }) { + return ( + + {/* 고객 서류함을 그대로 쓴다 — 두 화면이 다른 것을 보여 주면 안 된다 */} + + + ) +} + +/** 팝업 한 벌 — 고객 서류함과 날짜별 일정이 같은 틀을 쓴다 */ +function Modal({ label, title, onClose, children }: { + label: string; title: string; onClose: () => void; children: React.ReactNode +}) { + useEscapeClose(onClose) + return ( +
{ if (ev.target === ev.currentTarget) onClose() }}> +
+
+ {title} + +
+
{children}
+
+
+ ) +} + +/* ── 달력 ─────────────────────────────────────────────────────────────── */ + +const CAL_KINDS = ['차량 도착', '납기', '고객 인도'] as const +type CalKind = (typeof CAL_KINDS)[number] +type DayMark = { kind: CalKind; orderId: number; name: string; done: boolean } + +/** + * 한 달 달력 — 날짜마다 **차량 도착 · 납기 · 고객 인도**를 찍는다. + * 예정일은 흐리게, 실제로 끝난 날짜는 진하게(실제 날짜 규칙은 shared/process/actual 과 같다). + * + * 날짜를 누르면 그날 것을 **항목별로 갈라 고객명까지** 보여 준다(2026-09-16 지시) — + * 「이 날 세 건」만으로는 누구에게 전화할지 알 수 없다. + */ +function CalendarCard({ orders, isMobile }: { orders: ApiOrder[] | null; isMobile: boolean }) { + const [month, setMonth] = useState(() => { const d = new Date(); d.setDate(1); d.setHours(0, 0, 0, 0); return d }) + const [openDay, setOpenDay] = useState(null) + + const marks = useMemo(() => { + const m = new Map() + const put = (day: string | null, mark: DayMark) => { + if (!day) return + const list = m.get(day); if (list) list.push(mark); else m.set(day, [mark]) + } + for (const o of orders ?? []) { + const name = o.quote.customer?.name ?? '—' + const arrival = shownDate(o.car_arrival_planned_at, o.car_arrived_on) + put(arrival.value, { kind: '차량 도착', orderId: o.id, name, done: !!o.car_arrived_on }) + const due = shownDate(o.delivery_due, o.shipped_on) + put(due.value, { kind: '납기', orderId: o.id, name, done: !!o.shipped_on }) + const handover = shownDate(o.addon?.target_on, o.addon?.finished ? o.addon.delivered_on : null) + put(handover.value, { kind: '고객 인도', orderId: o.id, name, done: !!o.addon?.finished }) + } + return m + }, [orders]) + + const first = new Date(month) + const pad = first.getDay() + const last = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate() + const cells: (number | null)[] = [...Array(pad).fill(null), ...Array.from({ length: last }, (_, i) => i + 1)] + const key = (d: number) => `${month.getFullYear()}-${String(month.getMonth() + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}` + const today = new Date().toISOString().slice(0, 10) + const move = (n: number) => setMonth(m => new Date(m.getFullYear(), m.getMonth() + n, 1)) + + return ( + +
+ + {tf('{0}년 {1}월', month.getFullYear(), month.getMonth() + 1)} + + + {t('차량 도착')} + {t('납기')} + {t('고객 인도')} + +
+
+ {['일', '월', '화', '수', '목', '금', '토'].map(d =>
{t(d)}
)} + {cells.map((d, i) => { + if (d === null) return
+ const day = key(d) + const list = marks.get(day) ?? [] + return ( + + ) + })} +
+ {openDay && ( + setOpenDay(null)} /> + )} + + ) +} + +/** + * 하루치 — **항목을 가로로, 고객명을 세로로** 내린다(2026-09-16 지시). + * 끝난 것은 진하게 ✓ 를 붙인다. 예정과 끝난 것을 같은 줄에 섞어 두면 무엇이 남았는지 못 읽는다. + */ +function DayModal({ day, list, isMobile, onClose }: { + day: string; list: DayMark[]; isMobile: boolean; onClose: () => void +}) { + return ( + +
+ {CAL_KINDS.map(kind => { + const hit = list.filter(x => x.kind === kind) + const dot = kind === '차량 도착' ? s.dotArrival : kind === '납기' ? s.dotDue : s.dotHandover + return ( +
+
+ {t(kind)} {hit.length} +
+ {hit.length === 0 ?
—
: ( +
    + {hit.map(m => ( +
  • + {m.name} + {m.done ? `✓ ${t('완료')}` : t('예정')} +
  • + ))} +
+ )} +
+ ) + })} +
+
+ ) +} + +const cardBase: React.CSSProperties = { + background: '#fff', border: 'var(--hairline)', borderRadius: 'var(--r-md)', + padding: 'var(--sp-4)', display: 'flex', flexDirection: 'column', gap: 'var(--sp-2)', minWidth: 0, +} +/** + * 목록이 드는 칸 — **높이를 못 박는다.** 0건이든 100건이든 카드 크기가 같아야 아래가 안 들썩인다. + * 높이는 네 줄 남짓(2026-09-16 지시로 절반으로 줄였다) — 훑는 자리지 여기서 일하는 자리가 아니다. + */ +const paneBase: React.CSSProperties = { + height: 104, overflowY: 'auto', borderTop: 'var(--hairline)', paddingTop: 4, minWidth: 0, +} + +const s: Record = { + root: { display: 'flex', flexDirection: 'column', gap: 'var(--sp-3)' }, + grid: { display: 'grid', gap: 'var(--sp-3)', alignItems: 'start' }, + card: cardBase, + cardFull: { ...cardBase, gridColumn: '1 / -1' }, + cardHead: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 'var(--sp-2)', flexWrap: 'wrap' }, + cardTitle: { fontSize: 'var(--fs-section)', fontWeight: 'var(--fw-section)' as React.CSSProperties['fontWeight'], color: 'var(--dark)' }, + headGap: { marginRight: 'auto' }, + headCount: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', fontVariantNumeric: 'tabular-nums' }, + toggle: { display: 'inline-flex', border: 'var(--hairline)', borderRadius: 999, overflow: 'hidden', background: '#fff' }, + toggleOn: { border: 'none', background: 'var(--dark)', color: '#fff', fontFamily: 'inherit', fontSize: 'var(--fs-caption)', padding: '3px 11px', cursor: 'pointer' }, + toggleOff: { border: 'none', background: 'none', color: 'var(--muted)', fontFamily: 'inherit', fontSize: 'var(--fs-caption)', padding: '3px 11px', cursor: 'pointer' }, + go: { border: 'none', background: 'none', color: 'var(--muted)', fontSize: 'var(--fs-caption)', cursor: 'pointer', fontFamily: 'inherit', padding: 0 }, + + // 한 줄을 가득 채운다. 줄(이름/숫자/아래)을 격자로 못 박아야 꺾쇠가 **숫자 줄**에 선다 + tilesRow: { display: 'grid', gridTemplateRows: 'auto auto auto', columnGap: 'var(--sp-3)', alignItems: 'center' }, + tilesMobile: { display: 'grid', gap: 'var(--sp-3)' }, + tileCell: { minWidth: 0 }, + mid: { textAlign: 'center' }, + sepArrow: { gridRow: 2, alignSelf: 'center', justifySelf: 'center', color: 'var(--muted)', fontSize: 20, lineHeight: 1 }, + sepLine: { gridRow: '1 / 4', justifySelf: 'center', width: 1, background: 'var(--line)', alignSelf: 'stretch' }, + perfVal: { fontSize: 30, fontWeight: 700, color: 'var(--dark)', fontVariantNumeric: 'tabular-nums', lineHeight: 1.2 }, + + numLabel: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }, + numVal: { fontSize: 26, fontWeight: 700, color: 'var(--dark)', fontVariantNumeric: 'tabular-nums', lineHeight: 1.2 }, + numValWarn: { fontSize: 26, fontWeight: 700, color: 'var(--req)', fontVariantNumeric: 'tabular-nums', lineHeight: 1.2 }, + numSub: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }, + // 단위(명·건)는 숫자보다 작게 — 없으면 「고객 3」과 「계약 3」이 같은 것으로 읽힌다 + unit: { fontSize: '0.5em', fontWeight: 400, color: 'var(--muted)', marginLeft: 2 }, + + headline: { display: 'flex', alignItems: 'center', gap: 'var(--sp-2)', minHeight: 36 }, + pane: paneBase, + list: { listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column' }, + listRow: { display: 'flex', alignItems: 'baseline', gap: 8, padding: '5px 0', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)' }, + listNo: { fontWeight: 700, color: 'var(--dark)', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap' }, + listName: { flex: 1, minWidth: 0, color: 'var(--body)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'left' }, + listOwner: { color: 'var(--muted)', fontSize: 'var(--fs-caption)', maxWidth: '40%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, + listSub: { color: 'var(--muted)', fontSize: 'var(--fs-caption)', whiteSpace: 'nowrap' }, + hitRow: { + display: 'flex', alignItems: 'baseline', gap: 8, padding: '6px 2px', width: '100%', + border: 'none', borderBottom: 'var(--hairline)', background: 'none', cursor: 'pointer', + fontFamily: 'inherit', fontSize: 'var(--fs-label)', textAlign: 'left', + }, + search: { + flex: 1, minWidth: 0, boxSizing: 'border-box', fontFamily: 'inherit', fontSize: 'var(--fs-input)', + padding: '7px 9px', border: 'var(--hairline)', borderRadius: 'var(--r-sm)', background: '#fff', + }, + muted: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', lineHeight: 1.6 }, + err: { fontSize: 'var(--fs-label)', color: 'var(--warn)' }, + + overlay: { + position: 'fixed', inset: 0, background: 'var(--scrim)', zIndex: 1000, + display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--sp-4)', + }, + modal: { + background: '#fff', borderRadius: 12, width: 'min(900px, 96vw)', maxHeight: '90vh', + display: 'flex', flexDirection: 'column', boxShadow: '0 10px 40px rgba(22,24,15,.22)', + }, + modalHead: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 'var(--sp-4)', borderBottom: 'var(--hairline)' }, + modalBody: { overflowY: 'auto', padding: 'var(--sp-4)' }, + close: { border: 'none', background: 'none', fontSize: 16, color: 'var(--muted)', cursor: 'pointer', width: 36, height: 36 }, + + dayGrid: { display: 'grid', gap: 'var(--sp-4)' }, + dayCol: { minWidth: 0 }, + dayColHead: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 'var(--fs-label)', fontWeight: 700, color: 'var(--dark)', paddingBottom: 4, borderBottom: '1px solid var(--dark)' }, + dayCount: { marginLeft: 'auto', color: 'var(--muted)', fontWeight: 400, fontVariantNumeric: 'tabular-nums' }, + dayRow: { display: 'flex', alignItems: 'baseline', gap: 8, padding: '6px 0', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', color: 'var(--body)' }, + dayRowDone: { display: 'flex', alignItems: 'baseline', gap: 8, padding: '6px 0', borderBottom: 'var(--hairline)', fontSize: 'var(--fs-label)', color: 'var(--dark)', fontWeight: 700 }, + + calHead: { display: 'flex', alignItems: 'center', gap: 'var(--sp-2)', flexWrap: 'wrap' }, + calNav: { border: 'var(--hairline)', background: '#fff', borderRadius: 6, width: 28, height: 28, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--dark)' }, + calMonth: { fontSize: 'var(--fs-body)', fontWeight: 700, color: 'var(--dark)', fontVariantNumeric: 'tabular-nums' }, + legend: { marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 6, fontSize: 'var(--fs-caption)', color: 'var(--muted)', flexWrap: 'wrap' }, + calGrid: { display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: 3 }, + calDow: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', textAlign: 'center', padding: '2px 0' }, + calEmpty: { minHeight: 56 }, + calDay: { minHeight: 56, border: 'var(--hairline)', borderRadius: 6, padding: '3px 4px', display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0, background: '#fff', cursor: 'pointer', fontFamily: 'inherit', alignItems: 'stretch' }, + calDayIdle: { minHeight: 56, border: 'var(--hairline)', borderRadius: 6, padding: '3px 4px', display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0, background: '#fff', cursor: 'default', fontFamily: 'inherit', alignItems: 'stretch' }, + calDayToday: { minHeight: 56, border: '1px solid var(--dark)', borderRadius: 6, padding: '3px 4px', display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0, background: '#fff', cursor: 'pointer', fontFamily: 'inherit', alignItems: 'stretch' }, + calNum: { fontSize: 'var(--fs-caption)', color: 'var(--muted)', fontVariantNumeric: 'tabular-nums', textAlign: 'left' }, + calMark: { display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--muted)', fontVariantNumeric: 'tabular-nums' }, + calMarkDone: { display: 'flex', alignItems: 'center', gap: 3, fontSize: 11, color: 'var(--dark)', fontWeight: 700, fontVariantNumeric: 'tabular-nums' }, + dotArrival: { width: 6, height: 6, borderRadius: 999, background: 'var(--dark)', display: 'inline-block' }, + dotDue: { width: 6, height: 6, borderRadius: 999, background: 'var(--lime)', display: 'inline-block' }, + dotHandover: { width: 6, height: 6, borderRadius: 999, background: 'var(--alert)', display: 'inline-block' }, +} diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 6fc21ee..bb0b3d1 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -10,6 +10,15 @@ * 원·없음·서울특별시·일반인처럼 코드가 값으로 비교하는 말도 여기 **번역은 있다**(화면용). */ export const EN: Record = { + // 영업 깔때기 — 단위(명·건)는 영어에서 낱말로 안 쓴다. 라벨이 이미 무엇을 세는지 말한다 + // (사전의 '{0}명': '{0}' 와 같은 규칙) + '상담고객': 'Consulted', + '명': '', + '건': '', + '예정': 'Planned', + '이번 달': 'This month', + '기간': 'Period', + '고객명 · 연락처 · 생년월일/사업자번호': 'Name · phone · date of birth/business no.', '특장사 단가': 'Upfitter rates', '분류': 'Assigned to', 'EV& 직접': 'EV& in-house', @@ -659,6 +668,22 @@ export const EN: Record = { '프리셋을 바꾸지 못했습니다': 'Could not change the preset', '역할 프리셋 — 관리자 안의 자리': 'Role preset — the seat inside admin', '역할 프리셋': 'Role preset', + '이번 달 영업 성과': 'This month', + '주문 진행 현황': 'Order progress', + '배정 요청 대기': 'Awaiting assignment request', + '고객 검색': 'Customer search', + '일정': 'Schedule', + '주문 진행': 'In progress', + '요청 대기': 'Awaiting request', + '영업의 배정 요청을 기다리는 건이 없습니다.': 'No quotes are waiting for a sales assignment request.', + '견적·계약서·서명본·발주서와 배정 뒤 쌓인 서류를 한자리에서 봅니다.': 'Quotes, contracts, signed copies, purchase orders, and documents gathered after assignment — all in one place.', + '대시보드를 불러오지 못했습니다': 'Could not load the dashboard', + '지난달': 'Previous month', + '다음달': 'Next month', + '일': 'Sun', + '월': 'Mon', + '화': 'Tue', + '토': 'Sat', '프리셋 구성을 바꾸지 못했습니다': 'Could not change the preset', '이 자리 기본값': 'Default for this seat', '고객 목록·서류함 조회': 'View customer list and document folders', @@ -738,7 +763,6 @@ export const EN: Record = { '완료 취소에 실패했습니다': 'Could not undo the completion', '우리 쪽 계약은 취소되어': 'Our side of the contract was canceled, so', '원': 'KRW', - '월': 'Mon', '위 내용을 확인하였으며, 개인정보 수집·이용에 동의합니다.': 'I have read the above and consent to the collection and use of my personal data.', '위임장 필요': 'Power of attorney required', '유선번호': 'Landline', @@ -776,7 +800,6 @@ export const EN: Record = { '이전': 'Previous', '이전값': 'Previous value', '인도완료': 'Delivered', - '일': 'Sun', '일반구매자': 'General buyer', '일반인': 'Individual', '일시': 'Date & time', @@ -856,7 +879,6 @@ export const EN: Record = { '주문 상세 로드 실패': 'Could not load the order details', '주문 수락 실패': 'Could not accept the order', '주문 전환 및 특장사 배정': 'Convert to order and assign an upfitter', - '주문 진행': 'Order progress', '주문 진행 조회': 'View order progress', '주문 파일 조회': 'View order files', '주문 현황': 'Order status', @@ -950,7 +972,6 @@ export const EN: Record = { '탑무게 — 냉동': 'Box weight — reefer', '탑무게 — 프레임(공통)': 'Box weight — frame (common)', '탑크기': 'Box size', - '토': 'Sat', '통풍시트, 열선 스티어링, 레인센서': 'Ventilated seats, heated steering wheel, rain-sensing wipers', '튜닝신청서_서명본.pdf': 'TuningApplication_Signed.pdf', '트림': 'Trim', @@ -1012,7 +1033,6 @@ export const EN: Record = { '형식코드': 'Model code', '홈 화면에 추가한 뒤에 켜야 알림이 옵니다': 'Add it to your Home Screen first, then turn this on to receive notifications', '홍길동': 'John Doe', - '화': 'Tue', '화물운송': 'Freight transport', '화물운송 허가': 'Trucking permit', '화물자동차 운송사업허가 보유 여부': 'Holds a trucking business permit', @@ -1216,6 +1236,10 @@ export const EN: Record = { * 쓰는 법: tf('{0} 대화', name) */ export const EN_FMT: Record = { + '{0} 일정': 'Schedule for {0}', + '{0} 일정 {1}건': '{0} — {1} items', + '{0}년 {1}월': '{1}/{0}', + '고객 검색 · {0}': 'Customer search · {0}', '역할 프리셋 · {0}': 'Role preset · {0}', '{0} 배정 요청을 거부합니다': 'Reject the assignment request for {0}', '배정 거부 · {0}': 'Assignment rejected · {0}', diff --git a/frontend/src/lib/salesFunnel.ts b/frontend/src/lib/salesFunnel.ts new file mode 100644 index 0000000..35246ae --- /dev/null +++ b/frontend/src/lib/salesFunnel.ts @@ -0,0 +1,36 @@ +import type { SalesStat } from '../api/stats' + +/** + * **영업 깔때기 — 화면이 읽는 한 벌.** + * + * 성과 탭과 관리자 대시보드가 **같은 정의**를 쓴다. 두 곳에서 따로 셈을 쓰면 + * 같은 달 숫자가 화면마다 달라진다. + * + * ⚠️ **임시저장(만든 견적 수)은 여기 넣지 않는다**(2026-09-16 지시). 견적 수와 고객 수는 + * 단위가 달라, 「607건 › 38% › 228명」이 **이탈률**로 읽혔다. 만든 견적 수는 깔때기가 아니라 + * 활동량이라 계정 이름 옆(견적 N건)에 적는다. + * + * 단위가 섞여 있다 — 일부러 그렇다(2026-09-16 지시). + * · 상담고객·견적완료는 **명** — 같은 고객에게 여러 건이 나가도 상담한 사람은 하나다 + * · 계약완료부터는 **건** — 계약·주문·인도는 차 한 대마다 따로 된 거래다 + * 그래서 숫자 옆에 단위를 **반드시** 적는다. 단위가 없으면 「명」과 「건」이 섞여 읽힌다. + */ +export interface FunnelStep { + key: string + label: string + unit: '건' | '명' + get: (s: SalesStat) => number +} + +export const FUNNEL_STEPS: FunnelStep[] = [ + { key: 'consult', label: '상담고객', unit: '명', get: s => s.customers.draft }, + { key: 'confirmed', label: '견적완료', unit: '명', get: s => s.customers.confirmed }, + { key: 'contracted', label: '계약완료', unit: '건', get: s => s.reached.contracted }, + { key: 'assigned', label: '배정완료', unit: '건', get: s => s.reached.assigned }, + { key: 'ordered', label: '주문진행', unit: '건', get: s => s.reached.ordered }, + { key: 'completed', label: '인도완료', unit: '건', get: s => s.reached.completed }, +] + +/** 대시보드 「영업 성과」 카드가 쓰는 네 칸 — 상담고객 › 계약완료 › 주문진행 › 인도완료 */ +export const DASH_STEPS: FunnelStep[] = ['consult', 'contracted', 'ordered', 'completed'] + .map(k => FUNNEL_STEPS.find(s => s.key === k)!) diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index ad36e3e..bc3321a 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -28,6 +28,7 @@ import { AcceptOrderModal } from '../components/AcceptOrderModal' import { Header } from '../components/Header' import { OrderDetail } from '../components/OrderDetail' import { OrderUnassignModal } from '../components/OrderUnassignModal' +import { AdminDashboard } from '../components/dashboard/AdminDashboard' import { AssignRejectModal } from '../components/AssignRejectModal' import { useOrderDeepLink, useViewDeepLink, type OrderDeepLink } from '../lib/deepLink' import { useBackClose } from '../lib/backClose' @@ -111,7 +112,7 @@ const MODULE_DESC: Record = { 'notify.push': '앱 알림 받기 (알림함·휴대폰 팝업)', 'notify.assign': '구 「제작 배정 알림 메일」 — 이제 역할 프리셋이 정한다(쓰지 않음)', } -type TabKey = 'quotes' | 'customers' | 'perf' | 'kanban' | 'files' | 'toggles' | 'accounts' | 'weights' | 'dims' | 'optiondb' | 'makerprice' | 'checklist' | 'holidays' +type TabKey = 'home' | 'quotes' | 'customers' | 'perf' | 'kanban' | 'files' | 'toggles' | 'accounts' | 'weights' | 'dims' | 'optiondb' | 'makerprice' | 'checklist' | 'holidays' function fmtPrice(n: number) { return n ? `₩${n.toLocaleString()}` : '—' } function fmtDate(s: string) { return s ? s.slice(0, 10) : '—' } @@ -2080,7 +2081,8 @@ export function AdminPage() { const isMobile = useIsMobile() const [modules, setModules] = useState([]) const [ac, setAc] = useState([]) - const [activeTab, setActiveTab] = useState('quotes') + /* 첫 화면 = 마이페이지(주문 조회 권한이 없으면 견적 목록) — 알림·깊은 링크는 아래에서 탭을 바꾼다 */ + const [activeTab, setActiveTab] = useState('home') const [saving, setSaving] = useState(null) /** 프리셋 구성 저장 실패 — 기능모듈 탭 위에 한 줄로 */ const [presetErr, setPresetErr] = useState('') @@ -2157,6 +2159,8 @@ export function AdminPage() { holidays: usePermission('basedata.holiday'), } const TABS: { key: TabKey; label: string; show: boolean }[] = ([ + /* 첫 자리는 **내 화면** — 자리에 맞는 카드를 모아 둔 곳(2026-09-16). 영업 화면은 컨피규레이터가 첫 자리다 */ + { key: 'home', label: t('마이페이지'), show: perm.orders }, { key: 'quotes', label: t('견적 목록'), show: true }, { key: 'customers', label: t('고객'), show: perm.customers }, { key: 'perf', label: t('영업 성과'), show: perm.stats }, @@ -2197,6 +2201,7 @@ export function AdminPage() {
+ {activeTab === 'home' && setActiveTab(tab)} />} {activeTab === 'quotes' && } {activeTab === 'customers' && } {activeTab === 'perf' && } diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index d4dc33a..dc4ea32 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -33,6 +33,19 @@ export function LoginPage() { } }, [session, loading, navigate]) + /* + * **로컬에서는 그냥 들어간다**(2026-09-16 지시 — 개발할 때 비밀번호를 치지 않게). + * + * ⚠️ 이 코드는 **개발 서버에서만 산다.** `import.meta.env.DEV` 는 빌드할 때 false 로 굳어 + * 운영 번들에서는 이 블록이 통째로 사라진다. 서버 쪽도 운영에는 그 경로가 없다(routes/dev-auth.ts). + */ + useEffect(() => { + if (!import.meta.env.DEV || loading || session) return + if (window.location.hostname !== 'localhost') return + const to = `${window.location.origin}/admin` + window.location.replace(`http://localhost:3001/api/v1/dev/login?email=master@local&to=${encodeURIComponent(to)}`) + }, [session, loading]) + async function handleSubmit(e: React.FormEvent) { e.preventDefault() if (!email.trim() || !password) { setError(t('이메일과 비밀번호를 입력해 주세요.')); return }