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({