diff --git a/frontend/src/components/dashboard/AdminDashboard.tsx b/frontend/src/components/dashboard/AdminDashboard.tsx
index c0678ee..80f5f09 100644
--- a/frontend/src/components/dashboard/AdminDashboard.tsx
+++ b/frontend/src/components/dashboard/AdminDashboard.tsx
@@ -4,6 +4,8 @@ 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 type { ApiOrder, ApiQuote } from '@shared/types/index'
import { buildDashboard } from '../../lib/orderDashboard'
import { DASH_STEPS } from '../../lib/salesFunnel'
@@ -34,7 +36,7 @@ import { useAuth } from '../../contexts/AuthContext'
* · 성과·진행 현황·일정은 **한 줄을 통째로** 쓴다. 숫자 칸은 남는 폭을 나눠 가진다
* · 목록이 드는 카드는 **칸 높이를 못 박고 목록만 구른다** — 0건이든 100건이든 카드 크기가 같아야 아래가 안 들썩인다
*/
-type CardKey = 'perf' | 'progress' | 'assignRequest' | 'assignQueue' | 'late' | 'customer' | 'calendar'
+type CardKey = 'perf' | 'progress' | 'assignRequest' | 'assignQueue' | 'late' | 'customer' | 'calendar' | 'pnl'
export interface DashboardDef {
/** 역할 프리셋 코드(shared/rbac/presets) */
@@ -53,8 +55,9 @@ export const DASHBOARDS: DashboardDef[] = [
{ code: 'sales_mgr', label: '영업관리', cards: ['perf', 'progress', 'assignRequest', 'customer', 'calendar'] },
// 제작을 굴린다 — 지금 배정할 건과 납기가 급한 건. 성과·고객은 이 자리의 일이 아니다
{ code: 'prod_mgr', label: '생산관리', cards: ['progress', 'assignQueue', 'late', 'calendar'] },
- // 보기만 한다 — 숫자와 일정. 손대는 카드(배정·고객 서류)는 넣지 않는다
- { code: 'exec', label: '경영관리', cards: ['perf', 'progress', 'calendar'] },
+ // 숫자를 본다 — 성과와 손익. 손대는 카드(배정·고객 서류)는 넣지 않는다.
+ // 손익만은 **직접 적는 자리**라(세금계산서 발행일·입금·원가) 할 일 수를 앞에 둔다
+ { code: 'exec', label: '경영관리', cards: ['perf', 'pnl', 'progress', 'calendar'] },
]
/** 이 계정이 볼 수 있는 마이페이지들 — 마스터는 전부, 나머지는 지정된 프리셋 하나(없으면 빈 목록) */
@@ -66,7 +69,7 @@ export function dashboardsFor(user: { is_master?: boolean; admin_preset?: string
export function AdminDashboard({ onGo }: {
/** 카드를 눌렀을 때 옮겨 갈 탭 — 대시보드는 요약만 보여 주고 일은 원래 화면에서 한다 */
- onGo: (tab: 'perf' | 'kanban') => void
+ onGo: (tab: 'perf' | 'kanban' | 'pnl') => void
}) {
const isMobile = useIsMobile()
const { session } = useAuth()
@@ -84,9 +87,11 @@ export function AdminDashboard({ onGo }: {
const canOrders = usePermission('order.view')
const canAssign = usePermission('order.confirm')
const canCustomer = usePermission('customer.view')
+ const canPnl = usePermission('pnl.view')
const shows = (key: CardKey): boolean => {
if (!board?.cards.includes(key)) return false
if (key === 'perf') return canPerf
+ if (key === 'pnl') return canPnl
if (key === 'customer') return canCustomer
if (key === 'assignRequest' || key === 'assignQueue') return canAssign
return canOrders
@@ -128,6 +133,7 @@ export function AdminDashboard({ onGo }: {
{err &&
{err}
}
{shows('perf') &&
onGo('perf')} />}
+ {shows('pnl') && onGo('pnl')} />}
{shows('progress') && onGo('kanban')} />}
{shows('assignQueue') && onGo('kanban')} />}
{shows('assignRequest') && }
@@ -190,6 +196,10 @@ interface Tile {
n: number
/** 명 · 건 */
unit: string
+ /** 숫자 대신 적을 글자(금액·비율처럼 수로 못 적는 것). 있으면 이쪽을 쓴다 */
+ text?: string
+ /** 결론 숫자 — 브랜드 라임으로 키운다(컨피규레이터 실구매가와 같은 색) */
+ profit?: boolean
/** 숫자 아래 한 줄(금액·건수). 자리를 비워도 줄 높이는 지킨다 */
sub?: string
warn?: boolean
@@ -208,11 +218,24 @@ interface Tile {
* 「이름 + 숫자 + 아래 줄」 전체의 한가운데에 서서, 정작 맞춰야 할 **숫자 줄과 어긋났다**(제보).
* 그래서 격자로 바꾸고 줄(이름/숫자/아래)과 칸을 좌표로 못 박는다 — 꺾쇠는 숫자 줄에만 놓인다.
*/
-function TileRow({ tiles, isMobile, mobileCols, big }: {
- tiles: Tile[]; isMobile: boolean; mobileCols: number; big?: boolean
+function TileRow({ tiles, isMobile, mobileCols, big, small }: {
+ tiles: Tile[]; isMobile: boolean; mobileCols: number
+ /** 첫 카드(영업 성과) — 한 단계 크게 */
+ big?: boolean
+ /** 금액이 섞인 줄 — **한 단계 작게.** 자릿수가 길어 큰 글씨로는 칸끼리 붙어 읽힌다(제보) */
+ small?: boolean
}) {
// ⚠️ 칸 변수를 t 로 두지 말 것 — 번역 함수 t() 를 가려 그 블록만 한국어가 남는다
- const numStyle = (x: Tile) => (x.warn && x.n > 0 ? s.numValWarn : big ? s.perfVal : s.numVal)
+ // 글자로 적는 칸(금액·비율)은 셀 수가 없다 — 경고는 「0보다 큰가」가 아니라 **경고로 표시했는가**로 본다
+ const numStyle = (x: Tile) => {
+ const base = x.warn && (x.text !== undefined || x.n > 0) ? s.numValWarn
+ : x.profit ? (big ? s.perfValProfit : s.numValProfit)
+ : big ? s.perfVal : s.numVal
+ return small ? { ...base, ...s.smNum } : base
+ }
+ const value = (x: Tile) => (x.text !== undefined
+ ? {x.text}
+ : <>{x.n}{t(x.unit)}>)
if (isMobile) {
return (
@@ -220,7 +243,7 @@ function TileRow({ tiles, isMobile, mobileCols, big }: {
{tiles.map(x => (
{t(x.label)}
-
{x.n}{t(x.unit)}
+
{value(x)}
{x.sub !== undefined &&
{x.sub}
}
))}
@@ -238,9 +261,7 @@ function TileRow({ tiles, isMobile, mobileCols, big }: {
{/* 가운데 정렬이라야 꺾쇠가 **두 숫자의 정확히 한가운데**에 선다 — 왼쪽으로 붙이면
칸마다 글자 너비가 달라 꺾쇠가 뒷 숫자에 딸려 붙은 것처럼 보인다(제보) */}
{t(x.label)}
-
- {x.n}{t(x.unit)}
-
+ {value(x)}
{x.sub ?? ''}
))}
@@ -286,6 +307,8 @@ function PerfCard({ isMobile, onGo }: { isMobile: boolean; onGo: () => void }) {
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),
+ // 깔때기의 끝 — **인도 완료가 결론**이다. 손익의 수익과 같은 색으로 둔다(2026-09-16 지시)
+ ...(step.key === 'completed' ? { profit: true } : {}),
...(i > 0 ? { sep: 'arrow' as const } : {}),
})) : []
return (
@@ -315,6 +338,42 @@ function ScopeToggle({ scope, onChange }: { scope: Scope; onChange: (v: Scope) =
)
}
+/**
+ * **이번 달 손익** — 경영관리가 매일 먼저 보는 자리(2026-09-16 지시).
+ *
+ * 앞에 「입력 필요」를 둔다. 손익표는 보는 표이기 전에 **적는 표**라, 적지 않은 건이 몇인지가
+ * 먼저 눈에 들어와야 한다 — 발행일을 안 적으면 그 달 숫자 자체가 거짓이 된다.
+ * 결론인 수익·수익률은 브랜드 라임으로 키운다(손익 탭의 요약과 같은 말을 한다).
+ *
+ * 삭제한 줄은 빼고 센다 — 손익 탭의 합계와 한 글자도 달라서는 안 된다(같은 함수를 쓴다).
+ */
+function PnlCard({ isMobile, onGo }: { isMobile: boolean; onGo: () => void }) {
+ const [view, setView] = useState(null)
+ useEffect(() => {
+ let alive = true
+ fetchPnl().then(v => { if (alive) setView(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 },
+ ] : []
+
+ return (
+
+ {!view ? {t('불러오는 중…')}
+ : }
+
+ )
+}
+
/** 지금 어디까지 왔나 — 「주문 진행」 현황판의 요약 칸과 같은 셈(lib/orderDashboard) */
function ProgressCard({ orders, contracted, isMobile, onGo }: {
orders: ApiOrder[] | null; contracted: ApiQuote[] | null; isMobile: boolean; onGo: () => void
@@ -333,7 +392,8 @@ function ProgressCard({ orders, contracted, isMobile, onGo }: {
{ 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: '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: '건' })) : []
return (
@@ -682,6 +742,18 @@ function DayModal({ day, list, isMobile, onClose }: {
)
}
+/**
+ * 달력 한 칸 — **PC 에서 글자가 너무 작았다**(제보). 휴대폰에서는 칸이 좁아 작아도 읽히지만,
+ * 넓은 화면에서는 같은 크기가 먼지처럼 보인다. 칸을 키우고 글자도 앱의 라벨 크기(`--fs-label`)로 올린다.
+ * 그 크기는 휴대폰에서 더 커지므로(토큰이 기기별로 다르다) 따로 가르지 않아도 된다.
+ */
+const CAL_H = 74
+const calCell: React.CSSProperties = {
+ minHeight: CAL_H, border: 'var(--hairline)', borderRadius: 6, padding: '5px 6px',
+ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0,
+ background: '#fff', fontFamily: 'inherit', alignItems: 'stretch',
+}
+
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,
@@ -720,6 +792,14 @@ const s: Record = {
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 },
+ // 결론 숫자 — 컨피규레이터 실구매가와 같은 색. 흰 바탕 대비가 낮아 **큰 글씨 전용**이다
+ numValProfit: { fontSize: 26, fontWeight: 700, color: 'var(--lime-ink)', fontVariantNumeric: 'tabular-nums', lineHeight: 1.2 },
+ perfValProfit: { fontSize: 30, fontWeight: 700, color: 'var(--lime-ink)', fontVariantNumeric: 'tabular-nums', lineHeight: 1.2 },
+ // 금액은 자릿수가 길다 — 숫자 칸보다 한 단계 작게 잡아야 옆 칸을 안 밀친다
+ tileText: { fontSize: '0.72em', fontWeight: 700, whiteSpace: 'nowrap' },
+ // 금액이 섞인 줄 전체를 한 단계 줄인다 — 큰 글씨로는 칸끼리 붙어 어느 숫자가 어느 이름인지 안 보인다
+ smNum: { fontSize: 19 },
+ tileTextSm: { fontSize: 17, fontWeight: 700, whiteSpace: 'nowrap' },
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 },
@@ -770,16 +850,16 @@ const s: Record = {
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' },
+ calGrid: { display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: 4 },
+ calDow: { fontSize: 'var(--fs-label)', color: 'var(--muted)', textAlign: 'center', padding: '3px 0' },
+ calEmpty: { minHeight: CAL_H },
+ calDay: { ...calCell, cursor: 'pointer' },
+ calDayIdle: { ...calCell, cursor: 'default' },
+ calDayToday: { ...calCell, border: '1px solid var(--dark)', cursor: 'pointer' },
+ calNum: { fontSize: 'var(--fs-label)', color: 'var(--muted)', fontVariantNumeric: 'tabular-nums', textAlign: 'left' },
+ calMark: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 'var(--fs-label)', color: 'var(--muted)', fontVariantNumeric: 'tabular-nums' },
+ calMarkDone: { display: 'flex', alignItems: 'center', gap: 5, fontSize: 'var(--fs-label)', color: 'var(--dark)', fontWeight: 700, fontVariantNumeric: 'tabular-nums' },
+ dotArrival: { width: 8, height: 8, borderRadius: 999, flexShrink: 0, background: 'var(--dark)', display: 'inline-block' },
+ dotDue: { width: 8, height: 8, borderRadius: 999, flexShrink: 0, background: 'var(--lime)', display: 'inline-block' },
+ dotHandover: { width: 8, height: 8, borderRadius: 999, flexShrink: 0, background: 'var(--alert)', display: 'inline-block' },
}
diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts
index 6ac31ee..c8c74dd 100644
--- a/frontend/src/i18n/en.ts
+++ b/frontend/src/i18n/en.ts
@@ -16,6 +16,8 @@ export const EN: Record = {
'명': '',
'건': '',
'예정': 'Planned',
+ '이번 달 손익': 'P&L this month',
+ '발행': 'Invoiced',
'주문·제작': 'Orders & build',
'성과·손익': 'Performance & P&L',
'기준데이터': 'Reference data',