Customer 페이지 추가.

This commit is contained in:
Hyojin Ahn 2026-06-23 10:42:19 -04:00
parent 6a86f05121
commit 7d7e6607f8
3 changed files with 294 additions and 1 deletions

View File

@ -8,8 +8,13 @@ import Login from '../auth/pages/Login'
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'
// CS
import CsHomePage from '../features/crm/CsHome/CsHomePage'
import DriverDailyStatPage from '../features/crm/DriverDailyStat/DriverDailyStatPage'
import CustomerListPage from '../features/crm/Customer/CustomerListPage'
//
import { RequireAuth, RequireRole } from '../auth/authGuard'
import { NAV } from '../lib/navConfig'
@ -18,6 +23,7 @@ import { NAV } from '../lib/navConfig'
const REAL_PAGES = {
'/crm/home': <CsHomePage />,
'/crm/driver-daily-stat': <DriverDailyStatPage />,
'/crm/customers': <CustomerListPage />,
'/opr/products': <ProductsPage />,
}

View File

@ -0,0 +1,149 @@
/* Customer list co-located styles.
Mirrors the app's plain class-based CSS and green brand (#4CAF50). */
.cust-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.cust-title { margin: 0; font-size: 20px; }
.cust-sub {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: #6b7280;
}
.cust-badge {
font-size: 12px;
color: #2e7d32;
background: #eef5ef;
border: 1px solid #d5e6d7;
border-radius: 999px;
padding: 2px 10px;
}
.cust-card {
background: #fff;
border: 1px solid #eceef0;
border-radius: 10px;
overflow: hidden;
}
.cust-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.cust-table th {
text-align: left;
padding: 11px 14px;
background: #f7f8f9;
border-bottom: 1px solid #eceef0;
font-weight: 600;
color: #4b5563;
white-space: nowrap;
}
.cust-table td {
padding: 11px 14px;
border-bottom: 1px solid #f1f2f4;
color: #1f2937;
white-space: nowrap;
}
.cust-table tbody tr:last-child td { border-bottom: none; }
.cust-table tbody tr:hover { background: #fafbfc; }
.cust-status {
display: inline-block;
font-size: 12px;
color: #6b7280;
background: #f1f2f4;
border-radius: 4px;
padding: 2px 8px;
}
.cust-status.on {
color: #2e7d32;
background: #eef5ef;
}
.cust-state {
padding: 32px 14px;
text-align: center;
color: #9ca3af;
}
.cust-msg {
margin-bottom: 12px;
font-size: 14px;
padding: 10px 12px;
border-radius: 6px;
}
.cust-msg.error {
color: #b3261e;
background: #fdecea;
border: 1px solid #f6cdc8;
}
.cust-pager {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 16px;
}
.cust-pageinfo { font-size: 14px; color: #4b5563; }
.cust-btn {
height: 34px;
padding: 0 14px;
font-size: 14px;
font-family: inherit;
border: 1px solid #4CAF50;
border-radius: 6px;
background: #4CAF50;
color: #fff;
cursor: pointer;
}
.cust-btn.ghost {
background: #fff;
color: #2e7d32;
border-color: #dcdfe3;
}
.cust-btn:disabled {
opacity: 0.5;
cursor: default;
}
.cust-link {
color: #1f2937;
text-decoration: none;
font-weight: 600;
}
.cust-link:hover {
color: #2e7d32;
text-decoration: underline;
}
.cust-ext {
margin-left: 4px;
font-size: 11px;
color: #9ca3af;
vertical-align: super;
}
.cust-link:hover .cust-ext { color: #2e7d32; }

View File

@ -0,0 +1,138 @@
// src/features/crm/Customer/CustomerListPage.jsx
// . MIS CDC sync, MIS .
// : GET /customer?page=&size= -> Spring Page<CustomerResponseDto>
import { useCallback, useEffect, useState } from 'react'
import { fetchWithRefresh } from '../../../lib/api'
import { crmUrl } from '../../../lib/endpoints'
import './Customer.css'
const PAGE_SIZE = 20
// 'A'=Active() . cus_status .
const STATUS_LABELS = { A: '활성' }
const statusLabel = (code) => (code ? (STATUS_LABELS[code] ?? code) : '-')
const formatDate = (v) => (v ? String(v).slice(0, 10) : '-')
const regionText = (c) => {
const parts = [c.cusCity, c.cusProvince].filter(Boolean)
return parts.length ? parts.join(', ') : (c.cusRegionId ?? '-')
}
const phoneText = (c) => {
if (!c.cusPhone) return '-'
return c.cusPhoneExt ? `${c.cusPhone} (${c.cusPhoneExt})` : c.cusPhone
}
// MIS . c_uid cus_no(=MIS c_accountno) .
// (MIS customer_detail c_accountno -> c_uid )
// TODO: / import.meta.env.VITE_MIS_BASE_URL
const MIS_BASE = 'https://mis.greenoilinc.com/index_intranet.php'
const misCustomerUrl = (cusNo) =>
`${MIS_BASE}?view=customer_detail&mode=update&c_accountno=${encodeURIComponent(cusNo)}`
export default function CustomerListPage() {
const [rows, setRows] = useState([])
const [page, setPage] = useState(0)
const [totalPages, setTotalPages] = useState(0)
const [totalElements, setTotalElements] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const load = useCallback(async (signal) => {
try {
setLoading(true)
const res = await fetchWithRefresh(crmUrl(`/customer?page=${page}&size=${PAGE_SIZE}`))
if (!res.ok) throw new Error(`고객 목록 요청 실패 (${res.status})`)
const json = await res.json() // Spring Page: { content, totalElements, totalPages, number }
if (signal?.aborted) return
setRows(json.content ?? [])
setTotalPages(json.totalPages ?? 0)
setTotalElements(json.totalElements ?? 0)
setError(null)
} catch (e) {
if (!signal?.aborted) { setError(e.message); setRows([]) }
} finally {
if (!signal?.aborted) setLoading(false)
}
}, [page])
useEffect(() => {
const ctrl = new AbortController()
load(ctrl.signal)
return () => ctrl.abort()
}, [load])
return (
<div>
<div className="cust-head">
<h2 className="cust-title">고객 관리</h2>
<span className="cust-sub">
{totalElements.toLocaleString()}
<span className="cust-badge">MIS 연동 · 조회 전용</span>
</span>
</div>
{error && <div className="cust-msg error">불러오기 오류: {error}</div>}
<div className="cust-card">
<table className="cust-table">
<thead>
<tr>
<th>고객번호</th>
<th>고객명</th>
<th>상태</th>
<th>지역</th>
<th>연락처</th>
<th>계약일</th>
<th>영업담당</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} className="cust-state">불러오는 </td></tr>
) : rows.length === 0 ? (
<tr><td colSpan={7} className="cust-state">등록된 고객이 없습니다.</td></tr>
) : (
rows.map((c) => (
<tr key={c.cusUuid}>
<td>{c.cusNo ?? '-'}</td>
<td>
{c.cusNo ? (
<a className="cust-link" href={misCustomerUrl(c.cusNo)}
target="_blank" rel="noopener noreferrer"
title="MIS에서 열기">
{c.cusName ?? '-'}<span className="cust-ext"></span>
</a>
) : (c.cusName ?? '-')}
</td>
<td>
<span className={`cust-status${c.cusStatus === 'A' ? ' on' : ''}`}>
{statusLabel(c.cusStatus)}
</span>
</td>
<td>{regionText(c)}</td>
<td>{phoneText(c)}</td>
<td>{formatDate(c.cusContractDate)}</td>
<td>{c.cusSalesperson ?? '-'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="cust-pager">
<button className="cust-btn ghost"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0 || loading}>이전</button>
<span className="cust-pageinfo">{page + 1} / {totalPages}</span>
<button className="cust-btn ghost"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1 || loading}>다음</button>
</div>
)}
</div>
)
}