diff --git a/src/app/App.jsx b/src/app/App.jsx
index 90d8dda..90613a8 100644
--- a/src/app/App.jsx
+++ b/src/app/App.jsx
@@ -9,12 +9,14 @@ import Home from '../auth/pages/Home'
import Admin from '../auth/pages/Admin'
import ProductsPage from '../features/products/ProductsPage'
import DriverDailyStatPage from '../features/crm/DriverDailyStat/DriverDailyStatPage'
+import CsHomePage from '../features/crm/CsHome/CsHomePage'
import { RequireAuth, RequireRole } from '../auth/authGuard'
import { NAV } from '../lib/navConfig'
// 실제 구현된 페이지만 여기에 등록. 나머지 메뉴는 자동으로 Placeholder로 채워진다.
// key는 navConfig item의 `to`(절대경로)와 일치시킨다.
const REAL_PAGES = {
+ '/crm/home': ,
'/crm/driver-daily-stat': ,
'/opr/products': ,
}
diff --git a/src/features/crm/CsHome/CsHome.css b/src/features/crm/CsHome/CsHome.css
new file mode 100644
index 0000000..b14ee99
--- /dev/null
+++ b/src/features/crm/CsHome/CsHome.css
@@ -0,0 +1,131 @@
+/* CS 홈 대시보드 — DriverDailyStat과 동일한 카드/그린(#4CAF50) 톤을 따른다. */
+
+.cshome-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-bottom: 4px;
+}
+
+.cshome-title {
+ margin: 0;
+ font-size: 20px;
+}
+
+.cshome-sub {
+ font-size: 13px;
+ color: #6b7280;
+}
+
+.cshome-sub .cshome-dot {
+ color: #4CAF50;
+ margin-right: 4px;
+}
+
+.cshome-section {
+ margin-top: 20px;
+}
+
+.cshome-section-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-bottom: 8px;
+}
+
+.cshome-section-title {
+ font-size: 13px;
+ font-weight: 600;
+ color: #5b6b60;
+ letter-spacing: 0.02em;
+}
+
+.cshome-section-note {
+ font-size: 12px;
+ color: #b08900;
+}
+
+.cshome-section-link {
+ font-size: 12px;
+ color: #2e7d32;
+ text-decoration: none;
+}
+
+.cshome-section-link:hover {
+ text-decoration: underline;
+}
+
+.cshome-grid {
+ display: grid;
+ gap: 12px;
+}
+
+.cshome-grid-2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.cshome-grid-3 {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.cshome-card {
+ background: #fff;
+ border: 1px solid #eceef0;
+ border-radius: 10px;
+ padding: 16px;
+}
+
+.cshome-card.accent {
+ border-left: 3px solid #2e7d32;
+}
+
+.cshome-k {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: #6b7280;
+ margin-bottom: 6px;
+}
+
+.cshome-v {
+ font-size: 26px;
+ font-weight: 600;
+ color: #1f2937;
+ line-height: 1.15;
+}
+
+.cshome-v .u {
+ font-size: 13px;
+ font-weight: 500;
+ color: #9aa5a0;
+}
+
+.cshome-card-sub {
+ margin-top: 4px;
+ font-size: 12px;
+ color: #2e7d32;
+}
+
+/* 드라이버 상태 LED */
+.cshome-led {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex: none;
+}
+
+.cshome-led.run { background: #4CAF50; }
+.cshome-led.arrive { background: #f4a52e; }
+.cshome-led.end { background: #9aa5a0; }
+
+@media (max-width: 720px) {
+ .cshome-grid-2,
+ .cshome-grid-3 {
+ grid-template-columns: 1fr;
+ }
+}
\ No newline at end of file
diff --git a/src/features/crm/CsHome/CsHomePage.jsx b/src/features/crm/CsHome/CsHomePage.jsx
new file mode 100644
index 0000000..0ca36bc
--- /dev/null
+++ b/src/features/crm/CsHome/CsHomePage.jsx
@@ -0,0 +1,201 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { fetchWithRefresh } from '../../../lib/api'
+import { crmUrl } from '../../../lib/endpoints'
+import './CsHome.css'
+
+const POLL_MS = 5 * 60 * 1000 // 5분 — 드라이버 대시보드와 동일
+
+const localToday = () => {
+ const d = new Date()
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+}
+
+const asList = (json) => (Array.isArray(json) ? json : (json?.data ?? []))
+const asObject = (json) => (json?.data ?? json ?? null)
+
+/* ─────────────────────────────────────────────────────────────
+ 드라이버 상태 코드(ddsStatus) → 카테고리 매핑.
+ 'O' = 운행 중 / 'P' = 도착 / 'C' = 운행 종료
+ 매핑되지 않은 코드는 어느 항목에도 집계되지 않습니다.
+ ───────────────────────────────────────────────────────────── */
+const RUNNING_CODES = new Set(['O'])
+const ARRIVED_CODES = new Set(['P'])
+const ENDED_CODES = new Set(['C'])
+const RUNNING = 'running'
+const ARRIVED = 'arrived'
+const ENDED = 'ended'
+const RANK = { [RUNNING]: 3, [ARRIVED]: 2, [ENDED]: 1 } // 운행중 > 도착 > 종료
+
+const categoryOf = (code) => {
+ const c = String(code ?? '').toUpperCase()
+ if (RUNNING_CODES.has(c)) return RUNNING
+ if (ARRIVED_CODES.has(c)) return ARRIVED
+ if (ENDED_CODES.has(c)) return ENDED
+ return null // 알 수 없는 상태는 제외
+}
+
+export default function CsHomePage() {
+ const [rows, setRows] = useState([]) // /driver-daily-stat 응답 (오늘)
+ const [cust, setCust] = useState(null) // { total, today } | null
+ const [loading, setLoading] = useState(true)
+ const [driverErr, setDriverErr] = useState(null)
+ const [custErr, setCustErr] = useState(null)
+ const [updatedAt, setUpdatedAt] = useState(null)
+
+ const load = useCallback(async (signal) => {
+ const today = localToday()
+
+ // 두 소스를 독립적으로 호출 — 한쪽이 실패해도 나머지는 그대로 표시.
+ const [driverRes, custRes] = await Promise.allSettled([
+ fetchWithRefresh(crmUrl(`/driver-daily-stat?date=${today}`)),
+ fetchWithRefresh(crmUrl('/customer/summary')),
+ ])
+
+ // 드라이버 운행 / 오일 픽업
+ try {
+ if (driverRes.status !== 'fulfilled') throw driverRes.reason
+ const res = driverRes.value
+ if (!res.ok) throw new Error(`운행 현황 요청 실패 (${res.status})`)
+ const list = asList(await res.json())
+ if (!signal?.aborted) { setRows(list); setDriverErr(null) }
+ } catch (e) {
+ if (!signal?.aborted) setDriverErr(e.message || '운행 현황을 불러오지 못했습니다.')
+ }
+
+ // 고객 요약
+ try {
+ if (custRes.status !== 'fulfilled') throw custRes.reason
+ const res = custRes.value
+ if (!res.ok) throw new Error(`고객 요약 요청 실패 (${res.status})`)
+ const obj = asObject(await res.json()) ?? {}
+ // 키 이름 변형을 관대하게 수용
+ const total = obj.totalCount ?? obj.total ?? obj.customerCount ?? null
+ const todayN = obj.todayCount ?? obj.today ?? obj.addedToday ?? obj.newToday ?? null
+ if (!signal?.aborted) {
+ setCust({ total, today: todayN })
+ setCustErr(null)
+ }
+ } catch (e) {
+ if (!signal?.aborted) setCustErr(e.message || '고객 요약을 불러오지 못했습니다.')
+ }
+
+ if (!signal?.aborted) {
+ setUpdatedAt(new Date())
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ const ctrl = new AbortController()
+ setLoading(true)
+ load(ctrl.signal)
+ const id = setInterval(() => load(ctrl.signal), POLL_MS)
+ return () => { ctrl.abort(); clearInterval(id) }
+ }, [load])
+
+ // 오일 픽업 합계 (오늘)
+ const pickup = useMemo(() => {
+ let target = 0, total = 0
+ for (const r of rows) {
+ target += Number(r.ddsTargetQty ?? 0)
+ total += Number(r.ddsTotalQty ?? 0)
+ }
+ const rate = target > 0 ? Math.round((total / target) * 100) : null
+ return { target: Math.round(target), total: Math.round(total), rate }
+ }, [rows])
+
+ // 드라이버 상태별 인원 (드라이버 단위로 dedupe, 우선순위: 운행중 > 도착 > 종료)
+ const drivers = useMemo(() => {
+ const byDriver = new Map()
+ for (const r of rows) {
+ const id = r.ddsDriverId
+ if (id == null) continue
+ const cat = categoryOf(r.ddsStatus)
+ if (cat == null) continue // 알 수 없는 상태는 제외
+ const prev = byDriver.get(id)
+ if (prev == null || RANK[cat] > RANK[prev]) byDriver.set(id, cat)
+ }
+ let running = 0, arrived = 0, ended = 0
+ for (const cat of byDriver.values()) {
+ if (cat === RUNNING) running++
+ else if (cat === ARRIVED) arrived++
+ else ended++
+ }
+ return { running, arrived, ended, total: byDriver.size }
+ }, [rows])
+
+ const fmt = (n) => (n == null ? '—' : Number(n).toLocaleString())
+
+ return (
+
+
+
CS 홈
+
+ ●
+ 오늘 기준 · 5분마다 자동 갱신
+ {updatedAt && ` · 업데이트 ${updatedAt.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}`}
+ {loading && rows.length === 0 && ' · 불러오는 중…'}
+
+
+
+ {/* 고객 */}
+
+
+ 고객
+ {custErr && 고객 요약 미연동 · {custErr}}
+
+
+
+
+
+
+
+ {/* 오일 픽업 */}
+
+
+ 오늘 오일 픽업
+ {driverErr && 운행 데이터 오류 · {driverErr}}
+
+
+
+
+
+
+
+ {/* 드라이버 현황 */}
+
+
+ 드라이버 현황
+ 운행 현황 자세히 →
+
+
+
+
+
+
+
+
+ )
+}
+
+function Card({ label, value, unit, sub, accent, dot }) {
+ return (
+
+
+ {dot && }
+ {label}
+
+
+ {value}{unit && {unit}}
+
+ {sub &&
{sub}
}
+
+ )
+}
diff --git a/src/layouts/Sidebar.jsx b/src/layouts/Sidebar.jsx
index 7e9d000..a0c0a67 100644
--- a/src/layouts/Sidebar.jsx
+++ b/src/layouts/Sidebar.jsx
@@ -5,6 +5,7 @@ import { useAuth } from '../app/providers/AuthProvider'
function Icon({ name }) {
const d = {
+ home: 'M4 11l8-7 8 7M6 10v9a1 1 0 001 1h3v-6h4v6h3a1 1 0 001-1v-9',
chart: 'M4 19V5M4 19h16M8 16v-5M12 16V8M16 16v-3',
users: 'M9 11a3 3 0 100-6 3 3 0 000 6zM4 19a5 5 0 0110 0M17 11a3 3 0 000-6',
truck: 'M3 7h11v8H3zM14 10h3l3 3v2h-6zM7 18a2 2 0 100-4 2 2 0 000 4zM18 18a2 2 0 100-4 2 2 0 000 4z',
diff --git a/src/lib/navConfig.js b/src/lib/navConfig.js
index 126f817..8768951 100644
--- a/src/lib/navConfig.js
+++ b/src/lib/navConfig.js
@@ -29,6 +29,7 @@ export const NAV = [
},
{
key: 'crm', label: 'CS', base: '/crm', items: [
+ { label: '홈', to: '/crm/home', icon: 'home' },
{ label: '드라이버 운행 현황', to: '/crm/driver-daily-stat', icon: 'chart' },
{ label: '고객 관리', to: '/crm/customers', icon: 'users' },
{ label: '일일 오더', to: '/crm/daily-orders', icon: 'clipboard' },
@@ -67,4 +68,4 @@ export function canSee(entry, userRole) {
// 현재 경로(pathname)에 해당하는 모듈 key를 찾는다. 없으면 null.
export function moduleKeyFor(pathname) {
return NAV.find((m) => pathname.startsWith(m.base))?.key ?? null
-}
+}
\ No newline at end of file
diff --git a/vite.config.js b/vite.config.js
index 09722b3..aefd748 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,9 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
-const AUTH_BACKEND = 'http://localhost:8080' // auth-service
-const CRM_BACKEND = 'http://localhost:8082' // crm-rest-api
-const OPR_BACKEND = 'http://localhost:8083' // opr-rest-api
+const AUTH_BACKEND = 'http://auth-service:8080' // auth-service
+const CRM_BACKEND = 'http://crm-rest-api:8082' // crm-rest-api
+const OPR_BACKEND = 'http://opr-rest-api:8083' // opr-rest-api
// https://vite.dev/config/
export default defineConfig({