CS 홈 추가

This commit is contained in:
Hyojin Ahn 2026-06-22 15:07:19 -04:00
parent 123995333e
commit 6a86f05121
6 changed files with 340 additions and 4 deletions

View File

@ -9,12 +9,14 @@ import Home from '../auth/pages/Home'
import Admin from '../auth/pages/Admin' import Admin from '../auth/pages/Admin'
import ProductsPage from '../features/products/ProductsPage' import ProductsPage from '../features/products/ProductsPage'
import DriverDailyStatPage from '../features/crm/DriverDailyStat/DriverDailyStatPage' import DriverDailyStatPage from '../features/crm/DriverDailyStat/DriverDailyStatPage'
import CsHomePage from '../features/crm/CsHome/CsHomePage'
import { RequireAuth, RequireRole } from '../auth/authGuard' import { RequireAuth, RequireRole } from '../auth/authGuard'
import { NAV } from '../lib/navConfig' import { NAV } from '../lib/navConfig'
// . Placeholder . // . Placeholder .
// key navConfig item `to`() . // key navConfig item `to`() .
const REAL_PAGES = { const REAL_PAGES = {
'/crm/home': <CsHomePage />,
'/crm/driver-daily-stat': <DriverDailyStatPage />, '/crm/driver-daily-stat': <DriverDailyStatPage />,
'/opr/products': <ProductsPage />, '/opr/products': <ProductsPage />,
} }

View File

@ -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;
}
}

View File

@ -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 (
<div>
<div className="cshome-head">
<h2 className="cshome-title">CS </h2>
<span className="cshome-sub">
<span className="cshome-dot"></span>
오늘 기준 · 5분마다 자동 갱신
{updatedAt && ` · 업데이트 ${updatedAt.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}`}
{loading && rows.length === 0 && ' · 불러오는 중…'}
</span>
</div>
{/* 고객 */}
<div className="cshome-section">
<div className="cshome-section-head">
<span className="cshome-section-title">고객</span>
{custErr && <span className="cshome-section-note">고객 요약 미연동 · {custErr}</span>}
</div>
<div className="cshome-grid cshome-grid-2">
<Card label="전체 고객 수" value={fmt(cust?.total)} unit="명" />
<Card label="오늘 추가된 고객" value={fmt(cust?.today)} unit="명" accent />
</div>
</div>
{/* 오일 픽업 */}
<div className="cshome-section">
<div className="cshome-section-head">
<span className="cshome-section-title">오늘 오일 픽업</span>
{driverErr && <span className="cshome-section-note">운행 데이터 오류 · {driverErr}</span>}
</div>
<div className="cshome-grid cshome-grid-2">
<Card label="오늘 목표량" value={fmt(pickup.target)} unit="L" />
<Card
label="현재 픽업량"
value={fmt(pickup.total)}
unit="L"
accent
sub={pickup.rate != null ? `목표 대비 ${pickup.rate}%` : null}
/>
</div>
</div>
{/* 드라이버 현황 */}
<div className="cshome-section">
<div className="cshome-section-head">
<span className="cshome-section-title">드라이버 현황</span>
<Link className="cshome-section-link" to="/crm/driver-daily-stat">운행 현황 자세히 </Link>
</div>
<div className="cshome-grid cshome-grid-3">
<Card label="운행 중" value={fmt(drivers.running)} unit="명" dot="run" />
<Card label="도착" value={fmt(drivers.arrived)} unit="명" dot="arrive" />
<Card label="운행 종료" value={fmt(drivers.ended)} unit="명" dot="end" />
</div>
</div>
</div>
)
}
function Card({ label, value, unit, sub, accent, dot }) {
return (
<div className={`cshome-card${accent ? ' accent' : ''}`}>
<div className="cshome-k">
{dot && <span className={`cshome-led ${dot}`} />}
{label}
</div>
<div className="cshome-v">
{value}{unit && <span className="u"> {unit}</span>}
</div>
{sub && <div className="cshome-card-sub">{sub}</div>}
</div>
)
}

View File

@ -5,6 +5,7 @@ import { useAuth } from '../app/providers/AuthProvider'
function Icon({ name }) { function Icon({ name }) {
const d = { 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', 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', 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', 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',

View File

@ -29,6 +29,7 @@ export const NAV = [
}, },
{ {
key: 'crm', label: 'CS', base: '/crm', items: [ key: 'crm', label: 'CS', base: '/crm', items: [
{ label: '홈', to: '/crm/home', icon: 'home' },
{ label: '드라이버 운행 현황', to: '/crm/driver-daily-stat', icon: 'chart' }, { label: '드라이버 운행 현황', to: '/crm/driver-daily-stat', icon: 'chart' },
{ label: '고객 관리', to: '/crm/customers', icon: 'users' }, { label: '고객 관리', to: '/crm/customers', icon: 'users' },
{ label: '일일 오더', to: '/crm/daily-orders', icon: 'clipboard' }, { label: '일일 오더', to: '/crm/daily-orders', icon: 'clipboard' },
@ -67,4 +68,4 @@ export function canSee(entry, userRole) {
// 현재 경로(pathname)에 해당하는 모듈 key를 찾는다. 없으면 null. // 현재 경로(pathname)에 해당하는 모듈 key를 찾는다. 없으면 null.
export function moduleKeyFor(pathname) { export function moduleKeyFor(pathname) {
return NAV.find((m) => pathname.startsWith(m.base))?.key ?? null return NAV.find((m) => pathname.startsWith(m.base))?.key ?? null
} }

View File

@ -1,9 +1,9 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
const AUTH_BACKEND = 'http://localhost:8080' // auth-service const AUTH_BACKEND = 'http://auth-service:8080' // auth-service
const CRM_BACKEND = 'http://localhost:8082' // crm-rest-api const CRM_BACKEND = 'http://crm-rest-api:8082' // crm-rest-api
const OPR_BACKEND = 'http://localhost:8083' // opr-rest-api const OPR_BACKEND = 'http://opr-rest-api:8083' // opr-rest-api
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({