diff --git a/backend/src/__tests__/pnl.test.ts b/backend/src/__tests__/pnl.test.ts index 49a3f09..f99860d 100644 --- a/backend/src/__tests__/pnl.test.ts +++ b/backend/src/__tests__/pnl.test.ts @@ -208,6 +208,36 @@ describe.runIf(live)('손익 — 세금계산서 발행일이 달을 정한다', expect((await request(app).post(`/api/v1/pnl/${id}/void`).set('Cookie', looker).send({ reason: 'x' })).status).toBe(403); }, 30_000); + it('🔴 요약 — 이번 달과 전체를 한 번에 주고, 삭제한 줄은 빼고 센다', async () => { + const ym = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().slice(0, 7); + const before = await request(app).get('/api/v1/pnl/summary').set('Cookie', keeper); + expect(before.status, JSON.stringify(before.body)).toBe(200); + expect(before.body.data.month).toBe(ym); + const m0 = before.body.data.month_total.count as number; + const a0 = before.body.data.all_total.count as number; + + // ① 이번 달에 한 건 — 둘 다 하나씩 는다 + const now = await acceptedQuote(); + await request(app).put(`/api/v1/pnl/${now}`).set('Cookie', keeper).send({ invoice_on: `${ym}-15` }); + // ② 아주 지난 달에 한 건 — **전체만** 는다 + const old = await acceptedQuote(); + await request(app).put(`/api/v1/pnl/${old}`).set('Cookie', keeper).send({ invoice_on: '2020-03-04' }); + + const after = await request(app).get('/api/v1/pnl/summary').set('Cookie', keeper); + expect(after.body.data.month_total.count, '이번 달에 지난 달 건이 섞였다').toBe(m0 + 1); + expect(after.body.data.all_total.count, '전체가 지난 달 건을 놓쳤다').toBe(a0 + 2); + + // ③ 삭제하면 둘 다 빠진다 — 줄은 표에 남지만 숫자는 아니다 + await request(app).post(`/api/v1/pnl/${now}/void`).set('Cookie', keeper).send({ reason: '시험' }); + const dead = await request(app).get('/api/v1/pnl/summary').set('Cookie', keeper); + expect(dead.body.data.month_total.count).toBe(m0); + expect(dead.body.data.all_total.count).toBe(a0 + 1); + }, 60_000); + + it('🔴 요약도 보기 권한이 있어야 본다', async () => { + expect((await request(app).get('/api/v1/pnl/summary').set('Cookie', outside)).status).toBe(403); + }, 30_000); + it('🔴 없는 견적에는 줄을 만들지 않는다', async () => { const r = await request(app).put('/api/v1/pnl/99999999').set('Cookie', keeper).send({ invoice_on: '2026-09-13' }); expect(r.status).toBe(404); diff --git a/backend/src/routes/pnl.ts b/backend/src/routes/pnl.ts index c9e7b15..88944cb 100644 --- a/backend/src/routes/pnl.ts +++ b/backend/src/routes/pnl.ts @@ -10,7 +10,7 @@ import { Router } from 'express'; import type { Request, Response } from 'express'; import { rbac, requirePermission } from '../middleware/rbac.js'; import { isYearMonth } from '@buildup-ev/shared/finance/pnl'; -import { pnlOfMonth, pnlPending, pnlMonths, savePnl, voidPnl, unvoidPnl, type PnlPatch } from '../services/pnl.js'; +import { pnlOfMonth, pnlPending, pnlMonths, pnlSummary, savePnl, voidPnl, unvoidPnl, type PnlPatch } from '../services/pnl.js'; export const pnlRouter = Router(); @@ -38,6 +38,15 @@ pnlRouter.get('/', ...view, async (req: Request, res: Response): Promise = } catch (e) { fail(res, 'GET /', e); } }); +// ── GET /pnl/summary — 대시보드 카드용 요약(이번 달 + 전체) ──────────────── +// ⚠️ `/:quoteId` 보다 **먼저** 둔다. 뒤에 두면 'summary' 가 견적 id 로 읽힌다 +pnlRouter.get('/summary', ...view, async (req: Request, res: Response): Promise => { + try { + const q = req.query['month']; + res.json({ data: await pnlSummary(isYearMonth(q) ? q : thisMonth()) }); + } catch (e) { fail(res, 'GET /summary', e); } +}); + // ── PUT /pnl/:quoteId — 줄 하나를 적는다(없으면 만든다) ────────────────────── pnlRouter.put('/:quoteId', ...manage, async (req: Request, res: Response): Promise => { const quoteId = Number(req.params['quoteId']); diff --git a/backend/src/services/pnl.ts b/backend/src/services/pnl.ts index 385286c..fa65921 100644 --- a/backend/src/services/pnl.ts +++ b/backend/src/services/pnl.ts @@ -10,7 +10,7 @@ */ import { prisma } from '../lib/prisma.js'; import { calcQuote } from '@buildup-ev/shared/pricing'; -import { monthBounds, supplyFromGross } from '@buildup-ev/shared/finance/pnl'; +import { monthBounds, supplyFromGross, sumP } from '@buildup-ev/shared/finance/pnl'; import { buildQuoteParams, quoteExtraFromInputs, type CustomerInput } from './quote-calc.js'; import { VISIBLE } from '../lib/visibility.js'; @@ -299,6 +299,45 @@ async function readPnl(quoteId: number): Promise { return saved ? toRow(saved as unknown as RowWithQuote) : null; } +/** + * **요약만** — 대시보드 카드가 쓴다. + * + * 이번 달과 전체를 **한 번에** 준다. 토글을 누를 때마다 다시 부르면 기다림이 생기는데, + * 요약은 줄을 싣지 않아 가볍다(줄 수백 개를 내려보내고 화면에서 더하는 쪽이 오히려 무겁다). + * + * ⚠️ 삭제한 줄은 빼고 센다. 셈은 손익 탭과 **같은 함수**(`sumP`)다 — 두 화면 숫자가 갈리면 안 된다. + */ +export interface PnlSummary { + month: string; + /** 발행일을 아직 안 적은 건 수 — 카드에서 「지금 할 일」이다 */ + pending: number; + month_total: ReturnType; + all_total: ReturnType; +} + +export async function pnlSummary(ym: string): Promise { + const empty = sumP([]); + if (!prisma) return { month: ym, pending: 0, month_total: empty, all_total: empty }; + const { from, to } = monthBounds(ym); + const pick = { supply_amount: true, deposit: true, capital: true, cost: true, invoice_on: true } as const; + const [rows, pending] = await Promise.all([ + // 삭제한 줄과 발행일이 없는 줄은 어느 달의 숫자도 아니다 + prisma.orderPnl.findMany({ where: { voided_at: null, invoice_on: { not: null } }, select: pick }), + pnlPending(), + ]); + const num = (r: typeof rows[number]) => ({ + supply_amount: n(r.supply_amount), deposit: n(r.deposit), capital: n(r.capital), cost: n(r.cost), + }); + const inMonth = (r: typeof rows[number]) => + !!r.invoice_on && r.invoice_on >= new Date(from) && r.invoice_on < new Date(to); + return { + month: ym, + pending: pending.length, + month_total: sumP(rows.filter(inMonth).map(num)), + all_total: sumP(rows.map(num)), + }; +} + /** 줄이 있는 달들 — 화면의 달 고르개가 「있는 달」만 보여 준다 */ export async function pnlMonths(): Promise { if (!prisma) return []; diff --git a/frontend/src/api/pnl.ts b/frontend/src/api/pnl.ts index fc0b0c2..9d9f47c 100644 --- a/frontend/src/api/pnl.ts +++ b/frontend/src/api/pnl.ts @@ -63,6 +63,32 @@ async function jsonOf(res: Response): Promise { return (await res.json() as { data: T }).data } +/** 합계 한 벌 — 손익 탭의 합계와 같은 셈이다(서버가 같은 함수로 낸다) */ +export interface PnlTotals { + count: number + supply_amount: number + vat: number + gross: number + deposit: number + capital: number + pay_diff: number + cost: number + profit: number + margin: number | null +} + +/** 대시보드 카드용 — 이번 달과 전체를 **한 번에** 받는다(토글이 기다리지 않게) */ +export interface PnlSummary { + month: string + pending: number + month_total: PnlTotals + all_total: PnlTotals +} + +export function fetchPnlSummary(): Promise { + return fetch('/api/v1/pnl/summary', { credentials: 'include' }).then(jsonOf) +} + export function fetchPnl(month?: string): Promise { const qs = month ? `?month=${encodeURIComponent(month)}` : '' return fetch(`/api/v1/pnl${qs}`, { credentials: 'include' }).then(jsonOf) diff --git a/frontend/src/components/dashboard/AdminDashboard.tsx b/frontend/src/components/dashboard/AdminDashboard.tsx index 80f5f09..087406e 100644 --- a/frontend/src/components/dashboard/AdminDashboard.tsx +++ b/frontend/src/components/dashboard/AdminDashboard.tsx @@ -4,8 +4,7 @@ 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 { fetchPnl, type PnlView } from '../../api/pnl' -import { sumP } from '@shared/finance/pnl' +import { fetchPnlSummary, type PnlSummary, type PnlTotals } from '../../api/pnl' import type { ApiOrder, ApiQuote } from '@shared/types/index' import { buildDashboard } from '../../lib/orderDashboard' import { DASH_STEPS } from '../../lib/salesFunnel' @@ -132,14 +131,19 @@ export function AdminDashboard({ onGo }: { )} {err &&
{err}
}
- {shows('perf') && onGo('perf')} />} - {shows('pnl') && onGo('pnl')} />} - {shows('progress') && onGo('kanban')} />} - {shows('assignQueue') && onGo('kanban')} />} - {shows('assignRequest') && } - {shows('late') && onGo('kanban')} />} - {shows('customer') && } - {shows('calendar') && } + {/* + ⚠️ **열쇠를 못 박는다.** 판(영업관리·생산관리·경영관리)마다 카드 묶음이 달라, 열쇠가 없으면 + React 가 자리로 짝을 맞춘다 — 같은 카드인데 자리가 달라졌다는 이유로 **다시 만들어져** + 빈 채로 떴다가 채워진다. 판을 바꿀 때 화면이 일그러지던 원인이다(제보). + */} + {shows('perf') && onGo('perf')} />} + {shows('pnl') && onGo('pnl')} />} + {shows('progress') && onGo('kanban')} />} + {shows('assignQueue') && onGo('kanban')} />} + {shows('assignRequest') && } + {shows('late') && onGo('kanban')} />} + {shows('customer') && } + {shows('calendar') && }
) @@ -154,6 +158,24 @@ function monthStart(): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01` } +/** + * 값이 오기 전의 **자리표** — 이름은 그대로 두고 숫자만 「—」로 둔다. + * + * 높이를 숫자로 짐작해 맞추면(minHeight) 화면 폭마다 어긋난다 — 휴대폰에서는 칸이 접혀 키가 달라진다. + * **같은 뼈대를 그리는 것**만이 어느 폭에서든 정확히 같은 자리를 차지한다. + * 그래야 값이 들어올 때 카드가 커지지 않고, 아래 카드들이 밀려 올라갔다 내려오지 않는다(제보). + */ +const skeleton = (tiles: Tile[]): Tile[] => + tiles.map(x => ({ ...x, n: 0, unit: '', text: '—', warn: false, profit: false, sub: x.sub === undefined ? undefined : '\u00a0' })) + +/** 빈 값 — 자리표를 그릴 때만 쓴다(숫자는 어차피 「—」로 덮인다) */ +const BLANK_SALES = { + sales_user_id: '', reached: {}, customers: {}, amount: { confirmed: 0, contracted: 0, completed: 0 }, + activity: { quotes: 0, emailed: 0, sign_requested: 0, edits: 0 }, +} as unknown as SalesStat +const BLANK_TOTALS = { count: 0, supply_amount: 0, vat: 0, gross: 0, deposit: 0, capital: 0, pay_diff: 0, cost: 0, profit: 0, margin: null } as PnlTotals +const BLANK_DASH = { assign: [], pending: [], active: [], addon: [], done: [], late: [] } as unknown as ReturnType + /** * 카드 한 장 — 제목 줄과 본문. 누를 수 있는 카드는 제목 오른쪽에 「열기」. * @@ -163,7 +185,13 @@ function monthStart(): string { */ function Card({ title, extra, full, onGo, children }: { title: string - /** 제목 오른쪽에 붙는 조작 — 지금은 성과 카드의 기간 토글뿐이다 */ + /** + * 카드의 조작·곁수치(기간 토글·건수) — **오른쪽 끝, 「열기」 바로 왼쪽**에 붙는다. + * + * ⚠️ 제목 옆에 두면 **카드마다 자리가 달라진다** — 「영업 성과」와 「손익」은 제목 길이가 달라 + * 토글이 좌우로 어긋나 보였다(제보). 오른쪽에 붙이면 제목이 몇 글자든 늘 같은 자리다. + * 앞으로 붙는 카드도 이 자리를 쓴다. + */ extra?: React.ReactNode /** 한 줄을 통째로 쓰는 카드 */ full?: boolean @@ -174,8 +202,8 @@ function Card({ title, extra, full, onGo, children }: {
{title} - {extra} + {extra} {onGo && }
{children} @@ -284,6 +312,7 @@ function TileRow({ tiles, isMobile, mobileCols, big, small }: { function PerfCard({ isMobile, onGo }: { isMobile: boolean; onGo: () => void }) { const [scope, setScope] = useState('month') const [shown, setShown] = useState(null) + const [busy, setBusy] = useState(false) /** 이미 부른 기간은 다시 부르지 않는다 — 토글을 오갈 때마다 기다리게 하지 않는다 */ const cache = useRef(new Map()) @@ -291,10 +320,17 @@ function PerfCard({ isMobile, onGo }: { isMobile: boolean; onGo: () => void }) { const hit = cache.current.get(scope) if (hit) { setShown(hit); return } let alive = true - setShown(null) + /* + * ⚠️ **앞의 숫자를 지우지 않는다**(제보: 토글을 누르면 화면이 순간 일그러진다). + * 지우면 카드 속이 「불러오는 중」 한 줄로 줄었다가 도로 커지는데, 그 한 번의 들썩임에 + * 아래 카드들이 통째로 밀려 올라갔다 내려온다. 새 숫자가 올 때까지 옛 숫자를 그대로 두면 + * **높이가 변하지 않아** 아무것도 움직이지 않는다. + */ + setBusy(true) fetchSalesStats(scope === 'month' ? { from: monthStart() } : {}) .then(r => { cache.current.set(scope, r.total); if (alive) setShown(r.total) }) .catch(() => { /* 카드 하나가 안 떠도 나머지는 보여야 한다 */ }) + .finally(() => { if (alive) setBusy(false) }) return () => { alive = false } }, [scope]) @@ -304,20 +340,24 @@ function PerfCard({ isMobile, onGo }: { isMobile: boolean; onGo: () => void }) { if (key === 'completed') return won(st.amount.completed) return '' } - const tiles: Tile[] = shown ? DASH_STEPS.map((step, i) => ({ + const make = (st: SalesStat): Tile[] => DASH_STEPS.map((step, i) => ({ key: step.key, label: step.label, unit: step.unit, - n: step.get(shown), sub: sub(step.key, shown), + n: step.get(st), sub: sub(step.key, st), // 깔때기의 끝 — **인도 완료가 결론**이다. 손익의 수익과 같은 색으로 둔다(2026-09-16 지시) ...(step.key === 'completed' ? { profit: true } : {}), ...(i > 0 ? { sep: 'arrow' as const } : {}), - })) : [] + })) + // 값이 오기 전에도 같은 뼈대를 그린다 — 카드 키가 안 변하니 아래가 들썩이지 않는다 + const tiles = shown ? make(shown) : skeleton(make(BLANK_SALES)) return ( } > - {!shown ?
{t('불러오는 중…')}
- : } + {/* 바꾸는 중에도 자리를 지킨다 — aria-busy 로만 알리고 눈에 보이는 것은 그대로 둔다 */} +
+ +
) } @@ -339,37 +379,39 @@ function ScopeToggle({ scope, onChange }: { scope: Scope; onChange: (v: Scope) = } /** - * **이번 달 손익** — 경영관리가 매일 먼저 보는 자리(2026-09-16 지시). + * **손익** — 경영관리가 매일 먼저 보는 자리(2026-09-16 지시). 영업 성과처럼 **이번 달/전체**를 오간다. * * 앞에 「입력 필요」를 둔다. 손익표는 보는 표이기 전에 **적는 표**라, 적지 않은 건이 몇인지가 * 먼저 눈에 들어와야 한다 — 발행일을 안 적으면 그 달 숫자 자체가 거짓이 된다. - * 결론인 수익·수익률은 브랜드 라임으로 키운다(손익 탭의 요약과 같은 말을 한다). + * 입력 필요는 달과 상관없다(아직 어느 달에도 안 들어간 건이다) — 토글을 눌러도 그대로다. * - * 삭제한 줄은 빼고 센다 — 손익 탭의 합계와 한 글자도 달라서는 안 된다(같은 함수를 쓴다). + * 이번 달과 전체를 **한 번에 받아** 두고 화면에서만 바꾼다 — 누를 때마다 기다리지 않게. + * 삭제한 줄은 빼고 센다. 셈은 손익 탭과 같은 함수라 두 화면 숫자가 갈릴 수 없다. */ function PnlCard({ isMobile, onGo }: { isMobile: boolean; onGo: () => void }) { - const [view, setView] = useState(null) + const [scope, setScope] = useState('month') + const [sum, setSum] = useState(null) useEffect(() => { let alive = true - fetchPnl().then(v => { if (alive) setView(v) }).catch(() => { /* 카드 하나가 안 떠도 나머지는 보여야 한다 */ }) + fetchPnlSummary().then(v => { if (alive) setSum(v) }).catch(() => { /* 카드 하나가 안 떠도 나머지는 보여야 한다 */ }) return () => { alive = false } }, []) - const total = useMemo(() => sumP((view?.rows ?? []).filter(r => !r.voided_at)), [view]) - const loss = total.profit < 0 - const tiles: Tile[] = view ? [ - { key: 'todo', label: '입력 필요', n: view.pending.length, unit: '건', warn: true }, - { key: 'count', label: '발행', n: total.count, unit: '건', sep: 'divider' }, - { key: 'gross', label: '공급대가', n: 0, unit: '', text: won(total.gross) }, - { key: 'cost', label: '원가', n: 0, unit: '', text: won(total.cost) }, - { key: 'profit', label: '수익', n: 0, unit: '', text: won(total.profit), profit: !loss, warn: loss, sep: 'arrow' }, - { key: 'margin', label: '수익률', n: 0, unit: '', text: total.margin === null ? '—' : `${(total.margin * 100).toFixed(1)}%`, profit: !loss, warn: loss }, - ] : [] + const total: PnlTotals | null = sum ? (scope === 'month' ? sum.month_total : sum.all_total) : null + const loss = !!total && total.profit < 0 + const make = (v: PnlTotals): Tile[] => [ + { key: 'todo', label: '입력 필요', n: sum?.pending ?? 0, unit: '건', warn: true }, + { key: 'count', label: '발행', n: v.count, unit: '건', sep: 'divider' }, + { key: 'gross', label: '공급대가', n: 0, unit: '', text: won(v.gross) }, + { key: 'cost', label: '원가', n: 0, unit: '', text: won(v.cost) }, + { key: 'profit', label: '수익', n: 0, unit: '', text: won(v.profit), profit: !loss, warn: loss, sep: 'arrow' }, + { key: 'margin', label: '수익률', n: 0, unit: '', text: v.margin === null ? '—' : `${(v.margin * 100).toFixed(1)}%`, profit: !loss, warn: loss }, + ] + const tiles = total ? make(total) : skeleton(make(BLANK_TOTALS)) return ( - - {!view ?
{t('불러오는 중…')}
- : } + }> + ) } @@ -387,19 +429,19 @@ function ProgressCard({ orders, contracted, isMobile, onGo }: { * 「납기일 경과」는 다음 단계가 아니라 **어디에 있든 늦은 건**이라, 꺾쇠 대신 세로선으로 끊는다 — * 꺾쇠로 이으면 인도 완료 다음에 납기일 경과가 오는 것처럼 읽힌다. */ - 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' }, + const make = (d: NonNullable): Tile[] => ([ + { key: 'assign', label: '배정 대기', n: d.assign.length }, + { key: 'pending', label: '수락 대기', n: d.pending.length, sep: 'arrow' }, + { key: 'active', label: '특장 진행', n: d.active.length, sep: 'arrow' }, + { key: 'addon', label: '부가 작업', n: d.addon.length, sep: 'arrow' }, // 여기서도 끝이 결론이다 — 영업 성과·손익과 같은 색 - { key: 'done', label: '인도 완료', n: dash.done.length, sep: 'arrow', profit: true }, - { key: 'late', label: '납기일 경과', n: dash.late.length, warn: true, sep: 'divider' }, - ] as Omit[]).map(x => ({ ...x, unit: '건' })) : [] + { key: 'done', label: '인도 완료', n: d.done.length, sep: 'arrow', profit: true }, + { key: 'late', label: '납기일 경과', n: d.late.length, warn: true, sep: 'divider' }, + ] as Omit[]).map(x => ({ ...x, unit: '건' })) + const tiles = dash ? make(dash) : skeleton(make(BLANK_DASH)) return ( - {!dash ?
{t('불러오는 중…')}
- : } +
) } @@ -754,6 +796,13 @@ const calCell: React.CSSProperties = { background: '#fff', fontFamily: 'inherit', alignItems: 'stretch', } +/** 토글 한 칸 — `flex: 1 1 0` 이라야 글자 수와 상관없이 **정확히 반**이다(basis 를 0 으로 둔다) */ +const toggleSeg: React.CSSProperties = { + flex: '1 1 0', minWidth: 0, border: 'none', cursor: 'pointer', + fontFamily: 'inherit', fontSize: 'var(--fs-caption)', padding: '4px 0', + textAlign: 'center', whiteSpace: 'nowrap', +} + 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, @@ -771,13 +820,19 @@ const s: Record = { 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' }, + cardHead: { display: 'flex', alignItems: 'center', gap: 'var(--sp-3)', flexWrap: 'wrap' }, cardTitle: { fontSize: 'var(--fs-section)', fontWeight: 'var(--fw-section)' as React.CSSProperties['fontWeight'], color: 'var(--dark)' }, - headGap: { marginRight: 'auto' }, + // 제목과 오른쪽 묶음(토글·곁수치·열기) 사이를 벌린다 — 오른쪽은 늘 같은 자리에 선다 + headGap: { flex: 1, minWidth: 0 }, 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' }, + /** + * 기간 토글 — **칸을 정확히 반으로 가른다.** + * 글자 수에 맞춰 넓이를 잡으면(「이번 달」 3자 · 「전체」 2자) 가르는 선이 가운데가 아니게 되고, + * 카드마다 토글 모양이 달라 보인다(제보). 넓이를 못 박고 두 칸이 똑같이 나눠 갖는다. + */ + toggle: { display: 'inline-flex', width: 132, flexShrink: 0, border: 'var(--hairline)', borderRadius: 999, overflow: 'hidden', background: '#fff' }, + toggleOn: { ...toggleSeg, background: 'var(--dark)', color: '#fff' }, + toggleOff: { ...toggleSeg, background: 'none', color: 'var(--muted)' }, go: { border: 'none', background: 'none', color: 'var(--muted)', fontSize: 'var(--fs-caption)', cursor: 'pointer', fontFamily: 'inherit', padding: 0 }, // 한 줄을 가득 채운다. 줄(이름/숫자/아래)을 격자로 못 박아야 꺾쇠가 **숫자 줄**에 선다 @@ -814,7 +869,8 @@ const s: Record = { listSub: { color: 'var(--muted)', fontSize: 'var(--fs-caption)', whiteSpace: 'nowrap' }, listLate: { color: 'var(--req)', fontSize: 'var(--fs-caption)', fontWeight: 700, whiteSpace: 'nowrap' }, tagWarn: { color: 'var(--req)', fontSize: 'var(--fs-caption)', border: '1px solid var(--req)', borderRadius: 4, padding: '0 4px', whiteSpace: 'nowrap' }, - boardBar: { display: 'inline-flex', border: 'var(--hairline)', borderRadius: 999, overflow: 'hidden', background: '#fff', alignSelf: 'flex-start' }, + // 마이페이지 고르개도 같은 규칙 — 칸을 똑같이 나눈다 + boardBar: { display: 'inline-flex', width: 264, border: 'var(--hairline)', borderRadius: 999, overflow: 'hidden', background: '#fff', alignSelf: 'flex-start' }, hitRow: { display: 'flex', alignItems: 'baseline', gap: 8, padding: '6px 2px', width: '100%', border: 'none', borderBottom: 'var(--hairline)', background: 'none', cursor: 'pointer',