From 7d7e6607f81cd733441bf03a94822d093accb37c Mon Sep 17 00:00:00 2001 From: Hyojin Ahn Date: Tue, 23 Jun 2026 10:42:19 -0400 Subject: [PATCH] =?UTF-8?q?Customer=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/App.jsx | 8 +- src/features/crm/Customer/Customer.css | 149 ++++++++++++++++++ .../crm/Customer/CustomerListPage.jsx | 138 ++++++++++++++++ 3 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 src/features/crm/Customer/Customer.css create mode 100644 src/features/crm/Customer/CustomerListPage.jsx diff --git a/src/app/App.jsx b/src/app/App.jsx index 90613a8..bbdb4ea 100644 --- a/src/app/App.jsx +++ b/src/app/App.jsx @@ -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' + +// CS +import CsHomePage from '../features/crm/CsHome/CsHomePage' import DriverDailyStatPage from '../features/crm/DriverDailyStat/DriverDailyStatPage' -import CsHomePage from '../features/crm/CsHome/CsHomePage' +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': , '/crm/driver-daily-stat': , + '/crm/customers': , '/opr/products': , } diff --git a/src/features/crm/Customer/Customer.css b/src/features/crm/Customer/Customer.css new file mode 100644 index 0000000..2eaaa6f --- /dev/null +++ b/src/features/crm/Customer/Customer.css @@ -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; } diff --git a/src/features/crm/Customer/CustomerListPage.jsx b/src/features/crm/Customer/CustomerListPage.jsx new file mode 100644 index 0000000..d9ba2b6 --- /dev/null +++ b/src/features/crm/Customer/CustomerListPage.jsx @@ -0,0 +1,138 @@ +// src/features/crm/Customer/CustomerListPage.jsx +// 고객 목록 — 조회 전용. 데이터는 MIS에서 CDC로 sync, 쓰기는 MIS 전담. +// 백엔드: GET /customer?page=&size= -> Spring Page +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 ( +
+
+

고객 관리

+ + 총 {totalElements.toLocaleString()}건 + MIS 연동 · 조회 전용 + +
+ + {error &&
불러오기 오류: {error}
} + +
+ + + + + + + + + + + + + + {loading ? ( + + ) : rows.length === 0 ? ( + + ) : ( + rows.map((c) => ( + + + + + + + + + + )) + )} + +
고객번호고객명상태지역연락처계약일영업담당
불러오는 중…
등록된 고객이 없습니다.
{c.cusNo ?? '-'} + {c.cusNo ? ( + + {c.cusName ?? '-'} + + ) : (c.cusName ?? '-')} + + + {statusLabel(c.cusStatus)} + + {regionText(c)}{phoneText(c)}{formatDate(c.cusContractDate)}{c.cusSalesperson ?? '-'}
+
+ + {totalPages > 1 && ( +
+ + {page + 1} / {totalPages} + +
+ )} +
+ ) +}