CS 홈 추가
This commit is contained in:
parent
123995333e
commit
6a86f05121
|
|
@ -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': <CsHomePage />,
|
||||
'/crm/driver-daily-stat': <DriverDailyStatPage />,
|
||||
'/opr/products': <ProductsPage />,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue