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 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 />,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }) {
|
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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue