BillCare 초기 소스 등록
비즈케어 홈즈 홈상품 운용관리(BillCare) 프론트/백엔드/Docker 구성을 exdev git에 등록합니다. Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,32 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>빌링 케어 / 비즈케어 홈즈</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
client_max_body_size 20m;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8080/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_pass_request_headers on;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_next_upstream error timeout http_502 http_503;
|
||||
proxy_next_upstream_tries 2;
|
||||
proxy_next_upstream_timeout 10s;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@ant-design/plots": "^2.6.8",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"antd": "^6.5.0",
|
||||
"axios": "^1.18.1",
|
||||
"dayjs": "^1.11.21",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"oxlint": "^1.71.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,163 @@
|
||||
import { Navigate, Outlet, Route, Routes } from 'react-router-dom'
|
||||
import {
|
||||
App as AntApp,
|
||||
ConfigProvider,
|
||||
theme as antTheme,
|
||||
} from 'antd'
|
||||
import koKR from 'antd/locale/ko_KR'
|
||||
import AgentShell from './components/AgentShell'
|
||||
import { useTheme } from './themeContext'
|
||||
import { THEME_PRIMARY } from './theme'
|
||||
import { formRequiredMark } from './formRequiredMark'
|
||||
import './styles/app.css'
|
||||
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import HomeDashboardPage from './pages/care/HomeDashboardPage'
|
||||
import ProductInternetPage from './pages/care/ProductInternetPage'
|
||||
import ProductHomePage from './pages/care/ProductHomePage'
|
||||
import ProductLogPage from './pages/care/ProductLogPage'
|
||||
import ContractInternetPage from './pages/care/ContractInternetPage'
|
||||
import ContractHomePage from './pages/care/ContractHomePage'
|
||||
import ContractLogPage from './pages/care/ContractLogPage'
|
||||
import ContractAdjustPage from './pages/care/ContractAdjustPage'
|
||||
import ContractBalancePage from './pages/care/ContractBalancePage'
|
||||
import ContractWritePage from './pages/care/ContractWritePage'
|
||||
import ReceptionListPage from './pages/care/ReceptionListPage'
|
||||
import InvoicePublishPage from './pages/care/InvoicePublishPage'
|
||||
import InvoiceMyPage from './pages/care/InvoiceMyPage'
|
||||
import InvoiceLogPage from './pages/care/InvoiceLogPage'
|
||||
import PendingCalendarPage from './pages/care/PendingCalendarPage'
|
||||
import PendingPage from './pages/care/PendingPage'
|
||||
import PendingLogPage from './pages/care/PendingLogPage'
|
||||
import BookingPage from './pages/care/BookingPage'
|
||||
import BookingLogPage from './pages/care/BookingLogPage'
|
||||
import CustomerPage from './pages/care/CustomerPage'
|
||||
import CustomerLogPage from './pages/care/CustomerLogPage'
|
||||
import AbooksPage from './pages/care/AbooksPage'
|
||||
import AbooksLogPage from './pages/care/AbooksLogPage'
|
||||
import ReportDailyPage from './pages/care/ReportDailyPage'
|
||||
import ReportMonthlyPage from './pages/care/ReportMonthlyPage'
|
||||
import ReportInternetPage from './pages/care/ReportInternetPage'
|
||||
import ReportHomePage from './pages/care/ReportHomePage'
|
||||
import ReportMemberPage from './pages/care/ReportMemberPage'
|
||||
import ReportAgencyPage from './pages/care/ReportAgencyPage'
|
||||
import ReportIncentivePage from './pages/care/ReportIncentivePage'
|
||||
import SettingAgencyPage from './pages/care/SettingAgencyPage'
|
||||
import SettingMemberPage from './pages/care/SettingMemberPage'
|
||||
import SettingPreferencePage from './pages/care/SettingPreferencePage'
|
||||
import SettingLevelPage from './pages/care/SettingLevelPage'
|
||||
import SettingPartner1Page from './pages/care/SettingPartner1Page'
|
||||
import SettingPartner2Page from './pages/care/SettingPartner2Page'
|
||||
import CsNoticePage from './pages/care/CsNoticePage'
|
||||
import CsQuestionPage from './pages/care/CsQuestionPage'
|
||||
import BbsPage from './pages/care/BbsPage'
|
||||
import SitemapPage from './pages/care/SitemapPage'
|
||||
|
||||
function ProtectedRoute() {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) return <Navigate to="/login" replace />
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={koKR}
|
||||
form={{ requiredMark: formRequiredMark }}
|
||||
theme={{
|
||||
algorithm: theme === 'light' ? antTheme.defaultAlgorithm : antTheme.darkAlgorithm,
|
||||
token: {
|
||||
colorPrimary: THEME_PRIMARY[theme] || '#1677ff',
|
||||
colorBgContainer: theme === 'light' ? '#ffffff' : theme === 'blue' ? '#15273d' : '#24283b',
|
||||
colorTextLightSolid: '#ffffff',
|
||||
colorBorder: theme === 'light' ? undefined : theme === 'blue' ? 'rgba(143, 187, 224, 0.55)' : 'rgba(192, 202, 245, 0.5)',
|
||||
colorBorderSecondary: theme === 'light' ? undefined : theme === 'blue' ? 'rgba(143, 187, 224, 0.35)' : 'rgba(192, 202, 245, 0.32)',
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
primaryShadow: 'none',
|
||||
defaultShadow: 'none',
|
||||
dangerShadow: 'none',
|
||||
},
|
||||
Checkbox: {
|
||||
colorBorder: theme === 'light' ? undefined : theme === 'blue' ? 'rgba(143, 187, 224, 0.65)' : 'rgba(192, 202, 245, 0.58)',
|
||||
},
|
||||
Select: {
|
||||
selectorBg: theme === 'light' ? '#ffffff' : theme === 'blue' ? '#15273d' : '#24283b',
|
||||
optionSelectedBg:
|
||||
theme === 'light' ? '#dbeafe' : theme === 'blue' ? 'rgba(30, 138, 212, 0.28)' : 'rgba(122, 162, 247, 0.22)',
|
||||
optionActiveBg:
|
||||
theme === 'light' ? '#f1f5f9' : theme === 'blue' ? 'rgba(30, 138, 212, 0.16)' : 'rgba(122, 162, 247, 0.12)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AntApp>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/care" element={<AgentShell />}>
|
||||
<Route path="main" element={<HomeDashboardPage />} />
|
||||
<Route path="main/sitemap" element={<SitemapPage />} />
|
||||
|
||||
<Route path="product/internet" element={<ProductInternetPage />} />
|
||||
<Route path="product/home" element={<ProductHomePage />} />
|
||||
<Route path="product/log" element={<ProductLogPage />} />
|
||||
|
||||
<Route path="contract/internet" element={<ContractInternetPage />} />
|
||||
<Route path="contract/home" element={<ContractHomePage />} />
|
||||
<Route path="contract/log" element={<ContractLogPage />} />
|
||||
<Route path="contract/adjust" element={<ContractAdjustPage />} />
|
||||
<Route path="contract/balance" element={<ContractBalancePage />} />
|
||||
<Route path="contract/write" element={<ContractWritePage />} />
|
||||
|
||||
<Route path="reception/list" element={<ReceptionListPage />} />
|
||||
|
||||
<Route path="invoice/publish" element={<InvoicePublishPage />} />
|
||||
<Route path="invoice/my" element={<InvoiceMyPage />} />
|
||||
<Route path="invoice/log" element={<InvoiceLogPage />} />
|
||||
|
||||
<Route path="pending/calendar" element={<PendingCalendarPage />} />
|
||||
<Route path="pending/pending" element={<PendingPage />} />
|
||||
<Route path="pending/log" element={<PendingLogPage />} />
|
||||
|
||||
<Route path="booking/booking" element={<BookingPage />} />
|
||||
<Route path="booking/log" element={<BookingLogPage />} />
|
||||
|
||||
<Route path="customer/customer" element={<CustomerPage />} />
|
||||
<Route path="customer/log" element={<CustomerLogPage />} />
|
||||
|
||||
<Route path="abooks/abooks-mon" element={<AbooksPage />} />
|
||||
<Route path="abooks/log" element={<AbooksLogPage />} />
|
||||
|
||||
<Route path="report/daily" element={<ReportDailyPage />} />
|
||||
<Route path="report/monthly" element={<ReportMonthlyPage />} />
|
||||
<Route path="report/internet" element={<ReportInternetPage />} />
|
||||
<Route path="report/home" element={<ReportHomePage />} />
|
||||
<Route path="report/member" element={<ReportMemberPage />} />
|
||||
<Route path="report/agency" element={<ReportAgencyPage />} />
|
||||
<Route path="report/incentive" element={<ReportIncentivePage />} />
|
||||
|
||||
<Route path="setting/agency" element={<SettingAgencyPage />} />
|
||||
<Route path="setting/member" element={<SettingMemberPage />} />
|
||||
<Route path="setting/preference" element={<SettingPreferencePage />} />
|
||||
<Route path="setting/level" element={<SettingLevelPage />} />
|
||||
<Route path="setting/partner1" element={<SettingPartner1Page />} />
|
||||
<Route path="setting/partner2" element={<SettingPartner2Page />} />
|
||||
|
||||
<Route path="cs/notice" element={<CsNoticePage />} />
|
||||
<Route path="cs/question" element={<CsQuestionPage />} />
|
||||
<Route path="bbs/bbs" element={<BbsPage />} />
|
||||
|
||||
<Route path="sitemap" element={<Navigate to="/care/main/sitemap" replace />} />
|
||||
<Route path="*" element={<Navigate to="/care/main" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="/" element={<Navigate to="/care/main" replace />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from './client'
|
||||
import type { User } from '../types'
|
||||
|
||||
type LoginData = {
|
||||
token: string
|
||||
role: string
|
||||
userid: string
|
||||
name: string
|
||||
groupId: string
|
||||
companyId: number | null
|
||||
staffPermission?: string
|
||||
menuFlags?: string | null
|
||||
}
|
||||
|
||||
export async function login(userid: string, password: string): Promise<User & { token: string }> {
|
||||
const { data } = await client.post<ApiResponse<LoginData>>('/auth/login', { userid, password })
|
||||
const body = unwrap(data)
|
||||
return {
|
||||
token: body.token,
|
||||
role: body.role,
|
||||
userid: body.userid,
|
||||
name: body.name,
|
||||
groupId: body.groupId,
|
||||
companyId: body.companyId,
|
||||
staffPermission: body.staffPermission,
|
||||
menuFlags: body.menuFlags,
|
||||
}
|
||||
}
|
||||
|
||||
export type MyMenusData = {
|
||||
staffPermission: string
|
||||
menus: string[]
|
||||
}
|
||||
|
||||
export async function fetchMyMenus(): Promise<MyMenusData> {
|
||||
const { data } = await client.get<ApiResponse<MyMenusData>>(`${apiPrefix()}/homes/my-menus`)
|
||||
return unwrap(data)
|
||||
}
|
||||
|
||||
export async function me(): Promise<User> {
|
||||
const { data } = await client.get<ApiResponse<User>>('/auth/me')
|
||||
return unwrap(data)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export type ApiResponse<T = unknown> = {
|
||||
result: boolean
|
||||
msg?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
const client = axios.create({ baseURL: '/api', timeout: 20_000 })
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
client.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.clear()
|
||||
if (!window.location.pathname.includes('/login')) window.location.href = '/login'
|
||||
}
|
||||
const msg = err.response?.data?.msg
|
||||
if (typeof msg === 'string' && msg && !err.message?.includes(msg)) {
|
||||
err.message = msg
|
||||
}
|
||||
return Promise.reject(err)
|
||||
},
|
||||
)
|
||||
|
||||
export function unwrap<T>(payload: ApiResponse<T> | T): T {
|
||||
if (payload && typeof payload === 'object' && 'result' in (payload as object)) {
|
||||
const r = payload as ApiResponse<T>
|
||||
if (!r.result) throw new Error(r.msg || '요청 실패')
|
||||
return r.data as T
|
||||
}
|
||||
return payload as T
|
||||
}
|
||||
|
||||
export function apiPrefix() {
|
||||
const role = localStorage.getItem('role')
|
||||
return role === 'SHOP' ? '/shop' : '/agent'
|
||||
}
|
||||
|
||||
export default client
|
||||
@@ -0,0 +1,31 @@
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from './client'
|
||||
|
||||
export type OpenRecord = Record<string, unknown> & { id: number }
|
||||
export type OpenSearch = { total: number; list: OpenRecord[]; counts: Record<string, number> }
|
||||
|
||||
export const openApi = {
|
||||
async search(params: Record<string, unknown>) {
|
||||
const { data } = await client.get<ApiResponse<OpenSearch>>(`${apiPrefix()}/opens/search`, { params })
|
||||
return unwrap(data)
|
||||
},
|
||||
async get(id: number) {
|
||||
const { data } = await client.get<ApiResponse<OpenRecord>>(`${apiPrefix()}/opens/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
async create(payload: Record<string, unknown>) {
|
||||
const { data } = await client.post<ApiResponse>(`${apiPrefix()}/opens`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
async save(id: number, payload: Record<string, unknown>) {
|
||||
const { data } = await client.put<ApiResponse>(`${apiPrefix()}/opens/${id}`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
async remove(id: number) {
|
||||
const { data } = await client.delete<ApiResponse>(`${apiPrefix()}/opens/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
async serialLookup(serial: string, gubun = '휴대폰') {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${apiPrefix()}/opens/serial-lookup`, { params: { serial, gubun } })
|
||||
return unwrap(data)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from './client'
|
||||
import type { Entity } from '../types'
|
||||
|
||||
export type PageResult<T = Entity> = { total: number; list: T[] }
|
||||
|
||||
export const resourceApi = {
|
||||
async list(path: string, params: Record<string, unknown> = {}) {
|
||||
const { data } = await client.get<ApiResponse<PageResult> | ApiResponse<Record<string, unknown>>>(
|
||||
`${apiPrefix()}/${path}`,
|
||||
{ params },
|
||||
)
|
||||
const body = unwrap(data) as PageResult | Record<string, unknown>
|
||||
if (body && typeof body === 'object' && 'list' in body) {
|
||||
const page = body as PageResult
|
||||
return { items: page.list ?? [], total: Number(page.total ?? 0) }
|
||||
}
|
||||
// dashboard / summary style
|
||||
return { items: [body as Entity], total: 1, raw: body }
|
||||
},
|
||||
|
||||
async get(path: string, params: Record<string, unknown> = {}) {
|
||||
const { data } = await client.get<ApiResponse>(`${apiPrefix()}/${path}`, { params })
|
||||
return unwrap(data)
|
||||
},
|
||||
|
||||
async create(path: string, payload: Record<string, unknown>) {
|
||||
const { data } = await client.post<ApiResponse<Entity>>(`${apiPrefix()}/${path}`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
|
||||
async update(path: string, id: number | string, payload: Record<string, unknown>) {
|
||||
const { data } = await client.put<ApiResponse<Entity>>(`${apiPrefix()}/${path}/${id}`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
|
||||
async put(path: string, payload: Record<string, unknown>, params?: Record<string, unknown>) {
|
||||
const { data } = await client.put<ApiResponse>(`${apiPrefix()}/${path}`, payload, { params })
|
||||
return unwrap(data)
|
||||
},
|
||||
|
||||
async remove(path: string, id: number) {
|
||||
const { data } = await client.delete<ApiResponse>(`${apiPrefix()}/${path}/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from './client'
|
||||
|
||||
export type Stock = Record<string, unknown> & { id: number; state: string; statusLabel: string }
|
||||
export type StockSearch = { total: number; list: Stock[]; counts: Record<string, number> }
|
||||
|
||||
export type StockHistoryItem = {
|
||||
id: number
|
||||
processDate?: string
|
||||
statusLabel?: string
|
||||
companyLabel?: string
|
||||
processor?: string
|
||||
}
|
||||
|
||||
export type StockNoteItem = {
|
||||
id: number
|
||||
stockId?: number
|
||||
contents?: string
|
||||
authorName?: string
|
||||
noteDate?: string
|
||||
}
|
||||
|
||||
export type StockDetail = {
|
||||
stock: Stock
|
||||
histories: StockHistoryItem[]
|
||||
notes: StockNoteItem[]
|
||||
}
|
||||
|
||||
export const stockApi = {
|
||||
async search(params: Record<string, unknown>) {
|
||||
const { data } = await client.get<ApiResponse<StockSearch>>(`${apiPrefix()}/stocks/search`, { params })
|
||||
return unwrap(data)
|
||||
},
|
||||
async detail(id: number) {
|
||||
const { data } = await client.get<ApiResponse<StockDetail>>(`${apiPrefix()}/stocks/${id}/detail`)
|
||||
return unwrap(data)
|
||||
},
|
||||
async save(id: number, payload: Record<string, unknown>) {
|
||||
const { data } = await client.put<ApiResponse<StockDetail>>(`${apiPrefix()}/stocks/${id}`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
async create(payload: Record<string, unknown>) {
|
||||
const { data } = await client.post<ApiResponse<StockDetail | Stock>>(`${apiPrefix()}/stocks`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
async remove(id: number) {
|
||||
const { data } = await client.delete<ApiResponse>(`${apiPrefix()}/stocks/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
async action(id: number, payload: Record<string, unknown>) {
|
||||
const { data } = await client.post<ApiResponse<StockDetail>>(`${apiPrefix()}/stocks/${id}/action`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
async addNote(id: number, payload: { contents: string; authorName?: string }) {
|
||||
const { data } = await client.post<ApiResponse<StockNoteItem>>(`${apiPrefix()}/stocks/${id}/notes`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
async deleteNote(id: number, noteId: number) {
|
||||
const { data } = await client.delete<ApiResponse>(`${apiPrefix()}/stocks/${id}/notes/${noteId}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
async bulkIn(payload: Record<string, unknown>) {
|
||||
const { data } = await client.post<ApiResponse>(`${apiPrefix()}/stocks/bulk-in`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
async bulkAction(ids: number[], state: string) {
|
||||
const { data } = await client.post<ApiResponse>(`${apiPrefix()}/stocks/bulk-action`, { ids, state })
|
||||
return unwrap(data)
|
||||
},
|
||||
async sheet(params: Record<string, unknown>) {
|
||||
const { data } = await client.get<ApiResponse<StockSearch>>(`${apiPrefix()}/stocks/sheet`, { params })
|
||||
return unwrap(data)
|
||||
},
|
||||
exportUrl(params: Record<string, unknown>) {
|
||||
const query = new URLSearchParams(Object.entries(params).filter(([, value]) => value !== undefined && value !== '').map(([key, value]) => [key, String(value)]))
|
||||
return `${apiPrefix()}/stocks/export?${query}`
|
||||
},
|
||||
}
|
||||
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Form, Input, Popover, message } from 'antd'
|
||||
import { LockOutlined, PoweroffOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../api/client'
|
||||
import Modal from './DraggableModal'
|
||||
|
||||
export type AccountProfile = {
|
||||
name: string
|
||||
userid: string
|
||||
staffPermission: string
|
||||
orgLabel: string
|
||||
memberCount: number
|
||||
expireDate: string
|
||||
remainDays: number
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
function formatExpireKo(iso: string) {
|
||||
const [y, m, d] = iso.split('-')
|
||||
if (!y || !m || !d) return iso
|
||||
return `${y}년 ${Number(m)}월 ${Number(d)}일`
|
||||
}
|
||||
|
||||
export default function AccountPopover({ onLogout }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [profile, setProfile] = useState<AccountProfile | null>(null)
|
||||
const [pwOpen, setPwOpen] = useState(false)
|
||||
const [pwLoading, setPwLoading] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
client
|
||||
.get<ApiResponse<AccountProfile>>(`${apiPrefix()}/homes/account-profile`)
|
||||
.then(({ data }) => {
|
||||
if (!cancelled) setProfile(unwrap(data))
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setProfile({
|
||||
name: localStorage.getItem('userName') || '사용자',
|
||||
userid: localStorage.getItem('userid') || '',
|
||||
staffPermission: localStorage.getItem('staffPermission') || '',
|
||||
orgLabel: '데모',
|
||||
memberCount: 0,
|
||||
expireDate: '2030-12-31',
|
||||
remainDays: 0,
|
||||
})
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [open])
|
||||
|
||||
const openPassword = () => {
|
||||
setOpen(false)
|
||||
form.resetFields()
|
||||
setPwOpen(true)
|
||||
}
|
||||
|
||||
const submitPassword = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setPwLoading(true)
|
||||
const { data } = await client.put<ApiResponse>(`${apiPrefix()}/homes/password`, {
|
||||
currentPassword: values.currentPassword,
|
||||
newPassword: values.newPassword,
|
||||
})
|
||||
unwrap(data)
|
||||
message.success('비밀번호를 변경했습니다.')
|
||||
setPwOpen(false)
|
||||
form.resetFields()
|
||||
} catch (e) {
|
||||
if (e && typeof e === 'object' && 'errorFields' in e) return
|
||||
message.error(e instanceof Error ? e.message : '비밀번호 변경에 실패했습니다.')
|
||||
} finally {
|
||||
setPwLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const p = profile
|
||||
const remain = p?.remainDays ?? 0
|
||||
const remainText = remain >= 0 ? `${remain}일 남았습니다.` : `${Math.abs(remain)}일 지났습니다.`
|
||||
|
||||
const content = (
|
||||
<div className="account-popover">
|
||||
<div className="account-popover-head">
|
||||
<div className="account-popover-name">{p?.name || '…'}</div>
|
||||
<div className="account-popover-sub">
|
||||
{p ? `${p.orgLabel} / ${p.staffPermission}` : '…'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="account-popover-meta">
|
||||
<div className="account-popover-row">
|
||||
<span className="account-popover-label">계정</span>
|
||||
<span className="account-popover-value">{p?.userid || '…'}</span>
|
||||
</div>
|
||||
<div className="account-popover-row">
|
||||
<span className="account-popover-label">직원</span>
|
||||
<span className="account-popover-value">{p ? `${p.memberCount}명` : '…'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="account-popover-expire">
|
||||
<div className="account-popover-expire-title">이용만료일</div>
|
||||
<div className="account-popover-expire-date">
|
||||
{p ? formatExpireKo(p.expireDate) : '…'}
|
||||
</div>
|
||||
<div className="account-popover-expire-remain">{p ? remainText : ''}</div>
|
||||
</div>
|
||||
<div className="account-popover-actions">
|
||||
<button type="button" className="account-popover-action" onClick={openPassword}>
|
||||
<LockOutlined />
|
||||
<span>비밀번호변경</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="account-popover-action"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onLogout()
|
||||
}}
|
||||
>
|
||||
<PoweroffOutlined />
|
||||
<span>로그아웃</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
trigger="click"
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
overlayClassName="account-popover-overlay"
|
||||
content={content}
|
||||
>
|
||||
<button type="button" className={`header-icon-btn${open ? ' active' : ''}`} aria-label="프로필">
|
||||
<UserOutlined />
|
||||
</button>
|
||||
</Popover>
|
||||
|
||||
<Modal
|
||||
title="비밀번호변경"
|
||||
open={pwOpen}
|
||||
onCancel={() => setPwOpen(false)}
|
||||
onOk={() => void submitPassword()}
|
||||
okText="변경"
|
||||
cancelText="취소"
|
||||
confirmLoading={pwLoading}
|
||||
destroyOnClose
|
||||
width={400}
|
||||
>
|
||||
<Form form={form} layout="vertical" className="account-pw-form" requiredMark={false}>
|
||||
<Form.Item
|
||||
name="currentPassword"
|
||||
label="현재 비밀번호"
|
||||
rules={[{ required: true, message: '현재 비밀번호를 입력하세요.' }]}
|
||||
>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="newPassword"
|
||||
label="새 비밀번호"
|
||||
rules={[
|
||||
{ required: true, message: '새 비밀번호를 입력하세요.' },
|
||||
{ pattern: /^[A-Za-z0-9]{4,20}$/, message: '영문, 숫자 4~20자입니다.' },
|
||||
]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
label="새 비밀번호 확인"
|
||||
dependencies={['newPassword']}
|
||||
rules={[
|
||||
{ required: true, message: '새 비밀번호 확인을 입력하세요.' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('newPassword') === value) return Promise.resolve()
|
||||
return Promise.reject(new Error('새 비밀번호 확인이 일치하지 않습니다.'))
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
AppstoreOutlined, CalendarOutlined, ShoppingCartOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import SchedulePanel from './SchedulePanel'
|
||||
import BillCareLogo from './BillCareLogo'
|
||||
import AccountPopover from './AccountPopover'
|
||||
import { careMenuSections, filterMenuSections, resolveSelectedKey, sectionContainsPath, shopMenuSections } from '../menu'
|
||||
import { fetchMyMenus } from '../api/auth'
|
||||
import { THEMES } from '../theme'
|
||||
import { useTheme } from '../themeContext'
|
||||
|
||||
const PANEL_MIN = 170
|
||||
const PANEL_MAX = 420
|
||||
|
||||
type Props = {
|
||||
shop?: boolean
|
||||
}
|
||||
|
||||
export default function AgentShell({ shop = false }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { theme, changeTheme } = useTheme()
|
||||
const [scheduleOpen, setScheduleOpen] = useState(false)
|
||||
const userName = localStorage.getItem('userName') || (shop ? '판매점' : '관리자')
|
||||
const [allowedMenus, setAllowedMenus] = useState<string[] | null>(() => {
|
||||
if (shop) return null
|
||||
try {
|
||||
const cached = localStorage.getItem('allowedMenus')
|
||||
return cached ? (JSON.parse(cached) as string[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const [panelWidth, setPanelWidth] = useState(() => {
|
||||
const v = Number(localStorage.getItem('lp.width'))
|
||||
return v >= PANEL_MIN && v <= PANEL_MAX ? v : 220
|
||||
})
|
||||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('lp.collapsed') === '1')
|
||||
const draggingRef = useRef(false)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (shop) {
|
||||
setAllowedMenus(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
fetchMyMenus()
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
if (res.staffPermission) localStorage.setItem('staffPermission', res.staffPermission)
|
||||
setAllowedMenus(res.menus || [])
|
||||
try {
|
||||
localStorage.setItem('allowedMenus', JSON.stringify(res.menus || []))
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
try {
|
||||
const cached = localStorage.getItem('allowedMenus')
|
||||
if (cached) setAllowedMenus(JSON.parse(cached) as string[])
|
||||
else setAllowedMenus(['home'])
|
||||
} catch {
|
||||
setAllowedMenus(['home'])
|
||||
}
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [shop])
|
||||
|
||||
const allSections = useMemo(() => (shop ? shopMenuSections() : careMenuSections()), [shop])
|
||||
const sections = useMemo(
|
||||
() => (shop ? allSections : filterMenuSections(allSections, allowedMenus)),
|
||||
[shop, allSections, allowedMenus],
|
||||
)
|
||||
const selectedKey = resolveSelectedKey(location.pathname)
|
||||
|
||||
const [openCards, setOpenCards] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
setOpenCards((prev) => {
|
||||
const next = { ...prev }
|
||||
sections.forEach((sec) => {
|
||||
if (next[sec.id] === undefined) {
|
||||
next[sec.id] = sectionContainsPath(sec, location.pathname) || sec.items.length <= 1
|
||||
}
|
||||
if (sectionContainsPath(sec, location.pathname)) next[sec.id] = true
|
||||
})
|
||||
return next
|
||||
})
|
||||
}, [location.pathname, sections])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('lp.width', String(panelWidth))
|
||||
}, [panelWidth])
|
||||
useEffect(() => {
|
||||
localStorage.setItem('lp.collapsed', collapsed ? '1' : '0')
|
||||
}, [collapsed])
|
||||
|
||||
const startResize = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
draggingRef.current = true
|
||||
setDragging(true)
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'col-resize'
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!draggingRef.current) return
|
||||
setPanelWidth(Math.min(PANEL_MAX, Math.max(PANEL_MIN, e.clientX)))
|
||||
}
|
||||
const onUp = () => {
|
||||
if (!draggingRef.current) return
|
||||
draggingRef.current = false
|
||||
setDragging(false)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const logout = () => {
|
||||
localStorage.clear()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const toggleCard = (id: string) => {
|
||||
setOpenCards((prev) => ({ ...prev, [id]: !prev[id] }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
<div className={`topbar-brand${collapsed ? ' is-collapsed' : ''}`} style={collapsed ? undefined : { width: panelWidth }}>
|
||||
{!collapsed && (
|
||||
<div className="brand-inline">
|
||||
<BillCareLogo title="BillCare" size="header" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="topbar-main">
|
||||
<div className="topbar-left-tools">
|
||||
{shop ? (
|
||||
<span className="topbar-user muted">에이전트 데모 / {userName}</span>
|
||||
) : (
|
||||
<div className="agent-header-tools">
|
||||
<strong>[ BillCare ]</strong>
|
||||
<span className="header-divider" />
|
||||
<ShoppingCartOutlined />
|
||||
<span>문자발송(장바구니)</span>
|
||||
<span className="sms-remain">잔여SMS <b>996</b>건</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="topbar-right">
|
||||
{!shop && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`header-icon-btn${location.pathname.includes('/sitemap') ? ' active' : ''}`}
|
||||
aria-label="전체보기"
|
||||
title="전체보기"
|
||||
onClick={() => navigate('/care/main/sitemap')}
|
||||
>
|
||||
<AppstoreOutlined />
|
||||
</button>
|
||||
<AccountPopover onLogout={logout} />
|
||||
<button
|
||||
type="button"
|
||||
className={`header-icon-btn${scheduleOpen ? ' active' : ''}`}
|
||||
aria-label="일정표"
|
||||
onClick={() => setScheduleOpen((v) => !v)}
|
||||
>
|
||||
<CalendarOutlined />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<select
|
||||
className="theme-select"
|
||||
value={theme}
|
||||
onChange={(e) => changeTheme(e.target.value as typeof theme)}
|
||||
title="테마 선택"
|
||||
>
|
||||
{THEMES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="app-body">
|
||||
{!collapsed && (
|
||||
<aside className="leftpanel" style={{ width: panelWidth }}>
|
||||
{sections.map((sec) => {
|
||||
const open = openCards[sec.id] !== false
|
||||
const activeSec = sectionContainsPath(sec, location.pathname)
|
||||
return (
|
||||
<div className={`lp-card${activeSec ? ' is-active' : ''}`} key={sec.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="lp-card-title"
|
||||
onClick={() => toggleCard(sec.id)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span>{sec.title}</span>
|
||||
<span className="lp-card-chevron">{open ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="lp-card-body">
|
||||
{sec.items.map((it) => (
|
||||
<NavLink
|
||||
key={it.key}
|
||||
to={it.key}
|
||||
title={it.label}
|
||||
className={({ isActive }) => (isActive || selectedKey === it.key ? 'active' : undefined)}
|
||||
>
|
||||
{it.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{!collapsed && (
|
||||
<div className={`panel-resizer${dragging ? ' dragging' : ''}`} onMouseDown={startResize} />
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="panel-toggle"
|
||||
style={{ left: collapsed ? 0 : panelWidth }}
|
||||
title={collapsed ? '패널 펼치기' : '패널 접기'}
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
>
|
||||
{collapsed ? '›' : '‹'}
|
||||
</button>
|
||||
|
||||
<div className="main-area">
|
||||
<div className="content">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!shop && <SchedulePanel open={scheduleOpen} onClose={() => setScheduleOpen(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import logoImage from '../assets/logo_image.png'
|
||||
import logoMark from '../assets/logo_mark_light.png'
|
||||
import { useTheme } from '../themeContext'
|
||||
import type { Theme } from '../theme'
|
||||
|
||||
interface BillCareLogoProps {
|
||||
className?: string
|
||||
title?: string
|
||||
size?: 'header' | 'login'
|
||||
forceTheme?: Theme
|
||||
}
|
||||
|
||||
function isDarkTheme(theme: Theme) {
|
||||
return theme === 'dark' || theme === 'blue'
|
||||
}
|
||||
|
||||
/**
|
||||
* logo_image.png — 라이트 헤더 가로 로고
|
||||
* logo_mark_light.png / logo_image_theme.png — 마크 (로그인·다크 헤더)
|
||||
*/
|
||||
export default function BillCareLogo({
|
||||
className = '',
|
||||
title = 'BillCare',
|
||||
size = 'header',
|
||||
forceTheme,
|
||||
}: BillCareLogoProps) {
|
||||
const { theme } = useTheme()
|
||||
const active = forceTheme ?? theme
|
||||
const dark = isDarkTheme(active)
|
||||
const root = ['billcare-logo', `billcare-logo--${size}`, `billcare-logo--${active}`, className]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
// 로그인: 항상 마크 + BillCare (색상은 CSS로 고정)
|
||||
if (size === 'login') {
|
||||
return (
|
||||
<div className={root} title={title}>
|
||||
<img src={logoMark} alt="" className="billcare-logo-mark" draggable={false} />
|
||||
<span className="billcare-logo-title">{title}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 라이트 헤더: 가로 로고
|
||||
if (!dark) {
|
||||
return (
|
||||
<img
|
||||
src={logoImage}
|
||||
alt={title}
|
||||
title={title}
|
||||
draggable={false}
|
||||
className={root}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 다크/블루 헤더: 마크 + BillCare
|
||||
return (
|
||||
<div className={root} title={title}>
|
||||
<img src={logoMark} alt="" className="billcare-logo-mark" draggable={false} />
|
||||
<span className="billcare-logo-title">{title}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Table, type TableProps } from 'antd'
|
||||
import { normalizeTableColumns } from '../tableAlign'
|
||||
|
||||
/** Ant Design Table 래퍼 — 목록 정렬 규칙(헤더/텍스트 중앙, 숫자·금액 우측) 적용 */
|
||||
export default function CareTable<RecordType extends object>(props: TableProps<RecordType>) {
|
||||
const { columns, ...rest } = props
|
||||
return <Table<RecordType> {...rest} columns={normalizeTableColumns(columns)} />
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { Modal as AntModal, type ModalFuncProps, type ModalProps } from 'antd'
|
||||
|
||||
export type DraggableModalProps = ModalProps & {
|
||||
/** 타이틀바 우측(닫기 앞) 추가 액션 */
|
||||
titleActions?: ReactNode
|
||||
/** 타이틀바 드래그 (기본 true) */
|
||||
draggable?: boolean
|
||||
}
|
||||
|
||||
type DragState = {
|
||||
startX: number
|
||||
startY: number
|
||||
origX: number
|
||||
origY: number
|
||||
}
|
||||
|
||||
function useDraggableOffset(open?: boolean) {
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
||||
const offsetRef = useRef(offset)
|
||||
const dragRef = useRef<DragState | null>(null)
|
||||
/** 드래그 후 mask 합성 click 으로 닫히는 것 방지 */
|
||||
const suppressCloseUntilRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
offsetRef.current = offset
|
||||
}, [offset])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setOffset({ x: 0, y: 0 })
|
||||
offsetRef.current = { x: 0, y: 0 }
|
||||
dragRef.current = null
|
||||
suppressCloseUntilRef.current = 0
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const onTitleMouseDown = useCallback((e: ReactMouseEvent) => {
|
||||
if (e.button !== 0) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (target?.closest('button, a, input, select, textarea, .app-modal-close, .ant-btn')) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const current = offsetRef.current
|
||||
dragRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origX: current.x,
|
||||
origY: current.y,
|
||||
}
|
||||
let moved = false
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const d = dragRef.current
|
||||
if (!d) return
|
||||
const dx = ev.clientX - d.startX
|
||||
const dy = ev.clientY - d.startY
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) moved = true
|
||||
setOffset({ x: d.origX + dx, y: d.origY + dy })
|
||||
}
|
||||
|
||||
const blockSyntheticClick = (ev: MouseEvent) => {
|
||||
if (Date.now() < suppressCloseUntilRef.current) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
dragRef.current = null
|
||||
// mousedown(헤더) → mouseup(마스크) 시 wrap 에 click 이 가서 닫히는 문제 방지
|
||||
suppressCloseUntilRef.current = Date.now() + (moved ? 300 : 120)
|
||||
window.removeEventListener('mousemove', onMove, true)
|
||||
window.removeEventListener('mouseup', onUp, true)
|
||||
window.setTimeout(() => {
|
||||
window.removeEventListener('click', blockSyntheticClick, true)
|
||||
}, 320)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMove, true)
|
||||
window.addEventListener('mouseup', onUp, true)
|
||||
window.addEventListener('click', blockSyntheticClick, true)
|
||||
}, [])
|
||||
|
||||
const shouldSuppressClose = useCallback(() => Date.now() < suppressCloseUntilRef.current, [])
|
||||
|
||||
return { offset, onTitleMouseDown, shouldSuppressClose }
|
||||
}
|
||||
|
||||
function withModalClass(className?: string) {
|
||||
return ['app-draggable-modal', className].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function TitleBar({
|
||||
title,
|
||||
titleActions,
|
||||
draggable,
|
||||
showClose,
|
||||
onClose,
|
||||
onMouseDown,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
titleActions?: ReactNode
|
||||
draggable: boolean
|
||||
showClose: boolean
|
||||
onClose?: (e: ReactMouseEvent<HTMLButtonElement>) => void
|
||||
onMouseDown: (e: ReactMouseEvent) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`app-modal-titlebar${draggable ? ' is-draggable' : ''}`}
|
||||
onMouseDown={draggable ? onMouseDown : undefined}
|
||||
>
|
||||
<div className="app-modal-title">{title}</div>
|
||||
<div className="app-modal-title-right">
|
||||
{titleActions}
|
||||
{showClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="app-modal-close"
|
||||
onClick={onClose}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
aria-label="닫기"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DraggableModal({
|
||||
className,
|
||||
rootClassName,
|
||||
modalRender,
|
||||
open,
|
||||
title,
|
||||
titleActions,
|
||||
draggable = true,
|
||||
centered = false,
|
||||
closable = true,
|
||||
onCancel,
|
||||
classNames,
|
||||
styles,
|
||||
...rest
|
||||
}: DraggableModalProps) {
|
||||
const { offset, onTitleMouseDown, shouldSuppressClose } = useDraggableOffset(open)
|
||||
const showClose = closable !== false
|
||||
|
||||
const handleCancel: ModalProps['onCancel'] = (e) => {
|
||||
if (shouldSuppressClose()) return
|
||||
onCancel?.(e)
|
||||
}
|
||||
|
||||
const baseStyles = {
|
||||
container: {
|
||||
padding: 0,
|
||||
overflow: 'hidden' as const,
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
boxShadow: 'var(--shadow)',
|
||||
},
|
||||
header: {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
background: 'transparent',
|
||||
borderBottom: 'none',
|
||||
},
|
||||
body: {
|
||||
padding: '16px 18px 18px',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text)',
|
||||
},
|
||||
footer: {
|
||||
margin: 0,
|
||||
padding: '12px 16px 14px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<AntModal
|
||||
{...rest}
|
||||
open={open}
|
||||
centered={centered}
|
||||
closable={false}
|
||||
onCancel={handleCancel}
|
||||
className={withModalClass(className)}
|
||||
rootClassName={['app-draggable-modal-root', rootClassName].filter(Boolean).join(' ')}
|
||||
classNames={classNames}
|
||||
styles={baseStyles}
|
||||
title={(
|
||||
<TitleBar
|
||||
title={title}
|
||||
titleActions={titleActions}
|
||||
draggable={draggable}
|
||||
showClose={showClose}
|
||||
onClose={(e) => onCancel?.(e)}
|
||||
onMouseDown={onTitleMouseDown}
|
||||
/>
|
||||
)}
|
||||
modalRender={(node) => {
|
||||
const wrapped = (
|
||||
<div
|
||||
className="app-modal-drag-root"
|
||||
style={{ transform: `translate(${offset.x}px, ${offset.y}px)` }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{node}
|
||||
</div>
|
||||
)
|
||||
return modalRender ? modalRender(wrapped) : wrapped
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function StaticDragShell({
|
||||
title,
|
||||
children,
|
||||
onTitleMouseDown,
|
||||
offset,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
children: ReactNode
|
||||
onTitleMouseDown: (e: ReactMouseEvent) => void
|
||||
offset: { x: number; y: number }
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="app-modal-drag-root"
|
||||
style={{ transform: `translate(${offset.x}px, ${offset.y}px)` }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="app-modal-static-panel">
|
||||
<TitleBar
|
||||
title={title}
|
||||
draggable
|
||||
showClose={false}
|
||||
onMouseDown={onTitleMouseDown}
|
||||
/>
|
||||
<div className="app-modal-slot">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StaticDragWrap({ title, children }: { title?: ReactNode; children: ReactNode }) {
|
||||
const [open] = useState(true)
|
||||
const { offset, onTitleMouseDown, shouldSuppressClose } = useDraggableOffset(open)
|
||||
|
||||
useEffect(() => {
|
||||
const block = (ev: MouseEvent) => {
|
||||
if (shouldSuppressClose()) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
}
|
||||
}
|
||||
window.addEventListener('click', block, true)
|
||||
return () => window.removeEventListener('click', block, true)
|
||||
}, [shouldSuppressClose])
|
||||
|
||||
return (
|
||||
<StaticDragShell title={title} offset={offset} onTitleMouseDown={onTitleMouseDown}>
|
||||
{children}
|
||||
</StaticDragShell>
|
||||
)
|
||||
}
|
||||
|
||||
function wrapStatic(config: ModalFuncProps = {}): ModalFuncProps {
|
||||
const userRender = config.modalRender
|
||||
const title = config.title
|
||||
return {
|
||||
...config,
|
||||
title: null,
|
||||
closable: false,
|
||||
centered: config.centered ?? false,
|
||||
className: withModalClass(config.className),
|
||||
rootClassName: ['app-draggable-modal-root', config.rootClassName].filter(Boolean).join(' '),
|
||||
modalRender: (node) => {
|
||||
const wrapped = <StaticDragWrap title={title}>{node}</StaticDragWrap>
|
||||
return userRender ? userRender(wrapped) : wrapped
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
DraggableModal.confirm = (props?: ModalFuncProps) => AntModal.confirm(wrapStatic(props))
|
||||
DraggableModal.info = (props?: ModalFuncProps) => AntModal.info(wrapStatic(props))
|
||||
DraggableModal.success = (props?: ModalFuncProps) => AntModal.success(wrapStatic(props))
|
||||
DraggableModal.error = (props?: ModalFuncProps) => AntModal.error(wrapStatic(props))
|
||||
DraggableModal.warning = (props?: ModalFuncProps) => AntModal.warning(wrapStatic(props))
|
||||
DraggableModal.warn = (props?: ModalFuncProps) => AntModal.warn(wrapStatic(props))
|
||||
DraggableModal.destroyAll = AntModal.destroyAll
|
||||
DraggableModal.useModal = AntModal.useModal
|
||||
DraggableModal.config = AntModal.config
|
||||
|
||||
export default DraggableModal
|
||||
export type { ModalProps }
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** 카테고리 섹션 (billing_react OrderFormLayout 이식) */
|
||||
export function FormSection({
|
||||
title,
|
||||
desc,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
desc?: string
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="popup-section of-section">
|
||||
<div className="of-section-head">
|
||||
<div className="of-section-titles">
|
||||
<h3 className="of-section-title">{title}</h3>
|
||||
{desc ? <p className="of-section-desc">{desc}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="of-section-actions">{actions}</div> : null}
|
||||
</div>
|
||||
<div className="of-section-body">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function FormGroup({ label, children }: { label?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="of-group">
|
||||
{label ? <div className="of-group-label">{label}</div> : null}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FormGrid({ children, cols = 2 }: { children: ReactNode; cols?: 1 | 2 }) {
|
||||
return <div className={`of-grid of-grid-${cols}`}>{children}</div>
|
||||
}
|
||||
|
||||
export function FormField({
|
||||
label,
|
||||
hint,
|
||||
full,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
hint?: string
|
||||
full?: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={`of-field${full ? ' of-field-full' : ''}`}>
|
||||
<div className="of-field-meta">
|
||||
<span className="of-field-label">{label}</span>
|
||||
{hint ? <span className="of-field-hint">{hint}</span> : null}
|
||||
</div>
|
||||
<div className="of-field-control">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FormChoiceRow({ children }: { children: ReactNode }) {
|
||||
return <div className="of-choice-row">{children}</div>
|
||||
}
|
||||
|
||||
export function FormChoice({ children }: { children: ReactNode }) {
|
||||
return <label className="of-choice">{children}</label>
|
||||
}
|
||||
|
||||
export function FormBlock({
|
||||
title,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title?: string
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="of-block">
|
||||
{(title || actions) && (
|
||||
<div className="of-block-head">
|
||||
{title ? <span className="of-block-title">{title}</span> : null}
|
||||
{actions ? <div className="of-block-actions">{actions}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** 업무진행 팝업 섹션 카드 (billing_react PrgSection) */
|
||||
export default function PrgSection({
|
||||
title,
|
||||
extra,
|
||||
children,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
extra?: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="popup-section prg-section">
|
||||
{(title || extra) && (
|
||||
<div className="prg-section-head">
|
||||
<h3 className="prg-section-title">{title}</h3>
|
||||
{extra ? <div className="prg-section-actions">{extra}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="prg-section-body">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Select,
|
||||
message,
|
||||
} from 'antd'
|
||||
import Modal from './DraggableModal'
|
||||
import {
|
||||
FormBlock,
|
||||
FormField,
|
||||
FormGrid,
|
||||
FormGroup,
|
||||
FormSection,
|
||||
} from './OrderFormLayout'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../api/client'
|
||||
import { options } from '../pages/care/homesShared'
|
||||
|
||||
type Isp = { id: number; name: string; telecom: string; isSktb?: boolean }
|
||||
type Named = { id: number; name: string; type?: string }
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
isp: Isp | null
|
||||
counselorOptions?: string[]
|
||||
branchOptions?: string[]
|
||||
onCancel: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const PRODUCT_TYPES = [
|
||||
{ value: 'e01', label: '인터넷' },
|
||||
{ value: 'e02', label: '전화' },
|
||||
{ value: 'e03', label: 'TV' },
|
||||
{ value: 'e04', label: '렌탈' },
|
||||
]
|
||||
|
||||
const GIFT_TYPES = [
|
||||
{ value: 'a01', label: '없음' },
|
||||
{ value: 'a02', label: '현금' },
|
||||
{ value: 'a03', label: '상품' },
|
||||
{ value: 'a04', label: '현금+상품' },
|
||||
]
|
||||
|
||||
const BANKS = [
|
||||
'국민은행', '우리은행', '신한은행', '하나은행', '기업은행', '농협',
|
||||
'SC제일', '카카오뱅크', '토스뱅크', '새마을금고', '우체국', '기타',
|
||||
]
|
||||
|
||||
const CARD_COMPANIES = [
|
||||
'삼성카드', '신한카드', '현대카드', 'KB국민카드', '롯데카드',
|
||||
'우리카드', '하나카드', 'BC카드', 'NH농협카드', '기타',
|
||||
]
|
||||
|
||||
const CARD_YEARS = Array.from({ length: 31 }, (_, i) => String(i).padStart(2, '0'))
|
||||
const CARD_MONTHS = Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, '0'))
|
||||
|
||||
const DISCOUNTS = [
|
||||
{ value: 'none', label: '해당없음' },
|
||||
{ value: '국가유공자', label: '국가유공자(30% 할인)' },
|
||||
{ value: '장애인', label: '장애인(30% 할인)' },
|
||||
]
|
||||
|
||||
export default function ReceptionApplyModal({
|
||||
open,
|
||||
isp,
|
||||
counselorOptions = [],
|
||||
branchOptions = [],
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}: Props) {
|
||||
const [form] = Form.useForm()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [createFiles, setCreateFiles] = useState<Record<string, File | null>>({})
|
||||
const ispId = isp?.id
|
||||
const staffPermission = localStorage.getItem('staffPermission') || ''
|
||||
const isAdmin = ['대표', '총괄', '팀장', '개발자'].includes(staffPermission)
|
||||
|
||||
const productsQuery = useQuery({
|
||||
queryKey: ['reception-isp-products', ispId],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Named[]>>(`${apiPrefix()}/homes/reception-isps/${ispId}/products`)
|
||||
return unwrap(data)
|
||||
},
|
||||
enabled: open && ispId != null,
|
||||
})
|
||||
|
||||
const agreementsQuery = useQuery({
|
||||
queryKey: ['reception-isp-agreements', ispId],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Named[]>>(`${apiPrefix()}/homes/reception-isps/${ispId}/agreements`)
|
||||
return unwrap(data)
|
||||
},
|
||||
enabled: open && ispId != null,
|
||||
})
|
||||
|
||||
const customerType = Form.useWatch('customerType', form) || 'public'
|
||||
const paymentMethod = Form.useWatch('paymentMethod', form) || 'transfer'
|
||||
const productType = Form.useWatch('productType', form)
|
||||
const giftType = Form.useWatch('giftType', form) || 'a01'
|
||||
const isIssueDate = Form.useWatch('isIssueDate', form) ?? '1'
|
||||
const sameCustomerToPayment = Form.useWatch('sameCustomerToPayment', form)
|
||||
const sameCustomerToGift = Form.useWatch('sameCustomerToGift', form)
|
||||
const samePaymentToGift = Form.useWatch('samePaymentToGift', form)
|
||||
const watchName = Form.useWatch('name', form)
|
||||
const watchSsid = Form.useWatch('ssid', form)
|
||||
const watchPhone = Form.useWatch('phone', form)
|
||||
const watchTel = Form.useWatch('tel', form)
|
||||
const watchBank = Form.useWatch('bank', form)
|
||||
const watchAccountNumber = Form.useWatch('accountNumber', form)
|
||||
const watchPaymentName = Form.useWatch('paymentName', form)
|
||||
const watchPaymentSsid = Form.useWatch('paymentSsid', form)
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
const list = productsQuery.data || []
|
||||
if (!productType) return list
|
||||
return list.filter((p) => !p.type || p.type === productType)
|
||||
}, [productsQuery.data, productType])
|
||||
|
||||
const giftCash = giftType === 'a02' || giftType === 'a04'
|
||||
const giftGoods = giftType === 'a03' || giftType === 'a04'
|
||||
|
||||
const branchEmployeeOptions = useMemo(() => {
|
||||
const counselors = counselorOptions.length ? counselorOptions : ['홍길동', '김상담', '이상담', '정상담']
|
||||
const branches = branchOptions.length ? branchOptions : ['강남점', '서초점', '송파점']
|
||||
const out: { value: string; label: string }[] = []
|
||||
for (const b of branches) {
|
||||
for (const c of counselors) {
|
||||
out.push({ value: `${b}|${c}`, label: `[${b}] ${c}` })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [counselorOptions, branchOptions])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !isp) return
|
||||
setCreateFiles({})
|
||||
const rental = isp.telecom === 'SK매직' || isp.telecom === 'LG렌탈'
|
||||
const firstOpt = branchEmployeeOptions[0]?.value
|
||||
form.setFieldsValue({
|
||||
branchEmployee: firstOpt,
|
||||
customerType: 'public',
|
||||
paymentMethod: 'transfer',
|
||||
productType: rental ? 'e04' : 'e01',
|
||||
productId: undefined,
|
||||
agreementId: undefined,
|
||||
price: rental ? 120000 : 80000,
|
||||
installDate: dayjs(),
|
||||
discount: 'none',
|
||||
giftType: 'a01',
|
||||
giftPlace: 'b01',
|
||||
giftDate: dayjs(),
|
||||
cashAmount: 0,
|
||||
goods: '',
|
||||
goodsPrice: 0,
|
||||
name: '',
|
||||
postIt: '',
|
||||
postAdd: '',
|
||||
ssid: '',
|
||||
isIssueDate: '1',
|
||||
issueDate: undefined,
|
||||
driversLicenseNumber: '',
|
||||
phone: '',
|
||||
tel: '',
|
||||
email: '',
|
||||
zipcode: '',
|
||||
address: '',
|
||||
addressDetail: '',
|
||||
blName: '',
|
||||
blNumber: '',
|
||||
blCorpNumber: '',
|
||||
blType: '',
|
||||
blItem: '',
|
||||
blZipcode: '',
|
||||
blAddress: '',
|
||||
blAddressDetail: '',
|
||||
paymentName: '',
|
||||
paymentSsid: '',
|
||||
paymentTel: '',
|
||||
bank: undefined,
|
||||
accountNumber: '',
|
||||
cardCompany: undefined,
|
||||
cardYear: undefined,
|
||||
cardMonth: undefined,
|
||||
cardNumber: '',
|
||||
giftBank: undefined,
|
||||
giftAccountNumber: '',
|
||||
giftAccountName: '',
|
||||
giftSsid: '',
|
||||
sameCustomerToPayment: false,
|
||||
sameCustomerToGift: false,
|
||||
samePaymentToGift: false,
|
||||
memo: '',
|
||||
})
|
||||
}, [open, isp, form, branchEmployeeOptions])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sameCustomerToPayment) return
|
||||
form.setFieldsValue({
|
||||
paymentName: watchName,
|
||||
paymentSsid: watchSsid,
|
||||
paymentTel: watchPhone || watchTel,
|
||||
})
|
||||
}, [sameCustomerToPayment, watchName, watchSsid, watchPhone, watchTel, form])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sameCustomerToGift) return
|
||||
form.setFieldsValue({
|
||||
giftAccountName: watchName,
|
||||
giftSsid: watchSsid,
|
||||
})
|
||||
}, [sameCustomerToGift, watchName, watchSsid, form])
|
||||
|
||||
useEffect(() => {
|
||||
if (!samePaymentToGift) return
|
||||
form.setFieldsValue({
|
||||
giftBank: watchBank,
|
||||
giftAccountNumber: watchAccountNumber,
|
||||
giftAccountName: watchPaymentName || watchName,
|
||||
giftSsid: watchPaymentSsid || watchSsid,
|
||||
})
|
||||
}, [samePaymentToGift, watchBank, watchAccountNumber, watchPaymentName, watchPaymentSsid, watchName, watchSsid, form])
|
||||
|
||||
const searchAddress = (prefix: '' | 'bl') => {
|
||||
message.info('주소검색(데모) — 직접 입력해 주세요.')
|
||||
if (prefix === 'bl') {
|
||||
form.setFieldsValue({
|
||||
blZipcode: form.getFieldValue('blZipcode') || '06236',
|
||||
blAddress: form.getFieldValue('blAddress') || '서울특별시 강남구 테헤란로',
|
||||
})
|
||||
} else {
|
||||
form.setFieldsValue({
|
||||
zipcode: form.getFieldValue('zipcode') || '06236',
|
||||
address: form.getFieldValue('address') || '서울특별시 강남구 테헤란로',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleFinish = async (values: Record<string, unknown>) => {
|
||||
if (!isp) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const be = String(values.branchEmployee || '')
|
||||
const [branch, counselor] = be.includes('|') ? be.split('|') : [values.branch, values.counselor]
|
||||
const addrParts = [values.zipcode, values.address, values.addressDetail].filter(Boolean)
|
||||
const fullAddress = addrParts.join(' ')
|
||||
const memoExtra: string[] = []
|
||||
if (values.postAdd) memoExtra.push(`추가메모: ${values.postAdd}`)
|
||||
if (values.email) memoExtra.push(`이메일: ${values.email}`)
|
||||
if (values.tel) memoExtra.push(`일반전화: ${values.tel}`)
|
||||
if (values.paymentName) memoExtra.push(`납입자: ${values.paymentName}`)
|
||||
if (values.bank) memoExtra.push(`은행: ${values.bank} ${values.accountNumber || ''}`)
|
||||
if (values.cardCompany) memoExtra.push(`카드: ${values.cardCompany} ${values.cardNumber || ''}`)
|
||||
if (values.memo) memoExtra.push(String(values.memo))
|
||||
|
||||
const { data } = await client.post<ApiResponse<{ id: number }>>(`${apiPrefix()}/homes/receptions/apply`, {
|
||||
ispId: isp.id,
|
||||
counselor: counselor || counselorOptions[0] || '홍길동',
|
||||
branch: branch || branchOptions[0] || '',
|
||||
memo: memoExtra.join('\n'),
|
||||
customer: {
|
||||
type: values.customerType,
|
||||
name: values.name,
|
||||
phone: values.phone,
|
||||
tel: values.tel,
|
||||
ssid: values.ssid,
|
||||
email: values.email,
|
||||
postIt: values.postIt,
|
||||
postAdd: values.postAdd,
|
||||
zipcode: values.zipcode,
|
||||
address: fullAddress || values.address,
|
||||
addressDetail: values.addressDetail,
|
||||
issueDate: values.issueDate ? dayjs(values.issueDate as dayjs.Dayjs).format('YYYY-MM-DD') : undefined,
|
||||
driversLicenseNumber: values.driversLicenseNumber,
|
||||
blName: values.blName,
|
||||
blNumber: values.blNumber,
|
||||
},
|
||||
payment: {
|
||||
method: values.paymentMethod,
|
||||
name: values.paymentName,
|
||||
ssid: values.paymentSsid,
|
||||
tel: values.paymentTel,
|
||||
bank: values.bank,
|
||||
accountNumber: values.accountNumber,
|
||||
cardCompany: values.cardCompany,
|
||||
cardYear: values.cardYear,
|
||||
cardMonth: values.cardMonth,
|
||||
cardNumber: values.cardNumber,
|
||||
},
|
||||
product: {
|
||||
productType: values.productType,
|
||||
productId: values.productId,
|
||||
agreementId: values.agreementId,
|
||||
price: values.price,
|
||||
installDate: values.installDate ? dayjs(values.installDate as dayjs.Dayjs).format('YYYY-MM-DD') : undefined,
|
||||
discount: values.discount === 'none' ? '' : values.discount,
|
||||
},
|
||||
gift: {
|
||||
type: values.giftType,
|
||||
place: values.giftPlace,
|
||||
cashAmount: values.cashAmount,
|
||||
goods: values.goods,
|
||||
goodsPrice: values.goodsPrice,
|
||||
giftDate: values.giftDate ? dayjs(values.giftDate as dayjs.Dayjs).format('YYYY-MM-DD') : undefined,
|
||||
accountBank: values.giftBank,
|
||||
accountNumber: values.giftAccountNumber,
|
||||
accountName: values.giftAccountName,
|
||||
ssid: values.giftSsid,
|
||||
},
|
||||
})
|
||||
const created = unwrap(data) as { id: number }
|
||||
await uploadCreateAttachments(created.id)
|
||||
message.success(`[${isp.name}] 신규신청을 등록했습니다.`)
|
||||
onSuccess()
|
||||
} catch {
|
||||
message.error('신규신청 등록에 실패했습니다.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const uploadCreateAttachments = async (receptionId: number) => {
|
||||
for (const slot of [1, 2, 3, 4, 5] as const) {
|
||||
const file = createFiles[`file${slot}`]
|
||||
if (!file) continue
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
await client.post(`${apiPrefix()}/homes/receptions/${receptionId}/file/${slot}`, fd)
|
||||
}
|
||||
if (isAdmin) {
|
||||
for (const slot of [1, 2, 3] as const) {
|
||||
const file = createFiles[`adminFile${slot}`]
|
||||
if (!file) continue
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
await client.post(`${apiPrefix()}/homes/receptions/${receptionId}/admin-file/${slot}`, fd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buildMemoAuto = () => {
|
||||
const v = form.getFieldsValue()
|
||||
const giftLabel = GIFT_TYPES.find((g) => g.value === v.giftType)?.label || ''
|
||||
const productLabel = PRODUCT_TYPES.find((p) => p.value === v.productType)?.label || ''
|
||||
const productName = (productsQuery.data || []).find((p) => p.id === v.productId)?.name || ''
|
||||
const agreementName = (agreementsQuery.data || []).find((a) => a.id === v.agreementId)?.name || ''
|
||||
let content = ''
|
||||
content += `◈ 고객명 : ${v.name || ''}\n`
|
||||
content += `◈ 연락처 : ${v.phone || ''}\n`
|
||||
content += `◈ 상품 : ${productLabel} / ${productName}\n`
|
||||
content += `◈ 약정 : ${agreementName}\n`
|
||||
content += `◈ 사은품 : ${giftLabel}`
|
||||
if (v.giftType && v.giftType !== 'a01') {
|
||||
const parts: string[] = []
|
||||
if (v.giftType === 'a02' || v.giftType === 'a04') parts.push(`현금(${v.cashAmount || 0})`)
|
||||
if (v.giftType === 'a03' || v.giftType === 'a04') parts.push(`${v.goods || ''}(${v.goodsPrice || 0})`)
|
||||
if (parts.length) content += ` / ${parts.join(', ')}`
|
||||
}
|
||||
content += '\n'
|
||||
content += `◈ 납입자 : ${v.paymentName || ''}\n`
|
||||
content += `◈ 주소 : ${[v.zipcode, v.address, v.addressDetail].filter(Boolean).join(' ')}\n`
|
||||
return content
|
||||
}
|
||||
|
||||
const issueLabel = customerType === 'foreigner' ? '외국인등록증 발급일자' : '주민등록증 발급일자'
|
||||
const ssidLabel = customerType === 'foreigner' ? '외국인등록번호' : '주민등록번호'
|
||||
const addrLabel = customerType === 'foreigner' ? '설치주소' : '주소'
|
||||
|
||||
const addrStack = (prefix: '' | 'bl') => (
|
||||
<div className="of-addr-stack">
|
||||
<div className="of-addr-zip">
|
||||
<Form.Item name={prefix ? 'blZipcode' : 'zipcode'} noStyle>
|
||||
<Input placeholder="우편번호" style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Button type="default" onClick={() => searchAddress(prefix)}>주소검색</Button>
|
||||
</div>
|
||||
<Form.Item name={prefix ? 'blAddress' : 'address'} noStyle>
|
||||
<Input placeholder="주소" />
|
||||
</Form.Item>
|
||||
<Form.Item name={prefix ? 'blAddressDetail' : 'addressDetail'} noStyle>
|
||||
<Input placeholder="상세주소" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isp ? `신규가입 신청 - ${isp.name}` : '신규가입 신청'}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width="96%"
|
||||
style={{ maxWidth: 1040 }}
|
||||
className="reception-apply-modal order-form-modal"
|
||||
styles={{ body: { maxHeight: 'calc(92vh - 56px)', overflowY: 'auto' } }}
|
||||
>
|
||||
<Form form={form} layout="vertical" className="order-form-embedded-inner order-form-page reception-apply-form" onFinish={handleFinish}>
|
||||
<FormSection title="기본 정보">
|
||||
<FormGrid>
|
||||
<FormField label="영업점" full>
|
||||
<Form.Item name="branchEmployee" noStyle rules={[{ required: true, message: '영업점을 선택하세요' }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={branchEmployeeOptions}
|
||||
placeholder="--- 영업점 선택 ---"
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="통신사" full>
|
||||
<Input value={isp ? `[${isp.telecom}] ${isp.name}` : ''} disabled />
|
||||
</FormField>
|
||||
<FormField label="인증 링크" full>
|
||||
<p className="of-muted" style={{ padding: 0, border: 'none', margin: 0 }}>등록된 인증 링크가 없습니다.</p>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="고객정보">
|
||||
<FormGrid cols={1}>
|
||||
<FormField label="고객유형" full>
|
||||
<Form.Item name="customerType" noStyle>
|
||||
<Radio.Group className="of-antd-choice-row" style={{ padding: 0, border: 'none' }}>
|
||||
<Radio value="public">개인</Radio>
|
||||
<Radio value="company">사업자</Radio>
|
||||
<Radio value="foreigner">외국인</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
|
||||
{customerType === 'company' ? (
|
||||
<FormGrid>
|
||||
<FormField label="사업장명">
|
||||
<Form.Item name="name" noStyle rules={[{ required: true, message: '사업장명을 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="대표자명">
|
||||
<Form.Item name="blName" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="사업자등록번호">
|
||||
<Form.Item name="blNumber" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="법인등록번호">
|
||||
<Form.Item name="blCorpNumber" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="업태">
|
||||
<Form.Item name="blType" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="종목">
|
||||
<Form.Item name="blItem" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="포스트잇">
|
||||
<Form.Item name="postIt" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="추가메모">
|
||||
<Form.Item name="postAdd" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="사업장주소" full>{addrStack('bl')}</FormField>
|
||||
<FormField label="휴대폰">
|
||||
<Form.Item name="phone" noStyle rules={[{ required: true, message: '휴대폰을 입력하세요' }]}>
|
||||
<Input placeholder="010-0000-0000" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="일반전화">
|
||||
<Form.Item name="tel" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="이메일" full>
|
||||
<Form.Item name="email" noStyle><Input type="email" /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="주소" full>{addrStack('')}</FormField>
|
||||
</FormGrid>
|
||||
) : (
|
||||
<FormGrid>
|
||||
<FormField label="고객명">
|
||||
<Form.Item name="name" noStyle rules={[{ required: true, message: '고객명을 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="포스트잇">
|
||||
<Form.Item name="postIt" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="추가메모" full>
|
||||
<Form.Item name="postAdd" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label={ssidLabel} hint="예) 140101-*******">
|
||||
<Form.Item name="ssid" noStyle>
|
||||
<Input placeholder="예) 140101-*******" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label={isIssueDate === '1' ? issueLabel : '운전면허번호'}>
|
||||
<div className="order-form-inline">
|
||||
<Form.Item name="isIssueDate" noStyle>
|
||||
<Select
|
||||
style={{ minWidth: 140, flex: '0 0 auto' }}
|
||||
options={[
|
||||
{ value: '1', label: issueLabel },
|
||||
{ value: '0', label: '운전면허번호' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{isIssueDate === '1' ? (
|
||||
<Form.Item name="issueDate" noStyle>
|
||||
<DatePicker className="full-width" style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name="driversLicenseNumber" noStyle>
|
||||
<Input placeholder="예) 대구 00-123456-00" style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="휴대폰">
|
||||
<Form.Item name="phone" noStyle rules={[{ required: true, message: '휴대폰을 입력하세요' }]}>
|
||||
<Input placeholder="010-0000-0000" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="일반전화">
|
||||
<Form.Item name="tel" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="이메일" full>
|
||||
<Form.Item name="email" noStyle><Input type="email" /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label={addrLabel} full>{addrStack('')}</FormField>
|
||||
</FormGrid>
|
||||
)}
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="결제납입정보">
|
||||
<FormGrid cols={1}>
|
||||
<FormField label="납부방식" full>
|
||||
<Form.Item name="paymentMethod" noStyle>
|
||||
<Radio.Group className="of-antd-choice-row" style={{ padding: 0, border: 'none' }}>
|
||||
<Radio value="transfer">자동이체</Radio>
|
||||
<Radio value="card">신용카드</Radio>
|
||||
<Radio value="paper">지로청구</Radio>
|
||||
<Radio value="telephone">전화번호요금통합</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
|
||||
{(paymentMethod === 'transfer' || paymentMethod === 'card') && (
|
||||
<FormGrid>
|
||||
<FormField label="납입자">
|
||||
<div className="order-form-inline">
|
||||
<Form.Item name="paymentName" noStyle>
|
||||
<Input style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sameCustomerToPayment" valuePropName="checked" noStyle>
|
||||
<Checkbox>고객정보와 동일</Checkbox>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="주민등록번호">
|
||||
<Form.Item name="paymentSsid" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="전화번호" full>
|
||||
<Form.Item name="paymentTel" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
{paymentMethod === 'transfer' ? (
|
||||
<>
|
||||
<FormField label="은행">
|
||||
<Form.Item name="bank" noStyle>
|
||||
<Select allowClear placeholder="--- 은행선택 ---" options={options(BANKS)} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="계좌번호">
|
||||
<Form.Item name="accountNumber" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FormField label="카드">
|
||||
<div className="order-form-inline">
|
||||
<Form.Item name="cardCompany" noStyle>
|
||||
<Select allowClear placeholder="카드사" options={options(CARD_COMPANIES)} style={{ minWidth: 110 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="cardYear" noStyle>
|
||||
<Select allowClear placeholder="YY" options={CARD_YEARS.map((y) => ({ value: y, label: y }))} style={{ width: 72 }} />
|
||||
</Form.Item>
|
||||
<span>년</span>
|
||||
<Form.Item name="cardMonth" noStyle>
|
||||
<Select allowClear placeholder="MM" options={CARD_MONTHS.map((m) => ({ value: m, label: m }))} style={{ width: 72 }} />
|
||||
</Form.Item>
|
||||
<span>월</span>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="카드번호">
|
||||
<Form.Item name="cardNumber" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</FormGrid>
|
||||
)}
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="상품 정보">
|
||||
<FormBlock>
|
||||
<FormGrid>
|
||||
<FormField label="상품종류">
|
||||
<Form.Item name="productType" noStyle rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={PRODUCT_TYPES}
|
||||
onChange={() => form.setFieldValue('productId', undefined)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="상품">
|
||||
<Form.Item name="productId" noStyle rules={[{ required: true, message: '상품을 선택하세요' }]}>
|
||||
<Select
|
||||
loading={productsQuery.isLoading}
|
||||
options={(filteredProducts || []).map((p) => ({ value: p.id, label: p.name }))}
|
||||
placeholder="선택"
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="약정">
|
||||
<Form.Item name="agreementId" noStyle>
|
||||
<Select
|
||||
allowClear
|
||||
loading={agreementsQuery.isLoading}
|
||||
options={(agreementsQuery.data || []).map((a) => ({ value: a.id, label: a.name }))}
|
||||
placeholder="선택"
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="월사용요금">
|
||||
<Form.Item name="price" noStyle>
|
||||
<InputNumber className="full-width" min={0} controls={false} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="설치희망일자" full>
|
||||
<Form.Item name="installDate" noStyle>
|
||||
<DatePicker className="full-width" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="할인혜택" full>
|
||||
<Form.Item name="discount" noStyle>
|
||||
<Radio.Group className="of-antd-choice-row">
|
||||
{DISCOUNTS.map((d) => (
|
||||
<Radio key={d.value} value={d.value}>{d.label}</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
|
||||
<FormGroup label="상품별 사은품">
|
||||
<FormGrid>
|
||||
<FormField label="사은품종류">
|
||||
<Form.Item name="giftType" noStyle>
|
||||
<Select options={GIFT_TYPES} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="현금금액">
|
||||
<Form.Item name="cashAmount" noStyle>
|
||||
<InputNumber className="full-width" min={0} controls={false} disabled={!giftCash} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="물품명">
|
||||
<Form.Item name="goods" noStyle>
|
||||
<Input disabled={!giftGoods} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="물품금액">
|
||||
<Form.Item name="goodsPrice" noStyle>
|
||||
<InputNumber className="full-width" min={0} controls={false} disabled={!giftGoods} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormGroup>
|
||||
</FormBlock>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="사은품 정보">
|
||||
<FormGrid>
|
||||
<FormField label="지급예정일자">
|
||||
<Form.Item name="giftDate" noStyle>
|
||||
<DatePicker className="full-width" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="사은품지급처">
|
||||
<Form.Item name="giftPlace" noStyle>
|
||||
<Radio.Group className="of-antd-choice-row">
|
||||
<Radio value="b01">본사요청</Radio>
|
||||
<Radio value="b02">자체발송</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="결제은행">
|
||||
<div className="order-form-inline">
|
||||
<Form.Item name="giftBank" noStyle>
|
||||
<Select allowClear placeholder="--- 은행선택 ---" options={options(BANKS)} style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="samePaymentToGift" valuePropName="checked" noStyle>
|
||||
<Checkbox>결제정보와 동일</Checkbox>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="계좌번호">
|
||||
<Form.Item name="giftAccountNumber" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="예금주명">
|
||||
<div className="order-form-inline">
|
||||
<Form.Item name="giftAccountName" noStyle>
|
||||
<Input style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sameCustomerToGift" valuePropName="checked" noStyle>
|
||||
<Checkbox>고객정보와 동일</Checkbox>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="주민등록번호">
|
||||
<Form.Item name="giftSsid" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="기타 정보">
|
||||
<FormGrid cols={1}>
|
||||
<FormField label="기타사항" full>
|
||||
<div className="of-toolbar-inline">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost sm"
|
||||
onClick={() => form.setFieldsValue({ memo: `${form.getFieldValue('memo') || ''}${buildMemoAuto()}` })}
|
||||
>
|
||||
기타사항 자동입력
|
||||
</button>
|
||||
<button type="button" className="ghost sm" onClick={() => form.setFieldsValue({ memo: '' })}>
|
||||
기타사항 삭제
|
||||
</button>
|
||||
</div>
|
||||
<Form.Item name="memo" noStyle>
|
||||
<Input.TextArea rows={6} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
{[1, 2, 3, 4, 5].map((slot) => (
|
||||
<FormField key={slot} label={`첨부파일${slot}`} full>
|
||||
<div className="order-form-inline">
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0] || null
|
||||
setCreateFiles((prev) => ({ ...prev, [`file${slot}`]: f }))
|
||||
}}
|
||||
/>
|
||||
{createFiles[`file${slot}`] ? (
|
||||
<span className="muted">{createFiles[`file${slot}`]?.name}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</FormField>
|
||||
))}
|
||||
{isAdmin
|
||||
? [1, 2, 3].map((slot) => (
|
||||
<FormField key={`a${slot}`} label={`관리자 첨부파일${slot}`} full>
|
||||
<div className="order-form-inline">
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0] || null
|
||||
setCreateFiles((prev) => ({ ...prev, [`adminFile${slot}`]: f }))
|
||||
}}
|
||||
/>
|
||||
{createFiles[`adminFile${slot}`] ? (
|
||||
<span className="muted">{createFiles[`adminFile${slot}`]?.name}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</FormField>
|
||||
))
|
||||
: null}
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
|
||||
<div className="order-form-actions">
|
||||
<Button type="primary" htmlType="submit" loading={saving} className="pending-submit-btn">
|
||||
등록
|
||||
</Button>
|
||||
<Button onClick={onCancel}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Checkbox, DatePicker, Input, Select, message } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import Modal from './DraggableModal'
|
||||
import { FormField, FormGrid, FormSection } from './OrderFormLayout'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../api/client'
|
||||
import { options } from '../pages/care/homesShared'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
selectedIds: number[]
|
||||
progressStatuses: string[]
|
||||
giftStatuses: string[]
|
||||
centerOptions?: string[]
|
||||
onCancel: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const GIFT_PLACES = [
|
||||
{ value: 'b01', label: '본사요청' },
|
||||
{ value: 'b02', label: '자체발송' },
|
||||
{ value: '본사', label: '본사발송' },
|
||||
{ value: '영업점', label: '영업점발송' },
|
||||
]
|
||||
|
||||
export default function ReceptionBulkModal({
|
||||
open,
|
||||
selectedIds,
|
||||
progressStatuses,
|
||||
giftStatuses,
|
||||
centerOptions = [],
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}: Props) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const [pgAdminOnly, setPgAdminOnly] = useState(false)
|
||||
const [pgStatus, setPgStatus] = useState<string | undefined>()
|
||||
const [pgNotNotice, setPgNotNotice] = useState(false)
|
||||
const [pgCenterId, setPgCenterId] = useState<string | undefined>()
|
||||
const [pgMemo, setPgMemo] = useState('')
|
||||
const [pgOpended, setPgOpended] = useState('')
|
||||
const [pgCanceled, setPgCanceled] = useState('')
|
||||
|
||||
const [gPlace, setGPlace] = useState<string | undefined>()
|
||||
const [gStatus, setGStatus] = useState<string | undefined>()
|
||||
const [gProgressLog, setGProgressLog] = useState('')
|
||||
const [gDeliveryCompany, setGDeliveryCompany] = useState('')
|
||||
const [gTrackingNumber, setGTrackingNumber] = useState('')
|
||||
const [gRequested, setGRequested] = useState('')
|
||||
const [gSended, setGSended] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setPgAdminOnly(false)
|
||||
setPgStatus(undefined)
|
||||
setPgNotNotice(false)
|
||||
setPgCenterId(undefined)
|
||||
setPgMemo('')
|
||||
setPgOpended('')
|
||||
setPgCanceled('')
|
||||
setGPlace(undefined)
|
||||
setGStatus(undefined)
|
||||
setGProgressLog('')
|
||||
setGDeliveryCompany('')
|
||||
setGTrackingNumber('')
|
||||
setGRequested('')
|
||||
setGSended('')
|
||||
}, [open])
|
||||
|
||||
const need = () => {
|
||||
if (!selectedIds.length) {
|
||||
message.warning('대상 접수를 선택하세요.')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const run = async (fn: () => Promise<void>) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await fn()
|
||||
onSuccess()
|
||||
} catch (e: unknown) {
|
||||
message.error(e instanceof Error ? e.message : '일괄 처리에 실패했습니다.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runProgress = () => {
|
||||
if (!need()) return
|
||||
if (!pgStatus) {
|
||||
message.warning('진행상황을 선택하세요.')
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`선택 ${selectedIds.length}건을 일괄 처리하시겠습니까?`)) return
|
||||
void run(async () => {
|
||||
const { data } = await client.post<ApiResponse<{ count: number }>>(`${apiPrefix()}/homes/receptions/bulk-progress`, {
|
||||
ids: selectedIds,
|
||||
status: pgStatus,
|
||||
memo: pgMemo,
|
||||
centerId: pgCenterId || null,
|
||||
opended: pgOpended || null,
|
||||
canceled: pgCanceled || null,
|
||||
adminOnly: pgAdminOnly,
|
||||
notNotice: pgNotNotice,
|
||||
})
|
||||
const res = unwrap(data) as { count: number }
|
||||
message.success(`${res.count}건 진행상황을 변경했습니다.`)
|
||||
})
|
||||
}
|
||||
|
||||
const runGift = () => {
|
||||
if (!need()) return
|
||||
if (!gStatus) {
|
||||
message.warning('사은품진행상황을 선택하세요.')
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`선택 ${selectedIds.length}건을 일괄 처리하시겠습니까?`)) return
|
||||
void run(async () => {
|
||||
const { data } = await client.post<ApiResponse<{ count: number }>>(`${apiPrefix()}/homes/receptions/bulk-gift`, {
|
||||
ids: selectedIds,
|
||||
place: gPlace || null,
|
||||
status: gStatus,
|
||||
giftProgressLog: gProgressLog,
|
||||
deliveryCompany: gDeliveryCompany || null,
|
||||
trackingNumber: gTrackingNumber || null,
|
||||
requested: gRequested || null,
|
||||
sended: gSended || null,
|
||||
})
|
||||
const res = unwrap(data) as { count: number }
|
||||
message.success(`${res.count}건 사은품진행을 변경했습니다.`)
|
||||
})
|
||||
}
|
||||
|
||||
const runCenterWait = () => {
|
||||
if (!need()) return
|
||||
if (!window.confirm('입력처 정산대기로 일괄처리 하시겠습니까?')) return
|
||||
void run(async () => {
|
||||
const { data } = await client.post<ApiResponse<{ count: number }>>(`${apiPrefix()}/homes/receptions/bulk-center-progress`, {
|
||||
ids: selectedIds,
|
||||
})
|
||||
const res = unwrap(data) as { count: number }
|
||||
message.success(`${res.count}건을 입력처 정산대기로 변경했습니다.`)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`일괄진행 (${selectedIds.length}건 선택)`}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width="96%"
|
||||
style={{ maxWidth: 1040 }}
|
||||
className="reception-bulk-modal reception-apply-modal order-form-modal"
|
||||
styles={{ body: { maxHeight: 'calc(92vh - 56px)', overflowY: 'auto' } }}
|
||||
>
|
||||
<div className="order-form-embedded-inner order-form-page reception-apply-form reception-bulk-form">
|
||||
<FormSection title="진행상황 일괄 처리">
|
||||
<FormGrid>
|
||||
<FormField label="관리자 전용로그" full>
|
||||
<div className="order-form-inline">
|
||||
<Checkbox checked={pgAdminOnly} onChange={(e) => setPgAdminOnly(e.target.checked)}>
|
||||
관리자 전용로그
|
||||
</Checkbox>
|
||||
<span className="bulk-hint">■ 관리자만 보이고 영업점은 보이지 않습니다.</span>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="진행상황" full>
|
||||
<div className="order-form-inline">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="진행상황 선택"
|
||||
value={pgStatus}
|
||||
onChange={(v) => setPgStatus(v)}
|
||||
options={options(progressStatuses)}
|
||||
style={{ flex: 1, minWidth: 180 }}
|
||||
/>
|
||||
<Checkbox checked={pgNotNotice} onChange={(e) => setPgNotNotice(e.target.checked)}>
|
||||
하부점에 전달하지 않음
|
||||
</Checkbox>
|
||||
</div>
|
||||
<p className="bulk-hint-line">■ 퀵전송으로 등록된 접수인 경우 진행상황을 하부점에 알리지 않습니다.</p>
|
||||
<p className="bulk-hint-line">■ 진행상황을 1개 선택후 검색하여야 진행상황을 변경할 수 있습니다.</p>
|
||||
</FormField>
|
||||
<FormField label="입력처" full>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="입력처 선택"
|
||||
value={pgCenterId}
|
||||
onChange={(v) => setPgCenterId(v)}
|
||||
options={options(centerOptions.length ? centerOptions : ['본사'])}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="업무진행메모(LOG)" full>
|
||||
<Input.TextArea rows={3} value={pgMemo} onChange={(e) => setPgMemo(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="개통일자">
|
||||
<DatePicker
|
||||
value={pgOpended ? dayjs(pgOpended) : null}
|
||||
onChange={(d) => setPgOpended(d ? d.format('YYYY-MM-DD') : '')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="해지일자">
|
||||
<DatePicker
|
||||
value={pgCanceled ? dayjs(pgCanceled) : null}
|
||||
onChange={(d) => setPgCanceled(d ? d.format('YYYY-MM-DD') : '')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
<div className="bulk-foot">
|
||||
<button type="button" className="bulk-submit" disabled={busy} onClick={runProgress}>선택 일괄 처리</button>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="본사발송 사은품 일괄 처리">
|
||||
<FormGrid>
|
||||
<FormField label="사은품 지급처">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="지급처 선택"
|
||||
value={gPlace}
|
||||
onChange={(v) => setGPlace(v)}
|
||||
options={GIFT_PLACES}
|
||||
/>
|
||||
<p className="bulk-hint-line">■ 지급처를 검색하세요.</p>
|
||||
</FormField>
|
||||
<FormField label="사은품진행상황">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="사은품 진행상황 선택"
|
||||
value={gStatus}
|
||||
onChange={(v) => setGStatus(v)}
|
||||
options={options(giftStatuses)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="사은품진행메모(LOG)" full>
|
||||
<Input.TextArea rows={3} value={gProgressLog} onChange={(e) => setGProgressLog(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="택배회사">
|
||||
<Input value={gDeliveryCompany} onChange={(e) => setGDeliveryCompany(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="송장번호">
|
||||
<Input value={gTrackingNumber} onChange={(e) => setGTrackingNumber(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="사은품지급요청일">
|
||||
<DatePicker
|
||||
value={gRequested ? dayjs(gRequested) : null}
|
||||
onChange={(d) => setGRequested(d ? d.format('YYYY-MM-DD') : '')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="사은품지급완료일">
|
||||
<DatePicker
|
||||
value={gSended ? dayjs(gSended) : null}
|
||||
onChange={(d) => setGSended(d ? d.format('YYYY-MM-DD') : '')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
<div className="bulk-foot">
|
||||
<button type="button" className="bulk-submit" disabled={busy} onClick={runGift}>선택 일괄 처리</button>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="입력처 - 정산대기로 일괄처리">
|
||||
<FormGrid cols={1}>
|
||||
<FormField label="안내" full>
|
||||
<p className="bulk-desc-block" style={{ padding: 0, margin: 0 }}>
|
||||
■ 개통완료된 고객중에 입력처 정산이 진행안된 고객은 입력처 정산대기로 일괄처리 진행할수 있습니다.
|
||||
</p>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
<div className="bulk-foot">
|
||||
<button type="button" className="bulk-submit" disabled={busy} onClick={runCenterWait}>선택 일괄 처리</button>
|
||||
</div>
|
||||
</FormSection>
|
||||
</div>
|
||||
|
||||
<div className="balance-modal-footer" style={{ marginTop: 12 }}>
|
||||
<Button onClick={onCancel}>닫기</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
message,
|
||||
} from 'antd'
|
||||
import Modal from './DraggableModal'
|
||||
import PrgSection from './PrgSection'
|
||||
import ReceptionProgressReadOnly from './ReceptionProgressReadOnly'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../api/client'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
receptionId: number | null
|
||||
onClose: () => void
|
||||
onChanged?: () => void
|
||||
onEdit?: (id: number) => void
|
||||
onCopyLine?: (ispId: number) => void
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
postIt: string
|
||||
postAdd: string
|
||||
status: string
|
||||
adminOnly: boolean
|
||||
contractNumber: string
|
||||
installSchedule: string
|
||||
customerLoginId: string
|
||||
newTel: string
|
||||
multiLine: boolean
|
||||
addPayer: boolean
|
||||
record: boolean
|
||||
progressLog: string
|
||||
adminMemo: string
|
||||
openDate: string
|
||||
cancelDate: string
|
||||
giftStatus: string
|
||||
giftPlace: string
|
||||
giftProgressLog: string
|
||||
giftRequested: string
|
||||
giftSended: string
|
||||
changeOrderDate: boolean
|
||||
orderDate: string
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
postIt: '',
|
||||
postAdd: '',
|
||||
status: '',
|
||||
adminOnly: false,
|
||||
contractNumber: '',
|
||||
installSchedule: '',
|
||||
customerLoginId: '',
|
||||
newTel: '',
|
||||
multiLine: false,
|
||||
addPayer: false,
|
||||
record: false,
|
||||
progressLog: '',
|
||||
adminMemo: '',
|
||||
openDate: '',
|
||||
cancelDate: '',
|
||||
giftStatus: '',
|
||||
giftPlace: '',
|
||||
giftProgressLog: '',
|
||||
giftRequested: '',
|
||||
giftSended: '',
|
||||
changeOrderDate: false,
|
||||
orderDate: '',
|
||||
}
|
||||
|
||||
function d10(v?: string | null) {
|
||||
return v ? String(v).replace('T', ' ').substring(0, 10) : ''
|
||||
}
|
||||
function dt16(v?: string | null) {
|
||||
return v ? String(v).replace('T', ' ').substring(0, 16) : ''
|
||||
}
|
||||
|
||||
export default function ReceptionProgressModal({
|
||||
open,
|
||||
receptionId,
|
||||
onClose,
|
||||
onChanged,
|
||||
onEdit,
|
||||
onCopyLine,
|
||||
}: Props) {
|
||||
const qc = useQueryClient()
|
||||
const [f, setF] = useState<FormState>(EMPTY)
|
||||
const [recreateIsp, setRecreateIsp] = useState<number | undefined>()
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ['reception-progress', receptionId],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>>>(
|
||||
`${apiPrefix()}/homes/receptions/${receptionId}`,
|
||||
)
|
||||
return unwrap(data) as Record<string, any>
|
||||
},
|
||||
enabled: open && receptionId != null,
|
||||
})
|
||||
|
||||
const ispQuery = useQuery({
|
||||
queryKey: ['reception-isps'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ id: number; name: string }[]>>(
|
||||
`${apiPrefix()}/homes/reception-isps`,
|
||||
)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const data = detailQuery.data
|
||||
const reception = data?.reception || data
|
||||
const progressStatuses: string[] = data?.progressStatuses || []
|
||||
const giftStatuses: string[] = data?.giftStatuses || []
|
||||
const progressLogs: any[] = data?.progressLogs || data?.logs || []
|
||||
const giftLogs: any[] = data?.giftProgressLogs || []
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !reception) return
|
||||
const gift = data.gift || {}
|
||||
const customer = data.customer || {}
|
||||
setF({
|
||||
postIt: customer.postIt ?? reception.postIt ?? '',
|
||||
postAdd: customer.postAdd ?? reception.postAdd ?? '',
|
||||
status: '',
|
||||
adminOnly: false,
|
||||
contractNumber: reception.contractNumber ?? '',
|
||||
installSchedule: reception.installSchedule ?? '',
|
||||
customerLoginId: reception.customerLoginId ?? '',
|
||||
newTel: reception.newTel ?? '',
|
||||
multiLine: !!reception.multiLine,
|
||||
addPayer: !!reception.addPayer,
|
||||
record: !!reception.record,
|
||||
progressLog: '',
|
||||
adminMemo: data.etc?.adminMemo ?? reception.adminMemo ?? '',
|
||||
openDate: d10(reception.openDate),
|
||||
cancelDate: d10(reception.cancelDate),
|
||||
giftStatus: '',
|
||||
giftPlace: gift.place ?? reception.giftPlace ?? '',
|
||||
giftProgressLog: '',
|
||||
giftRequested: d10(gift.requested ?? reception.giftRequested),
|
||||
giftSended: d10(gift.sended ?? reception.giftSended),
|
||||
changeOrderDate: false,
|
||||
orderDate: d10(reception.orderDate),
|
||||
})
|
||||
}, [data]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const set = (patch: Partial<FormState>) => setF((p) => ({ ...p, ...patch }))
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async (closeAfter: boolean) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
postIt: f.postIt,
|
||||
postAdd: f.postAdd,
|
||||
contractNumber: f.contractNumber,
|
||||
installSchedule: f.installSchedule,
|
||||
customerLoginId: f.customerLoginId,
|
||||
newTel: f.newTel,
|
||||
multiLine: f.multiLine,
|
||||
addPayer: f.addPayer,
|
||||
record: f.record,
|
||||
adminMemo: f.adminMemo,
|
||||
openDate: f.openDate || null,
|
||||
cancelDate: f.cancelDate || null,
|
||||
giftPlace: f.giftPlace,
|
||||
giftRequested: f.giftRequested || null,
|
||||
giftSended: f.giftSended || null,
|
||||
adminOnly: f.adminOnly,
|
||||
memo: f.progressLog,
|
||||
giftProgressLog: f.giftProgressLog,
|
||||
changeOrderDate: f.changeOrderDate,
|
||||
orderDate: f.changeOrderDate && f.orderDate ? `${f.orderDate} 00:00:00` : undefined,
|
||||
}
|
||||
if (f.status) payload.status = f.status
|
||||
if (f.giftStatus) payload.giftStatus = f.giftStatus
|
||||
const { data: res } = await client.post(
|
||||
`${apiPrefix()}/homes/receptions/${receptionId}/progress`,
|
||||
payload,
|
||||
)
|
||||
return { body: unwrap(res), closeAfter }
|
||||
},
|
||||
onSuccess: ({ closeAfter }) => {
|
||||
message.success('저장했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['reception-progress', receptionId] })
|
||||
qc.invalidateQueries({ queryKey: ['receptions'] })
|
||||
onChanged?.()
|
||||
if (closeAfter) onClose()
|
||||
else {
|
||||
setF((p) => ({ ...p, status: '', giftStatus: '', progressLog: '', giftProgressLog: '', adminOnly: false }))
|
||||
detailQuery.refetch()
|
||||
}
|
||||
},
|
||||
onError: () => message.error('저장에 실패했습니다.'),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data: res } = await client.delete(`${apiPrefix()}/homes/receptions/${receptionId}`)
|
||||
return unwrap(res)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['receptions'] })
|
||||
onChanged?.()
|
||||
onClose()
|
||||
},
|
||||
onError: () => message.error('삭제에 실패했습니다.'),
|
||||
})
|
||||
|
||||
const delProgressLog = async (logId: number) => {
|
||||
try {
|
||||
await client.delete(`${apiPrefix()}/homes/receptions/progress-log/${logId}`)
|
||||
detailQuery.refetch()
|
||||
message.success('로그를 삭제했습니다.')
|
||||
} catch {
|
||||
message.error('로그 삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const delGiftLog = async (logId: number) => {
|
||||
try {
|
||||
await client.delete(`${apiPrefix()}/homes/receptions/gift-progress-log/${logId}`)
|
||||
detailQuery.refetch()
|
||||
message.success('사은품 로그를 삭제했습니다.')
|
||||
} catch {
|
||||
message.error('로그 삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const titleWarn = reception?.progressStatus === '9.취소' || reception?.detailProgress?.includes?.('주의')
|
||||
const title = receptionId
|
||||
? `${titleWarn ? '⚠ 주의 요망 ' : ''}업무 진행 내역 — 접수 #${receptionId}`
|
||||
: '업무 진행 내역'
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width="96%"
|
||||
style={{ maxWidth: 900 }}
|
||||
className="reception-progress-modal order-progress-modal"
|
||||
styles={{ body: { maxHeight: 'calc(92vh - 56px)', overflowY: 'auto', padding: '12px 14px 16px' } }}
|
||||
>
|
||||
{detailQuery.isLoading || !data ? (
|
||||
<div style={{ padding: 12 }}>로딩 중...</div>
|
||||
) : (
|
||||
<div className="prg-body">
|
||||
<ReceptionProgressReadOnly data={data} />
|
||||
|
||||
<PrgSection title="업무 진행 내역">
|
||||
<table className="prg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>접수자</th>
|
||||
<td colSpan={3}>
|
||||
<div className="prg-inline">
|
||||
<Input readOnly value={data.employeeName || reception?.counselor || ''} style={{ width: 200 }} />
|
||||
<label className="prg-chk">
|
||||
<Checkbox checked={f.adminOnly} onChange={(e) => set({ adminOnly: e.target.checked })} />
|
||||
관리자 전용로그
|
||||
</label>
|
||||
<span className="prg-note-i">(관리자만 보이고 영업점은 보이지 않습니다.)</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>현재진행상황</th>
|
||||
<td colSpan={3}>
|
||||
<Input readOnly value={reception?.progressStatus || ''} style={{ width: 200 }} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>포스트잇</th>
|
||||
<td colSpan={3}>
|
||||
<Input value={f.postIt} onChange={(e) => set({ postIt: e.target.value })} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>추가메모</th>
|
||||
<td colSpan={3}>
|
||||
<Input value={f.postAdd} onChange={(e) => set({ postAdd: e.target.value })} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>진행상황</th>
|
||||
<td colSpan={3}>
|
||||
<div className="prg-inline">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="진행상황 선택"
|
||||
style={{ minWidth: 180 }}
|
||||
value={f.status || undefined}
|
||||
onChange={(v) => set({ status: v || '' })}
|
||||
options={progressStatuses.map((s) => ({ value: s, label: s }))}
|
||||
/>
|
||||
<Button size="small" loading={saveMut.isPending} onClick={() => saveMut.mutate(false)}>
|
||||
적 용
|
||||
</Button>
|
||||
</div>
|
||||
<span className="prg-note">적용 버튼은 창을 닫지 않고 현재 설정을 저장합니다.</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>계약번호</th>
|
||||
<td>
|
||||
<Input value={f.contractNumber} onChange={(e) => set({ contractNumber: e.target.value })} />
|
||||
</td>
|
||||
<th>설치일정</th>
|
||||
<td>
|
||||
<Input value={f.installSchedule} onChange={(e) => set({ installSchedule: e.target.value })} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>고객아이디</th>
|
||||
<td>
|
||||
<Input value={f.customerLoginId} onChange={(e) => set({ customerLoginId: e.target.value })} />
|
||||
</td>
|
||||
<th>신규전화번호</th>
|
||||
<td>
|
||||
<Input value={f.newTel} onChange={(e) => set({ newTel: e.target.value })} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>접수유형</th>
|
||||
<td colSpan={3}>
|
||||
<div className="prg-inline">
|
||||
<label className="prg-chk">
|
||||
<Checkbox checked={f.multiLine} onChange={(e) => set({ multiLine: e.target.checked })} /> 다회선
|
||||
</label>
|
||||
<label className="prg-chk">
|
||||
<Checkbox checked={f.addPayer} onChange={(e) => set({ addPayer: e.target.checked })} /> 납입자추가
|
||||
</label>
|
||||
<label className="prg-chk">
|
||||
<Checkbox checked={f.record} onChange={(e) => set({ record: e.target.checked })} /> 녹취
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>업무진행메모(LOG)</th>
|
||||
<td colSpan={3}>
|
||||
<Input.TextArea rows={4} value={f.progressLog} onChange={(e) => set({ progressLog: e.target.value })} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>관리자메모</th>
|
||||
<td colSpan={3}>
|
||||
<Input.TextArea rows={3} value={f.adminMemo} onChange={(e) => set({ adminMemo: e.target.value })} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>접수일자</th>
|
||||
<td colSpan={3}>
|
||||
<div className="prg-inline">
|
||||
<DatePicker
|
||||
value={f.orderDate ? dayjs(f.orderDate) : null}
|
||||
onChange={(d) => set({ orderDate: d ? d.format('YYYY-MM-DD') : '' })}
|
||||
/>
|
||||
<label className="prg-chk">
|
||||
<Checkbox checked={f.changeOrderDate} onChange={(e) => set({ changeOrderDate: e.target.checked })} />
|
||||
접수일자 변경
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>개통일자</th>
|
||||
<td>
|
||||
<DatePicker
|
||||
className="full-width"
|
||||
style={{ width: '100%' }}
|
||||
value={f.openDate ? dayjs(f.openDate) : null}
|
||||
onChange={(d) => set({ openDate: d ? d.format('YYYY-MM-DD') : '' })}
|
||||
/>
|
||||
</td>
|
||||
<th>해지일자</th>
|
||||
<td>
|
||||
<DatePicker
|
||||
className="full-width"
|
||||
style={{ width: '100%' }}
|
||||
value={f.cancelDate ? dayjs(f.cancelDate) : null}
|
||||
onChange={(d) => set({ cancelDate: d ? d.format('YYYY-MM-DD') : '' })}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
|
||||
<PrgSection title="업무 진행 현황">
|
||||
<table className="prg-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 150 }}>날짜</th>
|
||||
<th style={{ width: 100 }}>진행상태</th>
|
||||
<th style={{ width: 100 }}>담당자</th>
|
||||
<th>내용</th>
|
||||
<th style={{ width: 60 }}>삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{progressLogs.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td className="center">{dt16(l.loggedAt || l.createdAt)}</td>
|
||||
<td className="center">{l.status}</td>
|
||||
<td className="center">{l.authorName || l.writer}</td>
|
||||
<td>{l.adminOnly ? '[관리자] ' : ''}{l.memo}</td>
|
||||
<td className="center">
|
||||
<Button danger size="small" onClick={() => delProgressLog(l.id)}>삭제</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{progressLogs.length === 0 && (
|
||||
<tr><td colSpan={5} className="muted" style={{ textAlign: 'center' }}>진행 내역이 없습니다.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
|
||||
<PrgSection title="사은품 진행 내역">
|
||||
<table className="prg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>사은품현재상황</th>
|
||||
<td>
|
||||
<Input readOnly value={data.gift?.status || reception?.giftStatus || ''} />
|
||||
</td>
|
||||
<th>사은품진행상황</th>
|
||||
<td>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="사은품 진행상황 선택"
|
||||
style={{ width: '100%' }}
|
||||
value={f.giftStatus || undefined}
|
||||
onChange={(v) => set({ giftStatus: v || '' })}
|
||||
options={giftStatuses.map((s) => ({ value: s, label: s }))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사은품 지급처</th>
|
||||
<td>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
value={f.giftPlace || undefined}
|
||||
onChange={(v) => set({ giftPlace: v || '' })}
|
||||
options={[
|
||||
{ value: 'b01', label: '본사요청' },
|
||||
{ value: 'b02', label: '자체발송' },
|
||||
{ value: '본사', label: '본사' },
|
||||
{ value: '영업점', label: '영업점' },
|
||||
]}
|
||||
/>
|
||||
</td>
|
||||
<th>지급요청/완료</th>
|
||||
<td>
|
||||
<Space>
|
||||
<DatePicker
|
||||
placeholder="요청일"
|
||||
value={f.giftRequested ? dayjs(f.giftRequested) : null}
|
||||
onChange={(d) => set({ giftRequested: d ? d.format('YYYY-MM-DD') : '' })}
|
||||
/>
|
||||
<DatePicker
|
||||
placeholder="완료일"
|
||||
value={f.giftSended ? dayjs(f.giftSended) : null}
|
||||
onChange={(d) => set({ giftSended: d ? d.format('YYYY-MM-DD') : '' })}
|
||||
/>
|
||||
</Space>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사은품진행메모</th>
|
||||
<td colSpan={3}>
|
||||
<Input.TextArea rows={3} value={f.giftProgressLog} onChange={(e) => set({ giftProgressLog: e.target.value })} />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
|
||||
<PrgSection title="사은품 진행 현황">
|
||||
<table className="prg-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 150 }}>날짜</th>
|
||||
<th style={{ width: 150 }}>사은품진행상태</th>
|
||||
<th style={{ width: 100 }}>담당자</th>
|
||||
<th>내용</th>
|
||||
<th style={{ width: 60 }}>삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{giftLogs.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td className="center">{dt16(l.loggedAt || l.createdAt)}</td>
|
||||
<td className="center">{l.status}</td>
|
||||
<td className="center">{l.authorName || l.writer}</td>
|
||||
<td>{l.memo}</td>
|
||||
<td className="center">
|
||||
<Button danger size="small" onClick={() => delGiftLog(l.id)}>삭제</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{giftLogs.length === 0 && (
|
||||
<tr><td colSpan={5} className="muted" style={{ textAlign: 'center' }}>사은품 진행 내역이 없습니다.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
|
||||
<div className="prg-foot">
|
||||
<div className="prg-foot-row">
|
||||
<Button type="primary" loading={saveMut.isPending} onClick={() => saveMut.mutate(true)}>
|
||||
등록
|
||||
</Button>
|
||||
<Button onClick={() => receptionId && onEdit?.(receptionId)}>수정</Button>
|
||||
<Button
|
||||
danger
|
||||
loading={deleteMut.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm('이 접수를 삭제하시겠습니까?')) deleteMut.mutate()
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={onClose}>닫기</Button>
|
||||
</div>
|
||||
<div className="prg-foot-row">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="추가 회선 선택"
|
||||
style={{ minWidth: 220 }}
|
||||
value={recreateIsp}
|
||||
onChange={(v) => setRecreateIsp(v)}
|
||||
options={(ispQuery.data || []).map((i) => ({ value: i.id, label: i.name }))}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!recreateIsp) {
|
||||
message.warning('추가 회선 통신사를 선택하세요.')
|
||||
return
|
||||
}
|
||||
onCopyLine?.(recreateIsp)
|
||||
}}
|
||||
>
|
||||
추가 회선 등록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import PrgSection from './PrgSection'
|
||||
|
||||
type ProgressDetail = Record<string, any>
|
||||
|
||||
const pmChecked = (method: string | undefined, code: string) => {
|
||||
const m = method || 'transfer'
|
||||
if (code === 'transfer') return m === 'transfer' || m === '계좌'
|
||||
if (code === 'card') return m === 'card' || m === '카드'
|
||||
if (code === 'giro' || code === 'paper') return m === 'giro' || m === 'paper' || m === '지로'
|
||||
if (code === 'phone' || code === 'telephone') return m === 'phone' || m === 'telephone' || m === '전화납부'
|
||||
return false
|
||||
}
|
||||
|
||||
/** 업무 진행 팝업 상단 read-only 섹션 */
|
||||
export default function ReceptionProgressReadOnly({ data }: { data: ProgressDetail | null }) {
|
||||
if (!data) return null
|
||||
const customer = data.customer || {}
|
||||
const payment = data.payment || data.paymentCard || data.paymentTransfer || {}
|
||||
const product = data.product || {}
|
||||
const gift = data.gift || {}
|
||||
const reception = data.reception || data
|
||||
const paymentMethod = payment.method || reception.paymentMethod || reception.paymentType
|
||||
const productType = product.productType || reception.productType || 'e01'
|
||||
const ctype = customer.type || reception.customerType || 'public'
|
||||
const amount = Number(product.price ?? reception.amount ?? 0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<PrgSection title="영업점 정보">
|
||||
<table className="prg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>영업점명</th>
|
||||
<td>{data.branchOfficeName || reception.branch || '-'}</td>
|
||||
<th>연락처</th>
|
||||
<td>{[data.branchPhone, data.branchTel, reception.branchPhone].filter(Boolean).join(', ') || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
|
||||
<PrgSection title="고객정보">
|
||||
<div className="prg-inline" style={{ justifyContent: 'center', marginBottom: 8 }}>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={ctype === 'public'} /> 개인</label>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={ctype === 'company'} /> 사업자</label>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={ctype === 'foreigner'} /> 외국인</label>
|
||||
</div>
|
||||
<table className="prg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>고객명</th>
|
||||
<td>{customer.name || reception.customerName || '-'}</td>
|
||||
<th>포스트잇</th>
|
||||
<td>{customer.postIt || reception.postIt || ''}</td>
|
||||
</tr>
|
||||
{(customer.postAdd || reception.postAdd) && (
|
||||
<tr>
|
||||
<th>추가메모</th>
|
||||
<td colSpan={3}>{customer.postAdd || reception.postAdd}</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<th>식별번호</th>
|
||||
<td>{customer.ssid || reception.identifyNumber || ''}</td>
|
||||
<th>고객유형</th>
|
||||
<td>{ctype === 'company' ? '사업자' : ctype === 'foreigner' ? '외국인' : '개인'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>휴대전화</th>
|
||||
<td>{customer.phone || reception.phone || '-'}</td>
|
||||
<th>유선전화</th>
|
||||
<td>{customer.tel || reception.tel || ''}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>이메일</th>
|
||||
<td colSpan={3}>{customer.email || reception.email || ''}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>주소</th>
|
||||
<td colSpan={3}>
|
||||
{[customer.zipcode || reception.zipcode ? `[${customer.zipcode || reception.zipcode}]` : '',
|
||||
customer.address || reception.address,
|
||||
customer.addressDetail || reception.addressDetail].filter(Boolean).join(' ') || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
{(customer.sido || reception.region) && (
|
||||
<tr>
|
||||
<th>지역</th>
|
||||
<td colSpan={3}>{customer.sido || reception.region}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
|
||||
<PrgSection title="결제납입정보">
|
||||
<table className="prg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>결제방법</th>
|
||||
<td colSpan={3}>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={pmChecked(paymentMethod, 'transfer')} /> 자동이체</label>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={pmChecked(paymentMethod, 'card')} /> 카드</label>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={pmChecked(paymentMethod, 'giro')} /> 지로</label>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={pmChecked(paymentMethod, 'phone')} /> 전화번호요금통합</label>
|
||||
</td>
|
||||
</tr>
|
||||
{(payment.name || payment.bank || payment.cardCompany) && (
|
||||
<>
|
||||
<tr>
|
||||
<th>납입자명</th>
|
||||
<td>{payment.name || ''}</td>
|
||||
<th>주민번호</th>
|
||||
<td>{payment.ssid || ''}</td>
|
||||
</tr>
|
||||
{pmChecked(paymentMethod, 'card') ? (
|
||||
<>
|
||||
<tr>
|
||||
<th>카드사</th>
|
||||
<td>{payment.cardCompany || ''}</td>
|
||||
<th>유효기간</th>
|
||||
<td>{[payment.cardYear, payment.cardMonth].filter(Boolean).join('/')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>카드번호</th>
|
||||
<td colSpan={3}>{payment.cardNumber || ''}</td>
|
||||
</tr>
|
||||
</>
|
||||
) : pmChecked(paymentMethod, 'transfer') ? (
|
||||
<tr>
|
||||
<th>은행</th>
|
||||
<td>{payment.bank || ''}</td>
|
||||
<th>계좌번호</th>
|
||||
<td>{payment.accountNumber || ''}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
|
||||
<PrgSection title="상품 정보">
|
||||
<table className="prg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>통신사</th>
|
||||
<td colSpan={3}>{data.ispName || reception.ispName || reception.carrier || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>상품종류</th>
|
||||
<td colSpan={3}>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={productType === 'e01' || (!productType && reception.productCategory === '인터넷')} /> 인터넷</label>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={productType === 'e02' || reception.productCategory === '전화'} /> 전화</label>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={productType === 'e03' || reception.productCategory === 'TV'} /> TV</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{productType === 'e02' ? '전화' : productType === 'e03' ? 'TV' : '인터넷'}</th>
|
||||
<td colSpan={3} style={{ padding: 4 }}>
|
||||
<table className="prg-table" style={{ margin: 0 }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>상품</th>
|
||||
<td>{data.productName || product.productName || product.name || reception.productName || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>약정</th>
|
||||
<td>{data.agreementName || reception.agreementName || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>이벤트</th>
|
||||
<td>{(data.eventNames || []).join(', ') || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>월사용요금</th>
|
||||
<td>{amount.toLocaleString()} 원</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>총 요금</th>
|
||||
<td colSpan={3}>{amount.toLocaleString()} 원</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>할인혜택</th>
|
||||
<td colSpan={3}>-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>설치희망일자</th>
|
||||
<td>{reception.openDate || '-'}</td>
|
||||
<th>연락처</th>
|
||||
<td>{reception.progressStatus || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
|
||||
{(gift.status || gift.category || reception.giftStatus) && (
|
||||
<PrgSection title="사은품 정보">
|
||||
<table className="prg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>사은품분류</th>
|
||||
<td>{gift.category || reception.giftCategory || '-'}</td>
|
||||
<th>진행상황</th>
|
||||
<td>{gift.status || reception.giftStatus || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>지급처</th>
|
||||
<td>{gift.place || reception.giftPlace || '-'}</td>
|
||||
<th>지급일</th>
|
||||
<td>{gift.giftDate || reception.giftDate || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
)}
|
||||
|
||||
{(data.etc?.memo || reception.memo) && (
|
||||
<PrgSection title="기타 정보">
|
||||
<table className="prg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>메모</th>
|
||||
<td colSpan={3} style={{ whiteSpace: 'pre-wrap' }}>{data.etc?.memo || reception.memo}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Form, Input, TimePicker, message } from 'antd'
|
||||
import Modal from './DraggableModal'
|
||||
import { CloseOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import 'dayjs/locale/ko'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../api/client'
|
||||
|
||||
dayjs.locale('ko')
|
||||
|
||||
type ScheduleRow = {
|
||||
id: number
|
||||
sdate?: string
|
||||
stime?: string
|
||||
contents?: string
|
||||
displayTime?: string
|
||||
}
|
||||
|
||||
type MonthData = {
|
||||
days?: Record<string, number>
|
||||
}
|
||||
|
||||
const weekDays = ['일', '월', '화', '수', '목', '금', '토']
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function SchedulePanel({ open, onClose }: Props) {
|
||||
const qc = useQueryClient()
|
||||
const [now, setNow] = useState(() => dayjs())
|
||||
const [cursor, setCursor] = useState(() => dayjs())
|
||||
const [selected, setSelected] = useState(() => dayjs())
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const t = window.setInterval(() => setNow(dayjs()), 1000)
|
||||
return () => window.clearInterval(t)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const today = dayjs()
|
||||
setNow(today)
|
||||
setCursor(today)
|
||||
setSelected(today)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const selectedKey = selected.format('YYYY-MM-DD')
|
||||
const year = cursor.year()
|
||||
const month = cursor.month() + 1
|
||||
|
||||
const monthQuery = useQuery({
|
||||
queryKey: ['schedules-month', year, month],
|
||||
enabled: open,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<MonthData>>(`${apiPrefix()}/schedules/month`, {
|
||||
params: { year, month },
|
||||
})
|
||||
return unwrap(data)
|
||||
},
|
||||
})
|
||||
|
||||
const dayQuery = useQuery({
|
||||
queryKey: ['schedules-day', selectedKey],
|
||||
enabled: open,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ total: number; list: ScheduleRow[] }>>(
|
||||
`${apiPrefix()}/schedules/search`,
|
||||
{ params: { sdate: selectedKey, size: 100 } },
|
||||
)
|
||||
return unwrap(data)
|
||||
},
|
||||
})
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ['schedules-month'] })
|
||||
qc.invalidateQueries({ queryKey: ['schedules-day'] })
|
||||
qc.invalidateQueries({ queryKey: ['schedules'] })
|
||||
}
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async (values: { contents: string; stime?: Dayjs }) => {
|
||||
const body = {
|
||||
sdate: selectedKey,
|
||||
contents: values.contents,
|
||||
stime: values.stime ? values.stime.format('HH:mm') : undefined,
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/schedules`, body)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('일정을 등록했습니다.')
|
||||
setCreateOpen(false)
|
||||
form.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/schedules/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('일정을 삭제했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const cells = useMemo(() => {
|
||||
const start = cursor.startOf('month')
|
||||
const startWeekday = start.day()
|
||||
const daysInMonth = cursor.daysInMonth()
|
||||
const prevMonth = cursor.subtract(1, 'month')
|
||||
const prevDays = prevMonth.daysInMonth()
|
||||
const list: { date: Dayjs; current: boolean }[] = []
|
||||
for (let i = startWeekday - 1; i >= 0; i -= 1) {
|
||||
list.push({ date: prevMonth.date(prevDays - i), current: false })
|
||||
}
|
||||
for (let d = 1; d <= daysInMonth; d += 1) {
|
||||
list.push({ date: cursor.date(d), current: true })
|
||||
}
|
||||
while (list.length % 7 !== 0) {
|
||||
const next = list[list.length - 1].date.add(1, 'day')
|
||||
list.push({ date: next, current: false })
|
||||
}
|
||||
return list
|
||||
}, [cursor])
|
||||
|
||||
const eventDays = monthQuery.data?.days || {}
|
||||
const events = dayQuery.data?.list || []
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="schedule-panel-mask" onClick={onClose} />
|
||||
<aside className="schedule-panel" aria-label="일정표">
|
||||
<div className="schedule-panel-head">일정표</div>
|
||||
|
||||
<div className="schedule-panel-body">
|
||||
<div className="schedule-block">
|
||||
<div className="schedule-label">TIME & DATE</div>
|
||||
<div className="schedule-clock">{now.format('hh:mm:ss A')}</div>
|
||||
<div className="schedule-today">{now.format('YYYY년 MM월 DD일 dddd')}</div>
|
||||
</div>
|
||||
|
||||
<div className="schedule-block">
|
||||
<div className="schedule-label">CALENDAR</div>
|
||||
<div className="schedule-calendar">
|
||||
<div className="schedule-cal-nav">
|
||||
<button type="button" onClick={() => setCursor((c) => c.subtract(1, 'month'))} aria-label="이전 달">
|
||||
<LeftOutlined />
|
||||
</button>
|
||||
<span>{cursor.format('YYYY년 M월')}</span>
|
||||
<button type="button" onClick={() => setCursor((c) => c.add(1, 'month'))} aria-label="다음 달">
|
||||
<RightOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="schedule-cal-weeks">
|
||||
{weekDays.map((d, i) => (
|
||||
<span key={d} className={i === 0 || i === 6 ? 'weekend' : undefined}>{d}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="schedule-cal-grid">
|
||||
{cells.map(({ date, current }) => {
|
||||
const key = date.format('YYYY-MM-DD')
|
||||
const isSelected = key === selectedKey
|
||||
const isToday = key === now.format('YYYY-MM-DD')
|
||||
const hasEvent = !!eventDays[key]
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={[
|
||||
'schedule-day',
|
||||
current ? 'current' : 'other',
|
||||
isSelected ? 'selected' : '',
|
||||
isToday ? 'today' : '',
|
||||
hasEvent ? 'has-event' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => {
|
||||
setSelected(date)
|
||||
if (!current) setCursor(date)
|
||||
}}
|
||||
>
|
||||
{date.date()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="schedule-block schedule-events">
|
||||
<div className="schedule-event-head">
|
||||
<span>{selectedKey} EVENT</span>
|
||||
<span>{events.length}건</span>
|
||||
</div>
|
||||
<div className="schedule-event-list">
|
||||
{events.length === 0 ? (
|
||||
<div className="schedule-event-empty">등록된 일정이 없습니다.</div>
|
||||
) : (
|
||||
events.map((ev) => (
|
||||
<div key={ev.id} className="schedule-event-card">
|
||||
<button
|
||||
type="button"
|
||||
className="schedule-event-del"
|
||||
aria-label="삭제"
|
||||
onClick={() => deleteMut.mutate(ev.id)}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
<div className="schedule-event-title">{ev.contents}</div>
|
||||
<div className="schedule-event-time">{ev.displayTime || ev.stime || '-'}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="schedule-panel-foot">
|
||||
<button
|
||||
type="button"
|
||||
className="schedule-add-btn"
|
||||
onClick={() => {
|
||||
form.setFieldsValue({ contents: undefined, stime: dayjs('09:00', 'HH:mm') })
|
||||
setCreateOpen(true)
|
||||
}}
|
||||
>
|
||||
+ 일정 등록
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Modal
|
||||
title={`${selectedKey} 일정 등록`}
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => createMut.mutate(v)}>
|
||||
<Form.Item name="contents" label="내용" rules={[{ required: true, message: '내용을 입력하세요.' }]}>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item name="stime" label="시간">
|
||||
<TimePicker format="HH:mm" className="full-width" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<div className="bbs-write-actions">
|
||||
<Button type="primary" htmlType="submit" loading={createMut.isPending}>등록</Button>
|
||||
<Button onClick={() => setCreateOpen(false)}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Input, Space, message } from 'antd'
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import Modal from './DraggableModal'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
items: string[]
|
||||
saving?: boolean
|
||||
submitClassName?: string
|
||||
onCancel: () => void
|
||||
onSave: (items: string[]) => void
|
||||
}
|
||||
|
||||
export default function SubjectSettingsModal({
|
||||
open,
|
||||
items,
|
||||
saving,
|
||||
submitClassName,
|
||||
onCancel,
|
||||
onSave }: Props) {
|
||||
const [list, setList] = useState<string[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setList(items.length ? [...items] : [])
|
||||
setInput('')
|
||||
// 팝업이 열릴 때만 초기화 (편집 중 items 참조 변경으로 목록이 리셋되지 않도록)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
const addItem = () => {
|
||||
const value = input.trim()
|
||||
if (!value) {
|
||||
message.warning('과목을 입력하세요.')
|
||||
return
|
||||
}
|
||||
if (list.some((item) => item === value)) {
|
||||
message.warning('이미 등록된 과목입니다.')
|
||||
return
|
||||
}
|
||||
setList((prev) => [...prev, value])
|
||||
setInput('')
|
||||
}
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
setList((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!list.length) {
|
||||
message.warning('과목을 1개 이상 등록하세요.')
|
||||
return
|
||||
}
|
||||
onSave(list)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="과목설정"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={420}
|
||||
>
|
||||
<div className="subject-settings">
|
||||
<Space.Compact className="full-width subject-settings-add">
|
||||
<Input
|
||||
value={input}
|
||||
placeholder="과목명 입력"
|
||||
maxLength={40}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onPressEnter={(e) => {
|
||||
e.preventDefault()
|
||||
addItem()
|
||||
}}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={addItem}>
|
||||
추가
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
<ul className="subject-settings-list">
|
||||
{list.map((item, index) => (
|
||||
<li key={`${item}-${index}`} className="subject-settings-item">
|
||||
<span className="subject-settings-name">{item}</span>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label={`${item} 삭제`}
|
||||
onClick={() => removeItem(index)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
{!list.length ? (
|
||||
<li className="subject-settings-empty">등록된 과목이 없습니다.</li>
|
||||
) : null}
|
||||
</ul>
|
||||
|
||||
<div className="balance-modal-footer">
|
||||
<Button
|
||||
className={submitClassName}
|
||||
type="primary"
|
||||
loading={saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
<Button onClick={onCancel}>취소</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** Ant Design Form requiredMark: * 대신 '필수' 뱃지 */
|
||||
export function formRequiredMark(label: ReactNode, { required }: { required: boolean }) {
|
||||
if (!required) return label
|
||||
return (
|
||||
<>
|
||||
{label}
|
||||
<span className="form-req">필수</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
/* ===== 테마 토큰 (billing_react 방식) =====
|
||||
html.theme-* 에만 토큰을 두어 :root 와 충돌하지 않게 한다. */
|
||||
html.theme-light {
|
||||
--primary: #2563eb;
|
||||
--primary-dark: #1d4ed8;
|
||||
--on-primary: #ffffff;
|
||||
|
||||
--bg: #f1f5f9;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f8fafc;
|
||||
--surface-3: #ffffff;
|
||||
--elevated: #eff6ff;
|
||||
|
||||
--text: #0f172a;
|
||||
--muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
|
||||
--danger: #dc2626;
|
||||
--success: #16a34a;
|
||||
|
||||
--highlight-bg: #fffbe6;
|
||||
--highlight-bg-strong: #fff3bf;
|
||||
--highlight-text: #0f172a;
|
||||
|
||||
--shadow: 0 8px 24px rgba(0,0,0,.14);
|
||||
--shadow-lg: 0 20px 60px rgba(0,0,0,.3);
|
||||
--focus-ring: #bfdbfe;
|
||||
|
||||
--navy: #152536;
|
||||
--blue: #1769aa;
|
||||
--line: var(--border);
|
||||
}
|
||||
|
||||
html.theme-dark {
|
||||
--primary: #7aa2f7;
|
||||
--primary-dark: #5a7dc7;
|
||||
--on-primary: #0b1020;
|
||||
|
||||
--bg: #16181e;
|
||||
--surface: #24283b;
|
||||
--surface-2: #1f2335;
|
||||
--surface-3: #1a1b26;
|
||||
--elevated: #2a2f45;
|
||||
|
||||
--text: #c0caf5;
|
||||
--muted: #8a93b8;
|
||||
--border: rgba(122, 162, 247, 0.16);
|
||||
|
||||
--danger: #f7768e;
|
||||
--success: #9ece6a;
|
||||
|
||||
--highlight-bg: #2f3650;
|
||||
--highlight-bg-strong: #3a4463;
|
||||
--highlight-text: #c0caf5;
|
||||
|
||||
--shadow: 0 8px 24px rgba(0,0,0,.5);
|
||||
--shadow-lg: 0 20px 60px rgba(0,0,0,.45);
|
||||
--focus-ring: rgba(122, 162, 247, 0.5);
|
||||
}
|
||||
|
||||
html.theme-blue {
|
||||
/* 버튼·강조: 흰 글씨가 읽히도록 채도 높은 중청색 */
|
||||
--primary: #1e8ad4;
|
||||
--primary-dark: #1670ad;
|
||||
--on-primary: #ffffff;
|
||||
|
||||
--bg: #0a1522;
|
||||
--surface: #15273d;
|
||||
--surface-2: #0f1e30;
|
||||
--surface-3: #0c1929;
|
||||
--elevated: #1e3a5f;
|
||||
|
||||
--text: #e8f4ff;
|
||||
--muted: #8fbbe0;
|
||||
--border: rgba(94, 181, 255, 0.16);
|
||||
|
||||
--danger: #ff6b6b;
|
||||
--success: #5ce0a0;
|
||||
|
||||
--highlight-bg: #1a3352;
|
||||
--highlight-bg-strong: #21406a;
|
||||
--highlight-text: #e8f4ff;
|
||||
|
||||
--shadow: 0 8px 24px rgba(0,0,0,.5);
|
||||
--shadow-lg: 0 20px 60px rgba(0,0,0,.45);
|
||||
--focus-ring: rgba(30, 138, 212, 0.45);
|
||||
}
|
||||
|
||||
/* 테마 클래스 적용 전 깜빡임 방지용 기본값 = 라이트 (:root 는 theme-* 보다 특이도가 낮음) */
|
||||
html:not(.theme-light):not(.theme-dark):not(.theme-blue),
|
||||
:root:not(.theme-light):not(.theme-dark):not(.theme-blue) {
|
||||
--primary: #2563eb;
|
||||
--primary-dark: #1d4ed8;
|
||||
--on-primary: #ffffff;
|
||||
--bg: #f1f5f9;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f8fafc;
|
||||
--surface-3: #ffffff;
|
||||
--elevated: #eff6ff;
|
||||
--text: #0f172a;
|
||||
--muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--danger: #dc2626;
|
||||
--success: #16a34a;
|
||||
--highlight-bg: #fffbe6;
|
||||
--highlight-bg-strong: #fff3bf;
|
||||
--highlight-text: #0f172a;
|
||||
--shadow: 0 8px 24px rgba(0,0,0,.14);
|
||||
--shadow-lg: 0 20px 60px rgba(0,0,0,.3);
|
||||
--focus-ring: #bfdbfe;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Pretendard', 'Noto Sans KR', -apple-system, 'Malgun Gothic', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
#root { min-height: 100vh; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
/* ===== App shell (billing_react Layout) ===== */
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.app-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 52px;
|
||||
background: var(--surface-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-shrink: 0;
|
||||
z-index: 30;
|
||||
}
|
||||
.topbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 14px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface-3);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.topbar-brand.is-collapsed {
|
||||
width: 0 !important;
|
||||
padding: 0;
|
||||
border-right: none;
|
||||
}
|
||||
.brand-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.topbar-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.topbar-left-tools { min-width: 0; overflow: hidden; }
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.topbar-user { font-size: 12px; white-space: nowrap; }
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
.theme-select {
|
||||
width: auto;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.leftpanel {
|
||||
flex-shrink: 0;
|
||||
background: var(--surface-3);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px 10px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--primary) 45%, var(--muted)) var(--surface-2);
|
||||
}
|
||||
.leftpanel::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.leftpanel::-webkit-scrollbar-track {
|
||||
background: var(--surface-2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.leftpanel::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--primary) 40%, var(--muted));
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--surface-2);
|
||||
}
|
||||
.leftpanel::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--primary) 70%, var(--muted));
|
||||
}
|
||||
|
||||
.main-area .content,
|
||||
.content {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--primary) 40%, var(--muted)) var(--surface-2);
|
||||
}
|
||||
.main-area .content::-webkit-scrollbar,
|
||||
.content::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
.main-area .content::-webkit-scrollbar-track,
|
||||
.content::-webkit-scrollbar-track {
|
||||
background: var(--bg);
|
||||
}
|
||||
.main-area .content::-webkit-scrollbar-thumb,
|
||||
.content::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--primary) 35%, var(--muted));
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--bg);
|
||||
}
|
||||
.main-area .content::-webkit-scrollbar-thumb:hover,
|
||||
.content::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--primary) 65%, var(--muted));
|
||||
}
|
||||
.lp-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lp-card.is-active {
|
||||
border-color: color-mix(in srgb, var(--primary) 45%, var(--border));
|
||||
}
|
||||
.lp-card-title {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
background: var(--surface-2);
|
||||
border: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.lp-card:has(.lp-card-body) .lp-card-title,
|
||||
.lp-card-title[aria-expanded="true"] {
|
||||
border-bottom-color: var(--border);
|
||||
}
|
||||
.lp-card-chevron {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.lp-card-body { padding: 6px; }
|
||||
.leftpanel a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
border-radius: 7px;
|
||||
color: var(--muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.leftpanel a:hover { background: var(--surface-2); color: var(--text); }
|
||||
.leftpanel a.active {
|
||||
color: var(--on-primary);
|
||||
background: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel-resizer {
|
||||
width: 3px;
|
||||
flex-shrink: 0;
|
||||
cursor: col-resize;
|
||||
background: var(--surface-3);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.panel-resizer:hover,
|
||||
.panel-resizer.dragging {
|
||||
background: var(--primary);
|
||||
border-right-color: var(--primary);
|
||||
}
|
||||
.panel-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 20;
|
||||
width: 15px;
|
||||
height: 42px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface-3);
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-left: none;
|
||||
border-radius: 0 8px 8px 0;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
.panel-toggle:hover { color: var(--primary); background: var(--elevated); }
|
||||
|
||||
.main-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
}
|
||||
.main-area .content {
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* legacy stubs kept for pages that still reference them */
|
||||
.admin-layout { min-height: 100vh; }
|
||||
.side { background: var(--surface-3) !important; }
|
||||
.brand {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
color: var(--text);
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.brand span { color: var(--primary); margin-left: 5px; }
|
||||
.header {
|
||||
background: var(--surface) !important;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 0 28px !important;
|
||||
}
|
||||
.content { padding: 26px; overflow: auto; }
|
||||
.page-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
color: var(--text);
|
||||
}
|
||||
.page-title .ant-typography { margin: 0; font-weight: 700; color: var(--text) !important; }
|
||||
.filter-card { margin-bottom: 16px; background: var(--surface-2); }
|
||||
.dashboard-row { margin-top: 16px; }
|
||||
.login-wrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1e293b, #2563eb);
|
||||
padding: 24px;
|
||||
}
|
||||
.login-box { width: 400px; max-width: 100%; }
|
||||
.login-demo { color: #64748b; font-size: 12px; margin-top: 16px; text-align: center; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.main-area .content { padding: 16px; }
|
||||
.content { padding: 16px; }
|
||||
.page-title { align-items: flex-start; gap: 10px; flex-direction: column; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { applyTheme, loadTheme } from './theme'
|
||||
import { ThemeProvider } from './themeContext'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
applyTheme(loadTheme())
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: (failureCount, error) => {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
if (status === 401 || status === 403) return false
|
||||
return failureCount < 3
|
||||
},
|
||||
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 5000),
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,190 @@
|
||||
export type MenuItem = {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type MenuSection = {
|
||||
id: string
|
||||
title: string
|
||||
items: MenuItem[]
|
||||
}
|
||||
|
||||
/** 비즈케어홈즈(h.bizcare.co.kr) 실제 메뉴 — 경로 그대로 */
|
||||
export function careMenuSections(): MenuSection[] {
|
||||
return [
|
||||
{
|
||||
id: 'home',
|
||||
title: '대시보드',
|
||||
items: [{ key: '/care/main', label: '대시보드' }],
|
||||
},
|
||||
{
|
||||
id: 'product',
|
||||
title: '상품관리',
|
||||
items: [
|
||||
{ key: '/care/product/internet', label: '인터넷상품' },
|
||||
{ key: '/care/product/home', label: '홈렌탈상품' },
|
||||
{ key: '/care/product/log', label: '상품이력' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'contract',
|
||||
title: '계약관리',
|
||||
items: [
|
||||
{ key: '/care/contract/internet', label: '인터넷접수' },
|
||||
{ key: '/care/contract/home', label: '홈렌탈접수' },
|
||||
{ key: '/care/contract/log', label: '계약이력' },
|
||||
{ key: '/care/contract/adjust', label: '일괄정책변경' },
|
||||
{ key: '/care/contract/balance', label: '계약후정산' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reception',
|
||||
title: '접수관리',
|
||||
items: [
|
||||
{ key: '/care/reception/list', label: '접수리스트' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'invoice',
|
||||
title: '정산관리',
|
||||
items: [
|
||||
{ key: '/care/invoice/publish', label: '정산서발행' },
|
||||
{ key: '/care/invoice/my', label: '나의정산서' },
|
||||
{ key: '/care/invoice/log', label: '정산서이력' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pending',
|
||||
title: '일정관리',
|
||||
items: [
|
||||
{ key: '/care/pending/calendar', label: '캘린더' },
|
||||
{ key: '/care/pending/pending', label: '일정관리' },
|
||||
{ key: '/care/pending/log', label: '일정이력' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'booking',
|
||||
title: '예약관리',
|
||||
items: [
|
||||
{ key: '/care/booking/booking', label: '예약관리' },
|
||||
{ key: '/care/booking/log', label: '예약이력' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'customer',
|
||||
title: '고객관리',
|
||||
items: [
|
||||
{ key: '/care/customer/customer', label: '고객관리' },
|
||||
{ key: '/care/customer/log', label: '고객이력' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'abooks',
|
||||
title: '장부관리',
|
||||
items: [
|
||||
{ key: '/care/abooks/abooks-mon', label: '간편장부' },
|
||||
{ key: '/care/abooks/log', label: '장부이력' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'report',
|
||||
title: '통계관리',
|
||||
items: [
|
||||
{ key: '/care/report/daily', label: '일일리포트' },
|
||||
{ key: '/care/report/monthly', label: '월간리포트' },
|
||||
{ key: '/care/report/internet', label: '인터넷통계' },
|
||||
{ key: '/care/report/home', label: '홈렌탈통계' },
|
||||
{ key: '/care/report/member', label: '직원별통계' },
|
||||
{ key: '/care/report/agency', label: '거래처별통계' },
|
||||
{ key: '/care/report/incentive', label: '인센티브계산' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'setting',
|
||||
title: '환경설정',
|
||||
items: [
|
||||
{ key: '/care/setting/agency', label: '거래처관리' },
|
||||
{ key: '/care/setting/member', label: '직원관리' },
|
||||
{ key: '/care/setting/preference', label: '기본설정' },
|
||||
{ key: '/care/setting/level', label: '권한설정' },
|
||||
{ key: '/care/setting/partner1', label: '상부점관리' },
|
||||
{ key: '/care/setting/partner2', label: '하부점관리' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cs',
|
||||
title: '고객센터',
|
||||
items: [
|
||||
{ key: '/care/cs/notice', label: '공지사항' },
|
||||
{ key: '/care/cs/question', label: '사용문의' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bbs',
|
||||
title: '사내게시판',
|
||||
items: [{ key: '/care/bbs/bbs', label: '사내게시판' }],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** @deprecated 호환용 */
|
||||
export function agentMenuSections(): MenuSection[] {
|
||||
return careMenuSections()
|
||||
}
|
||||
|
||||
export function shopMenuSections(): MenuSection[] {
|
||||
return [
|
||||
{
|
||||
id: 'shop',
|
||||
title: '판매점',
|
||||
items: [
|
||||
{ key: '/shop/notice', label: '공지사항' },
|
||||
{ key: '/shop/policy', label: '정책정보' },
|
||||
{ key: '/shop/customer', label: '고객조회' },
|
||||
{ key: '/shop/contract', label: '계약조회' },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** 권한설정·노출설정에서 허용된 섹션 id만 남김. allowedIds가 null이면 전부 표시(로딩 전). */
|
||||
export function filterMenuSections(
|
||||
sections: MenuSection[],
|
||||
allowedIds: string[] | null | undefined,
|
||||
): MenuSection[] {
|
||||
if (!allowedIds) return sections
|
||||
const set = new Set(allowedIds)
|
||||
return sections.filter((sec) => set.has(sec.id))
|
||||
}
|
||||
|
||||
export const SITEMAP_TITLE_TO_ID: Record<string, string> = {
|
||||
대시보드: 'home',
|
||||
상품관리: 'product',
|
||||
계약관리: 'contract',
|
||||
접수관리: 'reception',
|
||||
정산관리: 'invoice',
|
||||
일정관리: 'pending',
|
||||
예약관리: 'booking',
|
||||
고객관리: 'customer',
|
||||
장부관리: 'abooks',
|
||||
통계관리: 'report',
|
||||
환경설정: 'setting',
|
||||
고객센터: 'cs',
|
||||
사내게시판: 'bbs',
|
||||
게시판: 'bbs',
|
||||
}
|
||||
|
||||
export function resolveSelectedKey(pathname: string): string {
|
||||
if (pathname.startsWith('/care/contract/write')) {
|
||||
const q = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('type') : null
|
||||
if (q === 'home') return '/care/contract/home'
|
||||
return '/care/contract/internet'
|
||||
}
|
||||
if (pathname === '/care/sitemap') return '/care/main/sitemap'
|
||||
return pathname
|
||||
}
|
||||
|
||||
export function sectionContainsPath(section: MenuSection, pathname: string): boolean {
|
||||
const selected = resolveSelectedKey(pathname)
|
||||
return section.items.some((it) => selected === it.key || selected.startsWith(`${it.key}/`))
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { login } from '../api/auth'
|
||||
import BillCareLogo from '../components/BillCareLogo'
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const [userid, setUserid] = useState('demo')
|
||||
const [password, setPassword] = useState('demo123')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await login(userid, password)
|
||||
localStorage.setItem('token', data.token!)
|
||||
localStorage.setItem('role', data.role === 'SHOP' ? 'SHOP' : 'AGENT')
|
||||
localStorage.setItem('userName', data.name)
|
||||
localStorage.setItem('userid', data.userid)
|
||||
if (data.staffPermission) localStorage.setItem('staffPermission', data.staffPermission)
|
||||
else localStorage.removeItem('staffPermission')
|
||||
navigate('/care/main', { replace: true })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '로그인에 실패했습니다.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-wrap">
|
||||
<form className="login-box" onSubmit={submit}>
|
||||
<div className="login-brand">
|
||||
<BillCareLogo title="BillCare" size="login" />
|
||||
<p>통합 영업·정산 관리 시스템</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="login-userid">아이디</label>
|
||||
<input
|
||||
id="login-userid"
|
||||
value={userid}
|
||||
onChange={(e) => setUserid(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="login-password">비밀번호</label>
|
||||
<input
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
<button type="submit" className="login-submit" disabled={loading}>
|
||||
{loading ? '로그인 중...' : '로그인'}
|
||||
</button>
|
||||
<div className="login-demo">데모: demo / demo123</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { BookOutlined } from '@ant-design/icons'
|
||||
import { HomesLogListPage } from './HomesLogListPage'
|
||||
|
||||
const ACTIONS = [
|
||||
'간편장부-등록',
|
||||
'간편장부-수정',
|
||||
'간편장부-삭제',
|
||||
]
|
||||
|
||||
export default function AbooksLogPage() {
|
||||
return (
|
||||
<HomesLogListPage
|
||||
domain="ABOOKS"
|
||||
title="장부이력"
|
||||
description="장부관리의 변동이력을 확인하실 수 있습니다."
|
||||
defaultActions={ACTIONS}
|
||||
titleIcon={<BookOutlined className="product-title-icon" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Tabs,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import SubjectSettingsModal from '../../components/SubjectSettingsModal'
|
||||
import {
|
||||
BookOutlined,
|
||||
CloseOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
LeftOutlined,
|
||||
PlusSquareOutlined,
|
||||
ReloadOutlined,
|
||||
RightOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { options } from './homesShared'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
type AbookRow = {
|
||||
id: number
|
||||
entryDate?: string
|
||||
entryType?: string
|
||||
category?: string
|
||||
item?: string
|
||||
amount?: number
|
||||
deposit?: number | null
|
||||
withdraw?: number | null
|
||||
cardDeposit?: number | null
|
||||
cardWithdraw?: number | null
|
||||
memo?: string
|
||||
employee?: string
|
||||
}
|
||||
|
||||
type AbookSummary = {
|
||||
carryOver?: number
|
||||
deposit?: number
|
||||
withdraw?: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
type AbookResult = {
|
||||
total: number
|
||||
list: AbookRow[]
|
||||
counts?: Record<string, number>
|
||||
categoryCounts?: Record<string, number>
|
||||
categories?: string[]
|
||||
employees?: string[]
|
||||
items?: string[]
|
||||
summary?: AbookSummary
|
||||
}
|
||||
|
||||
const ENTRY_TYPES = ['입금', '출금', '카드입금', '카드출금'] as const
|
||||
|
||||
const money = (v?: number | null) => {
|
||||
if (v == null || Number.isNaN(Number(v))) return '-'
|
||||
return Number(v).toLocaleString('ko-KR')
|
||||
}
|
||||
|
||||
const isOut = (type?: string) => type === '출금' || type === '카드출금'
|
||||
const typeClass = (type?: string) => {
|
||||
if (type === '입금' || type === '카드입금') return 'abook-in'
|
||||
if (isOut(type)) return 'abook-out'
|
||||
return ''
|
||||
}
|
||||
|
||||
export default function AbooksPage() {
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const qc = useQueryClient()
|
||||
const [searchParams] = useSearchParams()
|
||||
const initialView = searchParams.get('view') === 'detail' ? 'detail' : 'month'
|
||||
const [view, setView] = useState<'month' | 'detail'>(initialView)
|
||||
const [month, setMonth] = useState(dayjs())
|
||||
const [day, setDay] = useState<number | 'ALL'>('ALL')
|
||||
const [categoryTab, setCategoryTab] = useState('ALL')
|
||||
const [detailFilters, setDetailFilters] = useState<Record<string, unknown>>({
|
||||
page: 0,
|
||||
size: 10,
|
||||
tab: 'ALL' })
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [editing, setEditing] = useState<AbookRow | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [catOpen, setCatOpen] = useState(false)
|
||||
|
||||
const monthParams = useMemo(() => ({
|
||||
yearMonth: month.format('YYYY-MM'),
|
||||
day: day === 'ALL' ? undefined : day,
|
||||
category: categoryTab === 'ALL' ? undefined : categoryTab,
|
||||
page: 0,
|
||||
size: 200 }), [month, day, categoryTab])
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-abooks', view, view === 'month' ? monthParams : detailFilters],
|
||||
queryFn: async () => {
|
||||
const params = view === 'month'
|
||||
? monthParams
|
||||
: {
|
||||
...detailFilters,
|
||||
entryType: detailFilters.tab === 'ALL' ? undefined : detailFilters.tab }
|
||||
const { data } = await client.get<ApiResponse<AbookResult>>(`${apiPrefix()}/homes/abooks`, { params })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const members = useQuery({
|
||||
queryKey: ['homes/members-options'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ list: { id: number; name?: string }[] }>>(`${apiPrefix()}/homes/members`, {
|
||||
params: { page: 0, size: 200 } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ['homes-abooks'] })
|
||||
setSelected([])
|
||||
}
|
||||
|
||||
const staffOptions = useMemo(() => {
|
||||
const fromApi = query.data?.employees || []
|
||||
const fromMembers = (members.data?.list || []).map((m) => String(m.name || '')).filter(Boolean)
|
||||
return Array.from(new Set([...fromApi, ...fromMembers, '홍길동'])).map((v) => ({ value: v, label: v }))
|
||||
}, [query.data, members.data])
|
||||
|
||||
const categories = useMemo(
|
||||
() => query.data?.categories || ['일반', '123'],
|
||||
[query.data?.categories],
|
||||
)
|
||||
|
||||
const categoryTabs = useMemo(
|
||||
() => [
|
||||
{ key: 'ALL', label: `전체 ${query.data?.categoryCounts?.ALL ?? query.data?.total ?? 0}` },
|
||||
...categories.map((c) => ({
|
||||
key: c,
|
||||
label: `${c} ${query.data?.categoryCounts?.[c] ?? 0}` })),
|
||||
],
|
||||
[query.data, categories],
|
||||
)
|
||||
|
||||
const detailTabs = useMemo(
|
||||
() => [
|
||||
{ key: 'ALL', label: `전체 ${query.data?.counts?.ALL ?? query.data?.total ?? 0}` },
|
||||
...ENTRY_TYPES.map((t) => ({ key: t, label: `${t} ${query.data?.counts?.[t] ?? 0}` })),
|
||||
],
|
||||
[query.data],
|
||||
)
|
||||
|
||||
const daysInMonth = month.daysInMonth()
|
||||
const today = dayjs()
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
employee: values.employee,
|
||||
entryDate: values.entryDate ? dayjs(values.entryDate as Dayjs).format('YYYY-MM-DD') : undefined,
|
||||
entryType: values.entryType,
|
||||
category: values.category,
|
||||
item: values.item,
|
||||
amount: values.amount,
|
||||
memo: values.memo }
|
||||
if (editing) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/abooks/${editing.id}`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/abooks`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '수정했습니다.' : '간편장부를 등록했습니다.')
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/abooks/delete`, { ids })
|
||||
return unwrap(data as ApiResponse<{ deleted: number }>)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
message.success(`${data.deleted}건을 삭제했습니다.`)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const saveCategories = useMutation({
|
||||
mutationFn: async (cats: string[]) => {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/abooks/categories`, { categories: cats })
|
||||
return unwrap(data as ApiResponse<{ categories: string[] }>)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('과목을 저장했습니다.')
|
||||
setCatOpen(false)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setCreating(true)
|
||||
editForm.setFieldsValue({
|
||||
employee: staffOptions[0]?.value || '홍길동',
|
||||
entryDate: dayjs(),
|
||||
entryType: '입금',
|
||||
category: categories[0] || '일반',
|
||||
item: undefined,
|
||||
amount: undefined,
|
||||
memo: undefined })
|
||||
}
|
||||
|
||||
const openEdit = (row: AbookRow) => {
|
||||
setCreating(false)
|
||||
setEditing(row)
|
||||
editForm.setFieldsValue({
|
||||
employee: row.employee,
|
||||
entryDate: row.entryDate ? dayjs(row.entryDate) : dayjs(),
|
||||
entryType: row.entryType || '입금',
|
||||
category: row.category || '일반',
|
||||
item: row.item,
|
||||
amount: row.amount,
|
||||
memo: row.memo })
|
||||
}
|
||||
|
||||
const openCatSettings = () => setCatOpen(true)
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onDetailSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setDetailFilters({
|
||||
page: 0,
|
||||
size: values.size ?? detailFilters.size ?? 10,
|
||||
tab: detailFilters.tab,
|
||||
category: values.category || undefined,
|
||||
employee: values.employee || undefined,
|
||||
item: values.item || undefined,
|
||||
q: values.q || undefined,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD') })
|
||||
}
|
||||
|
||||
const resetDetail = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ size: 10 })
|
||||
setDetailFilters({ page: 0, size: 10, tab: 'ALL' })
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const params = view === 'month'
|
||||
? monthParams
|
||||
: { ...detailFilters, entryType: detailFilters.tab === 'ALL' ? undefined : detailFilters.tab }
|
||||
const { data } = await client.get(`${apiPrefix()}/homes/abooks/export`, {
|
||||
params,
|
||||
responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '간편장부.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const summary = query.data?.summary
|
||||
const summaryText = `현금시제 : (전월) 이월금 ${money(summary?.carryOver)} 원 + 입금 ${money(summary?.deposit)} 원 - 출금 ${money(summary?.withdraw)} 원 = 합계 ${money(summary?.total)} 원`
|
||||
|
||||
const monthColumns = [
|
||||
{ title: '거래일', dataIndex: 'entryDate', width: 110 },
|
||||
{ title: '과목', dataIndex: 'category', width: 90 },
|
||||
{ title: '품목', dataIndex: 'item', width: 140, ellipsis: true },
|
||||
{
|
||||
title: '입금(+)',
|
||||
dataIndex: 'deposit',
|
||||
width: 100,
|
||||
align: 'right' as const,
|
||||
render: (v: number | null) => <span className="abook-in">{v != null ? money(v) : '-'}</span> },
|
||||
{
|
||||
title: '출금(-)',
|
||||
dataIndex: 'withdraw',
|
||||
width: 100,
|
||||
align: 'right' as const,
|
||||
render: (v: number | null) => <span className="abook-out">{v != null ? money(v) : '-'}</span> },
|
||||
{
|
||||
title: '카드입금(+)',
|
||||
dataIndex: 'cardDeposit',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (v: number | null) => <span className="abook-in">{v != null ? money(v) : '-'}</span> },
|
||||
{
|
||||
title: '카드출금(-)',
|
||||
dataIndex: 'cardWithdraw',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (v: number | null) => <span className="abook-out">{v != null ? money(v) : '-'}</span> },
|
||||
{ title: '비고', dataIndex: 'memo', width: 140, ellipsis: true, render: (v: string) => v || '-' },
|
||||
{ title: '직원', dataIndex: 'employee', width: 90 },
|
||||
{
|
||||
title: '관리',
|
||||
width: 64,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: AbookRow) => (
|
||||
<Button size="small" type="primary" className="abook-manage-btn" icon={<EditOutlined />} onClick={() => openEdit(row)} />
|
||||
) },
|
||||
]
|
||||
|
||||
const detailColumns = [
|
||||
{
|
||||
title: (
|
||||
<Checkbox
|
||||
checked={!!query.data?.list?.length && selected.length === query.data.list.length}
|
||||
indeterminate={selected.length > 0 && selected.length < (query.data?.list?.length || 0)}
|
||||
onChange={(e) => setSelected(e.target.checked ? (query.data?.list || []).map((r) => r.id) : [])}
|
||||
/>
|
||||
),
|
||||
width: 42,
|
||||
render: (_: unknown, row: AbookRow) => (
|
||||
<Checkbox
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={(e) => setSelected((prev) => (e.target.checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)))}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '거래일',
|
||||
dataIndex: 'entryDate',
|
||||
width: 110,
|
||||
sorter: (a: AbookRow, b: AbookRow) => String(a.entryDate || '').localeCompare(String(b.entryDate || '')) },
|
||||
{
|
||||
title: '구분',
|
||||
dataIndex: 'entryType',
|
||||
width: 90,
|
||||
render: (v: string) => <span className={typeClass(v)}>{v}</span> },
|
||||
{ title: '과목', dataIndex: 'category', width: 90 },
|
||||
{ title: '품목', dataIndex: 'item', width: 140, ellipsis: true },
|
||||
{
|
||||
title: '금액',
|
||||
dataIndex: 'amount',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
sorter: (a: AbookRow, b: AbookRow) => Number(a.amount || 0) - Number(b.amount || 0),
|
||||
render: (v: number, row: AbookRow) => <span className={typeClass(row.entryType)}>{money(v)}</span> },
|
||||
{ title: '비고', dataIndex: 'memo', width: 140, ellipsis: true, render: (v: string) => v || '-' },
|
||||
{ title: '직원', dataIndex: 'employee', width: 90 },
|
||||
{
|
||||
title: '관리',
|
||||
width: 64,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: AbookRow) => (
|
||||
<Button size="small" type="primary" className="abook-manage-btn" icon={<EditOutlined />} onClick={() => openEdit(row)} />
|
||||
) },
|
||||
]
|
||||
|
||||
const modalOpen = creating || Boolean(editing)
|
||||
|
||||
return (
|
||||
<div className="stock-page abook-manage-page">
|
||||
<div className="stock-title abook-title-row">
|
||||
<div>
|
||||
<h2>
|
||||
<BookOutlined className="product-title-icon" />
|
||||
간편장부
|
||||
</h2>
|
||||
<p>매장의 시제 및 관련 장부관리를 관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
<div className="abook-view-toggle">
|
||||
<Button className={view === 'month' ? 'abook-view-active' : ''} onClick={() => setView('month')}>
|
||||
월별 간편장부
|
||||
</Button>
|
||||
<Button className={view === 'detail' ? 'abook-view-active' : ''} onClick={() => setView('detail')}>
|
||||
상세 간편장부
|
||||
</Button>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap>
|
||||
{view === 'detail' && (
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
loading={deleteMut.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('삭제할 장부를 선택하세요.')
|
||||
return
|
||||
}
|
||||
deleteMut.mutate(selected)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
<Button className="abook-register-btn" type="primary" icon={<PlusSquareOutlined />} onClick={openCreate}>
|
||||
간편장부 등록
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{view === 'month' ? (
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="abook-month-nav">
|
||||
<Button type="text" icon={<LeftOutlined />} onClick={() => { setMonth((m) => m.subtract(1, 'month')); setDay('ALL') }} />
|
||||
<span className="abook-month-label">{month.format('YYYY년 M월')}</span>
|
||||
<Button type="text" icon={<RightOutlined />} onClick={() => { setMonth((m) => m.add(1, 'month')); setDay('ALL') }} />
|
||||
</div>
|
||||
<div className="abook-day-row">
|
||||
<button
|
||||
type="button"
|
||||
className={`abook-day-btn${day === 'ALL' ? ' active' : ''}`}
|
||||
onClick={() => setDay('ALL')}
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
{Array.from({ length: daysInMonth }, (_, i) => i + 1).map((d) => {
|
||||
const isToday = month.isSame(today, 'month') && d === today.date()
|
||||
return (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className={`abook-day-btn${day === d ? ' active' : ''}${isToday ? ' today' : ''}`}
|
||||
onClick={() => setDay(d)}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="stock-table-actions">
|
||||
<Tabs
|
||||
activeKey={categoryTab}
|
||||
onChange={setCategoryTab}
|
||||
items={categoryTabs}
|
||||
/>
|
||||
<Button type="link" icon={<SettingOutlined />} onClick={openCatSettings}>과목설정</Button>
|
||||
</div>
|
||||
<div className="abook-cash-summary">{summaryText}</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={monthColumns}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '등록되어 있는 간편장부가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form form={form} layout="inline" className="balance-filter" initialValues={{ size: 10 }} onFinish={onDetailSearch}>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={() => form.setFieldValue('dates', undefined)} />
|
||||
</div>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="category">
|
||||
<Select allowClear placeholder="과목" className="w-120" options={options(categories)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="employee">
|
||||
<Select allowClear showSearch placeholder="등록직원" className="w-120" options={staffOptions} optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="item">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder="품목"
|
||||
className="w-140"
|
||||
options={(query.data?.items || []).map((v) => ({ value: v, label: v }))}
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="q">
|
||||
<Input placeholder="검색" className="w-160" allowClear />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetDetail}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setDetailFilters((v) => ({ ...v, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="stock-table-actions">
|
||||
<Tabs
|
||||
activeKey={String(detailFilters.tab)}
|
||||
onChange={(tab) => setDetailFilters((v) => ({ ...v, tab, page: 0 }))}
|
||||
items={detailTabs}
|
||||
/>
|
||||
<Space>
|
||||
<Button type="link" icon={<SettingOutlined />} onClick={openCatSettings}>과목설정</Button>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={detailColumns}
|
||||
scroll={{ x: 1000 }}
|
||||
pagination={{
|
||||
current: Number(detailFilters.page) + 1,
|
||||
pageSize: Number(detailFilters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setDetailFilters((v) => ({ ...v, page: page - 1 })) }}
|
||||
locale={{ emptyText: '등록되어 있는 간편장부가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={editing ? '간편장부 수정' : '간편장부 등록'}
|
||||
open={modalOpen}
|
||||
onCancel={() => { setCreating(false); setEditing(null) }}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={560}
|
||||
className="abook-modal"
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '100px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
onFinish={(values) => save.mutate(values)}
|
||||
className="balance-edit-form"
|
||||
>
|
||||
<Form.Item name="employee" label="등록직원" rules={[{ required: true, message: '등록직원을 선택하세요' }]}>
|
||||
<Select options={staffOptions} showSearch optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="entryDate" label="거래일" rules={[{ required: true, message: '거래일을 선택하세요' }]}>
|
||||
<DatePicker className="full-width" />
|
||||
</Form.Item>
|
||||
<Form.Item name="entryType" label="구분" rules={[{ required: true, message: '구분을 선택하세요' }]}>
|
||||
<Radio.Group options={ENTRY_TYPES.map((t) => ({ value: t, label: t }))} />
|
||||
</Form.Item>
|
||||
<Form.Item label="과목" required>
|
||||
<Space.Compact className="full-width">
|
||||
<Form.Item name="category" noStyle rules={[{ required: true, message: '과목을 선택하세요' }]}>
|
||||
<Select options={options(categories)} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Button icon={<SettingOutlined />} onClick={openCatSettings}>설정</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item name="item" label="품목" rules={[{ required: true, message: '품목을 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="금액" rules={[{ required: true, message: '금액을 입력하세요' }]}>
|
||||
<InputNumber className="full-width" min={0} addonAfter="원" controls={false} />
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="비고" extra="최대 1,000자">
|
||||
<Input.TextArea rows={4} maxLength={1000} showCount />
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="abook-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<SubjectSettingsModal
|
||||
open={catOpen}
|
||||
items={categories}
|
||||
saving={saveCategories.isPending}
|
||||
submitClassName="abook-submit-btn"
|
||||
onCancel={() => setCatOpen(false)}
|
||||
onSave={(cats) => saveCategories.mutate(cats)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
EditOutlined,
|
||||
PlusSquareOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
UnorderedListOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type BbsRow = {
|
||||
id: number
|
||||
no?: number
|
||||
title?: string
|
||||
authorName?: string
|
||||
views?: number
|
||||
createdDate?: string
|
||||
contents?: string
|
||||
}
|
||||
|
||||
type BbsResult = {
|
||||
total: number
|
||||
list: BbsRow[]
|
||||
}
|
||||
|
||||
const initial = { page: 0, size: 10, searchType: '제목', sort: 'id,desc' } as Record<string, unknown>
|
||||
|
||||
export default function BbsPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [detail, setDetail] = useState<BbsRow | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-bbs', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<BbsResult>>(`${apiPrefix()}/homes/bbs`, {
|
||||
params: {
|
||||
...filters,
|
||||
q: filters.keyword || undefined } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-bbs'] })
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/bbs`, {
|
||||
title: values.title,
|
||||
contents: values.contents })
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('게시물을 등록했습니다.')
|
||||
setOpen(false)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const openDetail = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.get<ApiResponse<BbsRow>>(`${apiPrefix()}/homes/bbs/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (row) => {
|
||||
setDetail(row)
|
||||
qc.invalidateQueries({ queryKey: ['homes-bbs'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
setFilters({
|
||||
...initial,
|
||||
searchType: values.searchType || '제목',
|
||||
keyword: values.keyword || undefined,
|
||||
page: 0,
|
||||
size: Number(values.size ?? filters.size ?? 10),
|
||||
sort: filters.sort })
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ searchType: '제목', size: 10 })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '번호',
|
||||
dataIndex: 'no',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '제목',
|
||||
dataIndex: 'title',
|
||||
ellipsis: true,
|
||||
render: (v: string, row: BbsRow) => (
|
||||
<Button type="link" className="cs-notice-title" onClick={() => openDetail.mutate(row.id)}>
|
||||
{v}
|
||||
</Button>
|
||||
) },
|
||||
{
|
||||
title: '직원',
|
||||
dataIndex: 'authorName',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '조회',
|
||||
dataIndex: 'views',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
sorter: true,
|
||||
render: (v?: number) => (v == null ? 0 : Number(v).toLocaleString()) },
|
||||
{
|
||||
title: '등록일',
|
||||
dataIndex: 'createdDate',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="stock-page cs-notice-page homes-bbs-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<UnorderedListOutlined className="product-title-icon" />
|
||||
사내게시판
|
||||
</h2>
|
||||
<p>등록된 직원끼리 이용하실 수 있는 사내전용 게시판입니다.</p>
|
||||
</div>
|
||||
<Button
|
||||
className="bbs-register-btn"
|
||||
type="primary"
|
||||
icon={<PlusSquareOutlined />}
|
||||
onClick={() => {
|
||||
editForm.resetFields()
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
게시물 등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="farebox-filter"
|
||||
initialValues={{ searchType: '제목', size: 10 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="farebox-filter-row">
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-110" options={['제목', '직원', '내용'].map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-230" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
|
||||
검색
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={onReset}>
|
||||
초기화
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="size" className="farebox-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((prev) => ({ ...prev, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="farebox-table-toolbar">
|
||||
<Typography.Text>
|
||||
목록 <b>{query.data?.total ?? 0}</b>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<CareTable
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
rowKey="id"
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
bordered
|
||||
pagination={{
|
||||
current: Number(filters.page ?? 0) + 1,
|
||||
pageSize: Number(filters.size ?? 10),
|
||||
total: query.data?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
onChange={(_p, _f, sorter) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter
|
||||
if (!s?.field || !s.order) return
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort: `${String(s.field)},${s.order === 'ascend' ? 'asc' : 'desc'}`,
|
||||
page: 0 }))
|
||||
}}
|
||||
locale={{ emptyText: '등록되어 있는 게시물이 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="게시물 등록"
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
footer={null}
|
||||
width={560}
|
||||
destroyOnHidden
|
||||
centered
|
||||
>
|
||||
<Form form={editForm} layout="vertical" onFinish={(values) => createMut.mutate(values)}>
|
||||
<Form.Item name="title" label="제목" rules={[{ required: true, message: '제목을 입력하세요.' }]}>
|
||||
<Input placeholder="제목을 입력하세요" />
|
||||
</Form.Item>
|
||||
<Form.Item name="contents" label="내용" rules={[{ required: true, message: '내용을 입력하세요.' }]}>
|
||||
<Input.TextArea rows={10} placeholder="내용을 입력하세요" />
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button
|
||||
className="bbs-register-btn"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={createMut.isPending}
|
||||
icon={<EditOutlined />}
|
||||
>
|
||||
등록
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="사내게시판"
|
||||
open={Boolean(detail)}
|
||||
onCancel={() => setDetail(null)}
|
||||
footer={<Button onClick={() => setDetail(null)}>닫기</Button>}
|
||||
width={640}
|
||||
destroyOnHidden
|
||||
centered
|
||||
>
|
||||
{detail ? (
|
||||
<div>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
{detail.title}
|
||||
</Typography.Title>
|
||||
<div className="cs-notice-meta">
|
||||
<span>직원 {detail.authorName || '-'}</span>
|
||||
<span>조회 {detail.views ?? 0}</span>
|
||||
<span>등록일 {detail.createdDate || '-'}</span>
|
||||
</div>
|
||||
<div className="cs-notice-body">
|
||||
{(detail.contents || '')
|
||||
.split('\n')
|
||||
.map((line, idx) => (
|
||||
<p key={idx}>{line || '\u00A0'}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { HomesLogListPage } from './HomesLogListPage'
|
||||
|
||||
const ACTIONS = [
|
||||
'예약-등록',
|
||||
'예약-수정',
|
||||
'예약-삭제',
|
||||
'예약-상태',
|
||||
]
|
||||
|
||||
/** 원본 예약관리 타이틀 잎 아이콘 */
|
||||
function LeafOutlined({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={className} role="img" aria-label="leaf">
|
||||
<svg viewBox="0 0 24 24" width="1em" height="1em" fill="currentColor" aria-hidden>
|
||||
<path d="M17 8C8 10 5.9 16.17 3.82 21.34l1.89.66.95-2.3c.10.0.1.1.34.01C19 20 22 3 22 3c-1 2-8 2.25-13 3.25S2 11.5 2 13.5s1.75 3.75 1.75 3.75C7 8 17 8 17 8z" />
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BookingLogPage() {
|
||||
return (
|
||||
<HomesLogListPage
|
||||
domain="BOOKING"
|
||||
title="예약이력"
|
||||
description="예약관리와 관련된 이력을 확인하실 수 있습니다."
|
||||
defaultActions={ACTIONS}
|
||||
titleIcon={<LeafOutlined className="product-title-icon" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Tabs,
|
||||
Upload,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
CloseOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
PaperClipOutlined,
|
||||
PlusSquareOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { options } from './homesShared'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
/** 원본 예약관리 타이틀 잎 아이콘 */
|
||||
function LeafOutlined({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={className} role="img" aria-label="leaf">
|
||||
<svg viewBox="0 0 24 24" width="1em" height="1em" fill="currentColor" aria-hidden>
|
||||
<path d="M17 8C8 10 5.9 16.17 3.82 21.34l1.89.66.95-2.3c.10.0.1.1.34.01C19 20 22 3 22 3c-1 2-8 2.25-13 3.25S2 11.5 2 13.5s1.75 3.75 1.75 3.75C7 8 17 8 17 8z" />
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const INTERNET_OPTS = ['인터넷', 'TV', '전화', '와이파이', '스마트홈', '기타']
|
||||
const RENTAL_OPTS = ['정수기', '공기청정기', '비데', '매트리스', '가전', '기타']
|
||||
|
||||
type BookingRow = {
|
||||
id: number
|
||||
status?: string
|
||||
bookingDate?: string
|
||||
customerName?: string
|
||||
phone?: string
|
||||
internetProducts?: string
|
||||
rentalProducts?: string
|
||||
internetNote?: string
|
||||
rentalNote?: string
|
||||
zipcode?: string
|
||||
address?: string
|
||||
addressDetail?: string
|
||||
employee?: string
|
||||
receiveEmployee?: string
|
||||
memo?: string
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
type BookingResult = {
|
||||
total: number
|
||||
list: BookingRow[]
|
||||
counts?: Record<string, number>
|
||||
employees?: string[]
|
||||
internetOptions?: string[]
|
||||
rentalOptions?: string[]
|
||||
}
|
||||
|
||||
const initial = { page: 0, size: 10, tab: 'ALL' } as Record<string, unknown>
|
||||
|
||||
const maskName = (name?: string) => {
|
||||
if (!name) return '-'
|
||||
if (name.length <= 1) return name
|
||||
if (name.length === 2) return `${name[0]}*`
|
||||
return `${name[0]}*${name.slice(2)}`
|
||||
}
|
||||
|
||||
const maskPhone = (phone?: string) => {
|
||||
if (!phone) return '-'
|
||||
const digits = phone.replace(/\D/g, '')
|
||||
if (digits.length < 7) return phone
|
||||
if (digits.length === 10) return `${digits.slice(0, 3)}-****-${digits.slice(6)}`
|
||||
return `${digits.slice(0, 3)}-****-${digits.slice(-4)}`
|
||||
}
|
||||
|
||||
const splitPhone = (phone?: string) => {
|
||||
const digits = (phone || '').replace(/\D/g, '')
|
||||
if (digits.length >= 10) {
|
||||
return {
|
||||
phone1: digits.slice(0, 3),
|
||||
phone2: digits.slice(3, digits.length - 4),
|
||||
phone3: digits.slice(-4) }
|
||||
}
|
||||
return { phone1: '010', phone2: '', phone3: '' }
|
||||
}
|
||||
|
||||
const splitProducts = (v?: string) =>
|
||||
(v || '')
|
||||
.split(/[,,]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
export default function BookingPage() {
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const qc = useQueryClient()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [editing, setEditing] = useState<BookingRow | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-bookings', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<BookingResult>>(`${apiPrefix()}/homes/bookings`, {
|
||||
params: {
|
||||
...filters,
|
||||
status: filters.tab === 'ALL' ? undefined : filters.tab } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const members = useQuery({
|
||||
queryKey: ['homes/members-options'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ list: { id: number; name?: string }[] }>>(`${apiPrefix()}/homes/members`, {
|
||||
params: { page: 0, size: 200 } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ['homes-bookings'] })
|
||||
setSelected([])
|
||||
}
|
||||
|
||||
const staffOptions = useMemo(() => {
|
||||
const fromApi = query.data?.employees || []
|
||||
const fromMembers = (members.data?.list || []).map((m) => String(m.name || '')).filter(Boolean)
|
||||
return Array.from(new Set([...fromApi, ...fromMembers, '홍길동'])).map((v) => ({ value: v, label: v }))
|
||||
}, [query.data, members.data])
|
||||
|
||||
const internetOptions = useMemo(
|
||||
() => options(query.data?.internetOptions?.length ? query.data.internetOptions : INTERNET_OPTS),
|
||||
[query.data?.internetOptions],
|
||||
)
|
||||
const rentalOptions = useMemo(
|
||||
() => options(query.data?.rentalOptions?.length ? query.data.rentalOptions : RENTAL_OPTS),
|
||||
[query.data?.rentalOptions],
|
||||
)
|
||||
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{ key: 'ALL', label: `전체 ${query.data?.counts?.ALL ?? query.data?.total ?? 0}` },
|
||||
{ key: '접수', label: `접수 ${query.data?.counts?.['접수'] ?? 0}` },
|
||||
{ key: '완료', label: `완료 ${query.data?.counts?.['완료'] ?? 0}` },
|
||||
{ key: '보류', label: `보류 ${query.data?.counts?.['보류'] ?? 0}` },
|
||||
],
|
||||
[query.data],
|
||||
)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
status: values.status || editing?.status || '접수',
|
||||
bookingDate: values.bookingDate ? dayjs(values.bookingDate as Dayjs).format('YYYY-MM-DD') : undefined,
|
||||
receiveEmployee: values.receiveEmployee,
|
||||
employee: values.receiveEmployee,
|
||||
customerName: values.customerName,
|
||||
phone1: values.phone1,
|
||||
phone2: values.phone2,
|
||||
phone3: values.phone3,
|
||||
zipcode: values.zipcode,
|
||||
address: values.address,
|
||||
addressDetail: values.addressDetail,
|
||||
internetProducts: values.internetProducts || [],
|
||||
rentalProducts: values.rentalProducts || [],
|
||||
internetNote: values.internetNote,
|
||||
rentalNote: values.rentalNote,
|
||||
memo: values.memo,
|
||||
fileName: values.fileName }
|
||||
if (editing) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/bookings/${editing.id}`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/bookings`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '수정했습니다.' : '예약을 등록했습니다.')
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/bookings/delete`, { ids })
|
||||
return unwrap(data as ApiResponse<{ deleted: number }>)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
message.success(`${data.deleted}건을 삭제했습니다.`)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setFilters({
|
||||
...initial,
|
||||
receiveEmployee: values.receiveEmployee || undefined,
|
||||
internetProduct: values.internetProduct || undefined,
|
||||
rentalProduct: values.rentalProduct || undefined,
|
||||
phoneTail: values.phoneTail || undefined,
|
||||
q: values.q || undefined,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 10,
|
||||
tab: filters.tab })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ size: 10 })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setCreating(true)
|
||||
const defaultStaff = staffOptions[0]?.value || '홍길동'
|
||||
editForm.setFieldsValue({
|
||||
bookingDate: dayjs(),
|
||||
receiveEmployee: defaultStaff,
|
||||
customerName: undefined,
|
||||
phone1: '010',
|
||||
phone2: '',
|
||||
phone3: '',
|
||||
zipcode: undefined,
|
||||
address: undefined,
|
||||
addressDetail: undefined,
|
||||
internetProducts: [],
|
||||
rentalProducts: [],
|
||||
internetNote: undefined,
|
||||
rentalNote: undefined,
|
||||
memo: undefined,
|
||||
fileName: undefined,
|
||||
status: '접수' })
|
||||
}
|
||||
|
||||
const openEdit = (row: BookingRow) => {
|
||||
setCreating(false)
|
||||
setEditing(row)
|
||||
const phones = splitPhone(row.phone)
|
||||
editForm.setFieldsValue({
|
||||
bookingDate: row.bookingDate ? dayjs(row.bookingDate) : dayjs(),
|
||||
receiveEmployee: row.receiveEmployee || row.employee,
|
||||
customerName: row.customerName,
|
||||
...phones,
|
||||
zipcode: row.zipcode,
|
||||
address: row.address,
|
||||
addressDetail: row.addressDetail,
|
||||
internetProducts: splitProducts(row.internetProducts),
|
||||
rentalProducts: splitProducts(row.rentalProducts),
|
||||
internetNote: row.internetNote,
|
||||
rentalNote: row.rentalNote,
|
||||
memo: row.memo,
|
||||
fileName: row.fileName,
|
||||
status: row.status || '접수' })
|
||||
}
|
||||
|
||||
const searchAddress = () => {
|
||||
message.info('주소검색(데모) — 직접 입력해 주세요.')
|
||||
editForm.setFieldsValue({
|
||||
zipcode: editForm.getFieldValue('zipcode') || '06236',
|
||||
address: editForm.getFieldValue('address') || '서울특별시 강남구' })
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/homes/bookings/export`, {
|
||||
params: { ...filters, status: filters.tab === 'ALL' ? undefined : filters.tab },
|
||||
responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '예약관리.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: (
|
||||
<Checkbox
|
||||
checked={!!query.data?.list?.length && selected.length === query.data.list.length}
|
||||
indeterminate={selected.length > 0 && selected.length < (query.data?.list?.length || 0)}
|
||||
onChange={(e) => setSelected(e.target.checked ? (query.data?.list || []).map((r) => r.id) : [])}
|
||||
/>
|
||||
),
|
||||
width: 42,
|
||||
render: (_: unknown, row: BookingRow) => (
|
||||
<Checkbox
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={(e) => setSelected((prev) => (e.target.checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)))}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '접수일',
|
||||
dataIndex: 'bookingDate',
|
||||
width: 110,
|
||||
sorter: (a: BookingRow, b: BookingRow) => String(a.bookingDate || '').localeCompare(String(b.bookingDate || '')) },
|
||||
{
|
||||
title: '상태',
|
||||
dataIndex: 'status',
|
||||
width: 70,
|
||||
render: (v: string) => <span className={v === '접수' ? 'booking-status-open' : ''}>{v || '-'}</span> },
|
||||
{
|
||||
title: '고객명',
|
||||
dataIndex: 'customerName',
|
||||
width: 90,
|
||||
render: (v: string) => maskName(v) },
|
||||
{
|
||||
title: '연락처',
|
||||
dataIndex: 'phone',
|
||||
width: 130,
|
||||
render: (v: string) => maskPhone(v) },
|
||||
{
|
||||
title: '인터넷상품',
|
||||
dataIndex: 'internetProducts',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '홈렌탈상품',
|
||||
dataIndex: 'rentalProducts',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '접수직원',
|
||||
dataIndex: 'employee',
|
||||
width: 90,
|
||||
render: (_: string, row: BookingRow) => row.receiveEmployee || row.employee || '-' },
|
||||
{
|
||||
title: '관리',
|
||||
width: 64,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: BookingRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="booking-manage-btn"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(row)}
|
||||
/>
|
||||
) },
|
||||
]
|
||||
|
||||
const modalOpen = creating || Boolean(editing)
|
||||
|
||||
return (
|
||||
<div className="stock-page booking-manage-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<LeafOutlined className="product-title-icon" />
|
||||
예약관리
|
||||
</h2>
|
||||
<p>예약하시는 고객의 정보를 등록/관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
loading={deleteMut.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('삭제할 예약을 선택하세요.')
|
||||
return
|
||||
}
|
||||
deleteMut.mutate(selected)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button className="booking-register-btn" type="primary" icon={<PlusSquareOutlined />} onClick={openCreate}>
|
||||
예약 등록
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form form={form} layout="inline" className="balance-filter" initialValues={{ size: 10 }} onFinish={onSearch}>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={() => form.setFieldValue('dates', undefined)} />
|
||||
</div>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="receiveEmployee">
|
||||
<Select allowClear showSearch placeholder="접수직원" className="w-120" options={staffOptions} optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="internetProduct">
|
||||
<Select allowClear placeholder="인터넷상품" className="w-130" options={internetOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="rentalProduct">
|
||||
<Select allowClear placeholder="홈렌탈상품" className="w-130" options={rentalOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phoneTail">
|
||||
<Input placeholder="연락처(뒷4자리)" className="w-140" maxLength={4} allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="q">
|
||||
<Input placeholder="검색" className="w-160" allowClear />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((v) => ({ ...v, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="stock-table-actions">
|
||||
<Tabs
|
||||
activeKey={String(filters.tab)}
|
||||
onChange={(tab) => setFilters((v) => ({ ...v, tab, page: 0 }))}
|
||||
items={tabs}
|
||||
/>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((v) => ({ ...v, page: page - 1 })) }}
|
||||
locale={{ emptyText: '등록된 자료가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '예약 수정' : '예약 등록'}
|
||||
open={modalOpen}
|
||||
onCancel={() => { setCreating(false); setEditing(null) }}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={640}
|
||||
className="booking-modal"
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '110px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
onFinish={(values) => save.mutate(values)}
|
||||
className="balance-edit-form"
|
||||
>
|
||||
<Form.Item name="bookingDate" label="접수일" rules={[{ required: true, message: '접수일을 선택하세요' }]}>
|
||||
<DatePicker className="full-width" />
|
||||
</Form.Item>
|
||||
<Form.Item name="receiveEmployee" label="접수직원" rules={[{ required: true, message: '접수직원을 선택하세요' }]}>
|
||||
<Select options={staffOptions} showSearch optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="customerName" label="고객명" rules={[{ required: true, message: '고객명을 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="연락처" required>
|
||||
<Space.Compact>
|
||||
<Form.Item name="phone1" noStyle rules={[{ required: true, message: '연락처를 입력하세요' }]}>
|
||||
<Input style={{ width: 70 }} maxLength={3} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone2" noStyle rules={[{ required: true, message: '연락처를 입력하세요' }]}>
|
||||
<Input style={{ width: 80 }} maxLength={4} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone3" noStyle rules={[{ required: true, message: '연락처를 입력하세요' }]}>
|
||||
<Input style={{ width: 80 }} maxLength={4} />
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="주소">
|
||||
<div className="booking-address-row">
|
||||
<Button onClick={searchAddress}>주소검색</Button>
|
||||
<Form.Item name="zipcode" noStyle>
|
||||
<Input className="booking-zip" placeholder="우편번호" />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" noStyle>
|
||||
<Input className="booking-addr" placeholder="기본주소" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="addressDetail" noStyle>
|
||||
<Input className="booking-addr-detail" placeholder="상세주소" />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
<Form.Item name="internetProducts" label="인터넷상품">
|
||||
<Checkbox.Group options={INTERNET_OPTS} className="booking-check-group" />
|
||||
</Form.Item>
|
||||
<Form.Item name="internetNote" label=" " colon={false}>
|
||||
<Input placeholder="희망하시는 상품명 및 수량이 있으신 경우 입력해주세요." />
|
||||
</Form.Item>
|
||||
<Form.Item name="rentalProducts" label="홈렌탈상품">
|
||||
<Checkbox.Group options={RENTAL_OPTS} className="booking-check-group" />
|
||||
</Form.Item>
|
||||
<Form.Item name="rentalNote" label=" " colon={false}>
|
||||
<Input placeholder="희망하시는 상품명 및 수량이 있으신 경우 입력해주세요." />
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="비고" extra="최대 1,000자">
|
||||
<Input.TextArea rows={4} maxLength={1000} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item label="파일첨부">
|
||||
<Form.Item name="fileName" noStyle>
|
||||
<Input type="hidden" />
|
||||
</Form.Item>
|
||||
<Upload
|
||||
beforeUpload={(file) => {
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
message.error('최대 20M까지 첨부할 수 있습니다.')
|
||||
return Upload.LIST_IGNORE
|
||||
}
|
||||
editForm.setFieldValue('fileName', file.name)
|
||||
message.success(`${file.name} 선택됨 (데모)`)
|
||||
return false
|
||||
}}
|
||||
maxCount={1}
|
||||
showUploadList={false}
|
||||
>
|
||||
<Button icon={<PaperClipOutlined />}>파일 선택</Button>
|
||||
</Upload>
|
||||
<div className="booking-file-hint">* 다량의 파일은 압축해서 올려주십시요. (최대 20M)</div>
|
||||
<Form.Item shouldUpdate={(prev, cur) => prev.fileName !== cur.fileName} noStyle>
|
||||
{() => {
|
||||
const name = editForm.getFieldValue('fileName')
|
||||
return name ? <div className="booking-file-name">{name}</div> : null
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
<div className="booking-modal-footer">
|
||||
<div className="booking-modal-note">* 고객명, 연락처는 고객관리에도 동시등록됩니다.</div>
|
||||
<Space>
|
||||
<Button className="booking-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Card, Typography } from 'antd'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export default function CarePlaceholderPage({
|
||||
title,
|
||||
description = '원본 비즈케어홈즈 메뉴와 동일한 경로로 연결해 두었습니다. 상세 화면은 순차 구현 예정입니다.' }: Props) {
|
||||
return (
|
||||
<Card>
|
||||
<Typography.Title level={3} style={{ marginTop: 0 }}>{title}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">{description}</Typography.Paragraph>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Table, Button, Card, DatePicker, Form, InputNumber, Select, Space, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { RadarChartOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { money, options } from './homesShared'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
type AdjustRow = {
|
||||
id: number
|
||||
kind?: string
|
||||
status?: string
|
||||
contractDate?: string
|
||||
customerName?: string
|
||||
phone?: string
|
||||
products?: string
|
||||
months?: number
|
||||
agency?: string
|
||||
employee?: string
|
||||
policySum?: number
|
||||
settleAmount?: number
|
||||
margin?: number
|
||||
}
|
||||
|
||||
type AdjustResult = {
|
||||
total: number
|
||||
list: AdjustRow[]
|
||||
employees?: string[]
|
||||
products?: string[]
|
||||
}
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
{ value: 'INTERNET', label: '인터넷' },
|
||||
{ value: 'HOME', label: '홈렌탈' },
|
||||
]
|
||||
const CATEGORY_OPTIONS = ['인터넷', 'TV', '전화', '와이파이', '스마트홈', '정수기', '공기청정기', '비데', '매트리스', '가전', '기타']
|
||||
const MONTH_OPTIONS = [
|
||||
{ value: '12', label: '12개월' },
|
||||
{ value: '24', label: '24개월' },
|
||||
{ value: '36', label: '36개월' },
|
||||
{ value: '48', label: '48개월' },
|
||||
{ value: '60', label: '60개월' },
|
||||
]
|
||||
const STATUS_OPTIONS = ['접수', '신청', '완료', '철회']
|
||||
|
||||
export default function ContractAdjustPage() {
|
||||
const [form] = Form.useForm()
|
||||
const [searched, setSearched] = useState(false)
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({})
|
||||
const [rows, setRows] = useState<AdjustRow[]>([])
|
||||
const [batch, setBatch] = useState({
|
||||
policySum: undefined as number | undefined,
|
||||
settleAmount: undefined as number | undefined,
|
||||
margin: undefined as number | undefined })
|
||||
|
||||
const enabled = searched && !!filters.dateFrom && !!filters.dateTo
|
||||
const optionParams = useMemo(() => ({
|
||||
dateFrom: dayjs().startOf('month').format('YYYY-MM-DD'),
|
||||
dateTo: dayjs().format('YYYY-MM-DD') }), [])
|
||||
const optionsQuery = useQuery({
|
||||
queryKey: ['homes-contract-adjust-options', optionParams],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<AdjustResult>>(`${apiPrefix()}/homes/contracts/adjust`, { params: optionParams })
|
||||
return unwrap(data)
|
||||
} })
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-contract-adjust', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<AdjustResult>>(`${apiPrefix()}/homes/contracts/adjust`, { params: filters })
|
||||
return unwrap(data)
|
||||
},
|
||||
enabled })
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.data?.list) return
|
||||
setRows(query.data.list.map((row) => ({
|
||||
...row,
|
||||
policySum: Number(row.policySum ?? 0),
|
||||
settleAmount: Number(row.settleAmount ?? 0),
|
||||
margin: Number(row.margin ?? 0) })))
|
||||
}, [query.data])
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => {
|
||||
if (to.diff(from, 'day') > 30) {
|
||||
message.warning('최대 31일까지 조회할 수 있습니다.')
|
||||
return
|
||||
}
|
||||
form.setFieldsValue({ dates: [from, to] })
|
||||
}
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
if (!dates?.[0] || !dates?.[1]) {
|
||||
message.warning('계약일을 선택하세요.')
|
||||
return
|
||||
}
|
||||
if (dates[1].diff(dates[0], 'day') > 30) {
|
||||
message.warning('최대 31일까지 조회할 수 있습니다.')
|
||||
return
|
||||
}
|
||||
setSearched(true)
|
||||
setFilters({
|
||||
dateFrom: dates[0].format('YYYY-MM-DD'),
|
||||
dateTo: dates[1].format('YYYY-MM-DD'),
|
||||
kind: values.kind || undefined,
|
||||
category: values.category || undefined,
|
||||
product: values.product || undefined,
|
||||
months: values.months || undefined,
|
||||
status: values.status || undefined,
|
||||
employee: values.employee || undefined })
|
||||
}
|
||||
|
||||
const updateRow = (id: number, field: 'policySum' | 'settleAmount' | 'margin', value: number | null) => {
|
||||
setRows((prev) => prev.map((row) => (row.id === id ? { ...row, [field]: value ?? 0 } : row)))
|
||||
}
|
||||
|
||||
const applyBatch = (field: 'policySum' | 'settleAmount' | 'margin') => {
|
||||
const value = batch[field]
|
||||
if (value == null) {
|
||||
message.warning('금액을 입력하세요.')
|
||||
return
|
||||
}
|
||||
setRows((prev) => prev.map((row) => ({ ...row, [field]: value })))
|
||||
message.success('일괄 입력했습니다.')
|
||||
}
|
||||
|
||||
const saveOne = useMutation({
|
||||
mutationFn: async (row: AdjustRow) => {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/contracts/${row.id}`, {
|
||||
policySum: row.policySum,
|
||||
settleAmount: row.settleAmount,
|
||||
margin: row.margin })
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => message.success('변경했습니다.'),
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const saveAll = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/contracts/adjust/batch`, {
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
policySum: row.policySum,
|
||||
settleAmount: row.settleAmount,
|
||||
margin: row.margin })) })
|
||||
return unwrap(data as ApiResponse<{ updated: number }>)
|
||||
},
|
||||
onSuccess: (data) => message.success(`${data.updated}건을 변경했습니다.`),
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const employeeOptions = useMemo(() => {
|
||||
const fromApi = [...(optionsQuery.data?.employees || []), ...(query.data?.employees || [])]
|
||||
const fromRows = rows.map((r) => r.employee).filter(Boolean) as string[]
|
||||
return Array.from(new Set([...fromApi, ...fromRows]))
|
||||
}, [optionsQuery.data?.employees, query.data?.employees, rows])
|
||||
|
||||
const productOptions = useMemo(() => {
|
||||
const fromApi = [...(optionsQuery.data?.products || []), ...(query.data?.products || [])]
|
||||
return Array.from(new Set(fromApi)).map((v) => ({ value: v, label: v }))
|
||||
}, [optionsQuery.data?.products, query.data?.products])
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '종류',
|
||||
dataIndex: 'kind',
|
||||
width: 80,
|
||||
render: (v: string) => (v === 'HOME' ? '홈렌탈' : '인터넷') },
|
||||
{ title: '상태', dataIndex: 'status', width: 70 },
|
||||
{ title: '계약일', dataIndex: 'contractDate', width: 100 },
|
||||
{ title: '고객명', dataIndex: 'customerName', width: 90 },
|
||||
{ title: '가입상품', dataIndex: 'products', width: 180, ellipsis: true },
|
||||
{
|
||||
title: '약정',
|
||||
dataIndex: 'months',
|
||||
width: 80,
|
||||
render: (v: number) => (v ? `${v}개월` : '-') },
|
||||
{ title: '거래처', dataIndex: 'agency', width: 110, ellipsis: true },
|
||||
{ title: '직원', dataIndex: 'employee', width: 90 },
|
||||
{
|
||||
title: '정책합산',
|
||||
dataIndex: 'policySum',
|
||||
width: 120,
|
||||
render: (_: unknown, row: AdjustRow) => (
|
||||
<InputNumber
|
||||
className="adjust-cell-input"
|
||||
controls={false}
|
||||
value={row.policySum}
|
||||
onChange={(v) => updateRow(row.id, 'policySum', v)}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '정산금액',
|
||||
dataIndex: 'settleAmount',
|
||||
width: 120,
|
||||
render: (_: unknown, row: AdjustRow) => (
|
||||
<InputNumber
|
||||
className="adjust-cell-input"
|
||||
controls={false}
|
||||
value={row.settleAmount}
|
||||
onChange={(v) => updateRow(row.id, 'settleAmount', v)}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '판매마진',
|
||||
dataIndex: 'margin',
|
||||
width: 120,
|
||||
render: (_: unknown, row: AdjustRow) => (
|
||||
<InputNumber
|
||||
className="adjust-cell-input"
|
||||
controls={false}
|
||||
value={row.margin}
|
||||
onChange={(v) => updateRow(row.id, 'margin', v)}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '개별',
|
||||
width: 70,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, row: AdjustRow) => (
|
||||
<Button size="small" onClick={() => saveOne.mutate(row)} loading={saveOne.isPending}>변경</Button>
|
||||
) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stock-page adjust-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<RadarChartOutlined className="product-title-icon" />
|
||||
일괄정책변경
|
||||
</h2>
|
||||
<p>등록된 계약정보에 일괄적으로 기본정책금액을 변경하실 수 있습니다. (단일상품의 수량이 2개 이상인 경우 제외됩니다.)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card adjust-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
initialValues={{
|
||||
dates: [dayjs().startOf('month'), dayjs()] }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="adjust-filter-row adjust-date-row">
|
||||
<span className="adjust-label">계약일</span>
|
||||
<Form.Item name="dates" noStyle>
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Typography.Text type="danger" className="adjust-hint">* 최대 31일 조회</Typography.Text>
|
||||
<Button className="adjust-search-btn" type="primary" htmlType="submit" icon={<SearchOutlined />}>
|
||||
계약정보 검색
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="adjust-filter-row">
|
||||
<span className="adjust-label">검색조건</span>
|
||||
<Form.Item name="kind" noStyle>
|
||||
<Select allowClear placeholder="-종류-" className="w-110" options={KIND_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" noStyle>
|
||||
<Select allowClear placeholder="-구분-" className="w-110" options={options(CATEGORY_OPTIONS)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="product" noStyle>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder="-상품-"
|
||||
className="w-150"
|
||||
options={productOptions.length ? productOptions : undefined}
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="months" noStyle>
|
||||
<Select allowClear placeholder="-약정-" className="w-110" options={MONTH_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" noStyle>
|
||||
<Select allowClear placeholder="-상태-" className="w-110" options={options(STATUS_OPTIONS)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="employee" noStyle>
|
||||
<Select allowClear placeholder="-직원-" className="w-120" options={options(employeeOptions)} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{enabled && (
|
||||
<Card className="stock-table-card adjust-result-card" size="small">
|
||||
<div className="adjust-result-head">
|
||||
<Typography.Text>검색된 계약 <b>{query.data?.total ?? rows.length}</b> 건</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="adjust-batch-bar">
|
||||
<span className="adjust-batch-guide">원하시는 금액을 입력 후 엔터키를 눌러주세요.</span>
|
||||
<Space wrap size={12}>
|
||||
<Space.Compact className="adjust-batch-compact">
|
||||
<InputNumber
|
||||
className="adjust-batch-input"
|
||||
controls={false}
|
||||
placeholder="정책합산"
|
||||
value={batch.policySum}
|
||||
onChange={(v) => setBatch((s) => ({ ...s, policySum: v ?? undefined }))}
|
||||
onPressEnter={() => applyBatch('policySum')}
|
||||
/>
|
||||
<Button className="adjust-batch-btn" type="primary" onClick={() => applyBatch('policySum')}>
|
||||
일괄입력
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Space.Compact className="adjust-batch-compact">
|
||||
<InputNumber
|
||||
className="adjust-batch-input"
|
||||
controls={false}
|
||||
placeholder="정산금액"
|
||||
value={batch.settleAmount}
|
||||
onChange={(v) => setBatch((s) => ({ ...s, settleAmount: v ?? undefined }))}
|
||||
onPressEnter={() => applyBatch('settleAmount')}
|
||||
/>
|
||||
<Button className="adjust-batch-btn" type="primary" onClick={() => applyBatch('settleAmount')}>
|
||||
일괄입력
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Space.Compact className="adjust-batch-compact">
|
||||
<InputNumber
|
||||
className="adjust-batch-input"
|
||||
controls={false}
|
||||
placeholder="판매마진"
|
||||
value={batch.margin}
|
||||
onChange={(v) => setBatch((s) => ({ ...s, margin: v ?? undefined }))}
|
||||
onPressEnter={() => applyBatch('margin')}
|
||||
/>
|
||||
<Button className="adjust-batch-btn" type="primary" onClick={() => applyBatch('margin')}>
|
||||
일괄입력
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={rows}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
scroll={{ x: 1400 }}
|
||||
locale={{ emptyText: '검색된 계약이 없습니다.' }}
|
||||
summary={() => (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={8}>합계</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1} align="right">
|
||||
{money(rows.reduce((s, r) => s + Number(r.policySum || 0), 0))}
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={2} align="right">
|
||||
{money(rows.reduce((s, r) => s + Number(r.settleAmount || 0), 0))}
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={3} align="right">
|
||||
{money(rows.reduce((s, r) => s + Number(r.margin || 0), 0))}
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={4} />
|
||||
</Table.Summary.Row>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="adjust-submit">
|
||||
<Button
|
||||
className="adjust-submit-btn"
|
||||
type="primary"
|
||||
size="large"
|
||||
loading={saveAll.isPending}
|
||||
disabled={!rows.length}
|
||||
onClick={() => saveAll.mutate()}
|
||||
>
|
||||
상기 입력된 값으로 모두 변경합니다.
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Tabs,
|
||||
Tooltip,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
CloseOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
MailOutlined,
|
||||
PlusSquareOutlined,
|
||||
RadarChartOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { options, type PageResult } from './homesShared'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
const TABS = [
|
||||
['ALL', '전체'],
|
||||
['정산차감', '정산차감'],
|
||||
['정산추가', '정산추가'],
|
||||
] as const
|
||||
|
||||
type BalanceRow = {
|
||||
id: number
|
||||
balanceType?: string
|
||||
targetType?: string
|
||||
targetName?: string
|
||||
baseDate?: string
|
||||
reason?: string
|
||||
amount?: number
|
||||
memo?: string
|
||||
}
|
||||
|
||||
type BalanceSearch = PageResult & {
|
||||
list: BalanceRow[]
|
||||
counts?: Record<string, number>
|
||||
employees?: string[]
|
||||
agencies?: string[]
|
||||
reasons?: string[]
|
||||
}
|
||||
|
||||
const isDeduct = (gubun?: string) => Boolean(gubun && gubun.includes('차감'))
|
||||
|
||||
const initial = {
|
||||
page: 0,
|
||||
size: 10,
|
||||
tab: 'ALL' } as Record<string, unknown>
|
||||
|
||||
export default function ContractBalancePage() {
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const qc = useQueryClient()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [editing, setEditing] = useState<BalanceRow | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [amountSign, setAmountSign] = useState<'+' | '-'>('-')
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-balances', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<BalanceSearch>>(`${apiPrefix()}/homes/balances`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const members = useQuery({
|
||||
queryKey: ['homes/members-options'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<PageResult>>(`${apiPrefix()}/homes/members`, { params: { page: 0, size: 200 } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const agencies = useQuery({
|
||||
queryKey: ['homes/agencies-options'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<PageResult>>(`${apiPrefix()}/homes/agencies`, { params: { page: 0, size: 200 } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ['homes-balances'] })
|
||||
setSelected([])
|
||||
}
|
||||
|
||||
const employeeOptions = useMemo(() => {
|
||||
const fromApi = (query.data?.employees || []) as string[]
|
||||
const fromMembers = (members.data?.list || []).map((m) => String(m.name || '')).filter(Boolean)
|
||||
return Array.from(new Set([...fromApi, ...fromMembers])).map((v) => ({ value: v, label: v }))
|
||||
}, [query.data?.employees, members.data])
|
||||
|
||||
const agencyOptions = useMemo(() => {
|
||||
const fromApi = (query.data?.agencies || []) as string[]
|
||||
const fromAgencies = (agencies.data?.list || []).map((a) => String(a.name || '')).filter(Boolean)
|
||||
return Array.from(new Set([...fromApi, ...fromAgencies])).map((v) => ({ value: v, label: v }))
|
||||
}, [query.data?.agencies, agencies.data])
|
||||
|
||||
const reasonOptions = useMemo(() => {
|
||||
const fromApi = (query.data?.reasons || []) as string[]
|
||||
return fromApi.map((v) => ({ value: v, label: v }))
|
||||
}, [query.data?.reasons])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const raw = Number(values.amountAbs ?? 0)
|
||||
const signed = amountSign === '-' ? -Math.abs(raw) : Math.abs(raw)
|
||||
const payload: Record<string, unknown> = {
|
||||
targetType: values.targetType,
|
||||
targetName: values.targetName,
|
||||
balanceType: values.balanceType,
|
||||
reason: values.reason,
|
||||
memo: values.memo,
|
||||
amount: signed,
|
||||
baseDate: values.baseDate ? dayjs(values.baseDate as Dayjs).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD') }
|
||||
if (editing) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/balances/${editing.id}`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/balances`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '수정했습니다.' : '후정산을 등록했습니다.')
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message || '저장에 실패했습니다.') })
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/balances/delete`, { ids })
|
||||
return unwrap(data as ApiResponse<{ deleted: number }>)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
message.success(`${data.deleted}건을 삭제했습니다.`)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setFilters({
|
||||
...initial,
|
||||
employee: values.employee || undefined,
|
||||
agency: values.agency || undefined,
|
||||
reason: values.reason || undefined,
|
||||
q: values.q || undefined,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 10,
|
||||
tab: filters.tab })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ size: 10 })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setCreating(true)
|
||||
setAmountSign('-')
|
||||
editForm.setFieldsValue({
|
||||
targetType: '직원',
|
||||
targetName: undefined,
|
||||
balanceType: '정산차감',
|
||||
baseDate: dayjs(),
|
||||
amountAbs: undefined,
|
||||
reason: undefined,
|
||||
memo: undefined })
|
||||
}
|
||||
|
||||
const openEdit = (row: BalanceRow) => {
|
||||
setCreating(false)
|
||||
setEditing(row)
|
||||
const amount = Number(row.amount ?? 0)
|
||||
setAmountSign(amount < 0 || isDeduct(row.balanceType) ? '-' : '+')
|
||||
editForm.setFieldsValue({
|
||||
targetType: row.targetType || '직원',
|
||||
targetName: row.targetName,
|
||||
balanceType: row.balanceType || '정산차감',
|
||||
amountAbs: Math.abs(amount),
|
||||
reason: row.reason,
|
||||
memo: row.memo,
|
||||
baseDate: row.baseDate ? dayjs(row.baseDate) : dayjs() })
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/homes/balances/export`, { params: filters, responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '계약후정산.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const targetType = Form.useWatch('targetType', editForm)
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: (
|
||||
<Checkbox
|
||||
checked={!!query.data?.list?.length && selected.length === query.data.list.length}
|
||||
indeterminate={selected.length > 0 && selected.length < (query.data?.list?.length || 0)}
|
||||
onChange={(e) => setSelected(e.target.checked ? (query.data?.list || []).map((r) => r.id) : [])}
|
||||
/>
|
||||
),
|
||||
width: 42,
|
||||
render: (_: unknown, row: BalanceRow) => (
|
||||
<Checkbox
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={(e) => setSelected((prev) => (e.target.checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)))}
|
||||
/>
|
||||
) },
|
||||
{ title: '후정산일', dataIndex: 'baseDate', width: 110, sorter: (a: BalanceRow, b: BalanceRow) => String(a.baseDate || '').localeCompare(String(b.baseDate || '')) },
|
||||
{ title: '대상', dataIndex: 'targetType', width: 80 },
|
||||
{ title: '대상명', dataIndex: 'targetName', width: 120, ellipsis: true },
|
||||
{
|
||||
title: '구분',
|
||||
dataIndex: 'balanceType',
|
||||
width: 100,
|
||||
render: (v: string) => <span className={isDeduct(v) ? 'balance-deduct' : ''}>{v}</span> },
|
||||
{ title: '사유', dataIndex: 'reason', width: 140, ellipsis: true },
|
||||
{
|
||||
title: '정산금액',
|
||||
dataIndex: 'amount',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (v: unknown, row: BalanceRow) => {
|
||||
if (v == null || v === '') return '-'
|
||||
const n = Number(v)
|
||||
const cls = isDeduct(row.balanceType) || n < 0 ? 'balance-amount-deduct' : 'balance-amount'
|
||||
return <span className={cls}>{n.toLocaleString()}</span>
|
||||
} },
|
||||
{
|
||||
title: '비고',
|
||||
dataIndex: 'memo',
|
||||
width: 54,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => (v ? <Tooltip title={v}><MailOutlined className="memo-icon" /></Tooltip> : '-') },
|
||||
{
|
||||
title: '관리',
|
||||
width: 58,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, row: BalanceRow) => (
|
||||
<Button size="small" type="text" icon={<EditOutlined style={{ color: '#1677ff' }} />} onClick={() => openEdit(row)} />
|
||||
) },
|
||||
]
|
||||
|
||||
const modalOpen = creating || Boolean(editing)
|
||||
|
||||
return (
|
||||
<div className="stock-page balance-page contract-balance-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<RadarChartOutlined className="product-title-icon" />
|
||||
계약후정산
|
||||
</h2>
|
||||
<p>등록된 정산 외 추가로 후정산을 등록 및 관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
loading={remove.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) return
|
||||
remove.mutate(selected)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button className="balance-register-btn" type="primary" icon={<PlusSquareOutlined />} onClick={openCreate}>
|
||||
후정산등록
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="balance-filter"
|
||||
initialValues={{ size: 10 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={() => form.setFieldValue('dates', undefined)} />
|
||||
<Form.Item name="employee">
|
||||
<Select allowClear placeholder="직원" className="w-120" options={employeeOptions} showSearch optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="agency">
|
||||
<Select allowClear placeholder="하부점" className="w-130" options={agencyOptions} showSearch optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="reason">
|
||||
<Select allowClear placeholder="사유" className="w-130" options={reasonOptions} showSearch optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="q">
|
||||
<Input className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((v) => ({ ...v, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="stock-table-actions">
|
||||
<Tabs
|
||||
activeKey={String(filters.tab)}
|
||||
onChange={(tab) => setFilters((v) => ({ ...v, tab, page: 0 }))}
|
||||
items={TABS.map(([key, label]) => ({
|
||||
key,
|
||||
label: key === 'ALL'
|
||||
? `${label} ${query.data?.counts?.ALL ?? query.data?.total ?? 0}`
|
||||
: `${label} ${query.data?.counts?.[key] ?? 0}` }))}
|
||||
/>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1000 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `총 ${total}건`,
|
||||
onChange: (page) => setFilters((v) => ({ ...v, page: page - 1 })) }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '계약후정산 수정' : '계약후정산 등록'}
|
||||
open={modalOpen}
|
||||
onCancel={() => { setCreating(false); setEditing(null) }}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={520}
|
||||
className="balance-modal"
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '110px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
onFinish={(values) => save.mutate(values)}
|
||||
className="balance-edit-form"
|
||||
>
|
||||
<Form.Item name="targetType" label="후정산대상" rules={[{ required: true }]}>
|
||||
<Radio.Group
|
||||
options={options(['직원', '하부점'])}
|
||||
onChange={() => editForm.setFieldValue('targetName', undefined)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="targetName"
|
||||
label={targetType === '하부점' ? '하부점' : '직원'}
|
||||
rules={[{ required: true, message: '대상을 선택하세요' }]}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="- 선택 -"
|
||||
options={targetType === '하부점' ? agencyOptions : employeeOptions}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="baseDate" label="후정산일" rules={[{ required: true, message: '후정산일을 선택하세요' }]}>
|
||||
<DatePicker className="full-width" />
|
||||
</Form.Item>
|
||||
<Form.Item name="balanceType" label="구분" rules={[{ required: true }]}>
|
||||
<Radio.Group
|
||||
options={options(['정산차감', '정산추가'])}
|
||||
onChange={(e) => setAmountSign(String(e.target.value).includes('차감') ? '-' : '+')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="정산금액" required>
|
||||
<Space.Compact className="balance-amount-input">
|
||||
<Button
|
||||
className={amountSign === '-' ? 'balance-sign-minus' : ''}
|
||||
onClick={() => setAmountSign((s) => (s === '+' ? '-' : '+'))}
|
||||
>
|
||||
{amountSign}
|
||||
</Button>
|
||||
<Form.Item name="amountAbs" noStyle rules={[{ required: true, message: '정산금액을 입력하세요' }]}>
|
||||
<InputNumber controls={false} min={0} className="full-width" />
|
||||
</Form.Item>
|
||||
<Button disabled>원</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="사유" rules={[{ required: true, message: '사유를 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="비고">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="balance-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ContractListPage } from './ContractListShared'
|
||||
|
||||
export default function ContractHomePage() {
|
||||
return (
|
||||
<ContractListPage
|
||||
kind="HOME"
|
||||
title="홈렌탈접수"
|
||||
description="홈렌탈접수과 관련된 업무를 관리하실 수 있습니다."
|
||||
createLabel="홈렌탈접수 등록"
|
||||
writeType="home"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ContractListPage } from './ContractListShared'
|
||||
|
||||
export default function ContractInternetPage() {
|
||||
return (
|
||||
<ContractListPage
|
||||
kind="INTERNET"
|
||||
title="인터넷접수"
|
||||
description="인터넷접수과 관련된 업무를 관리하실 수 있습니다."
|
||||
createLabel="인터넷접수 등록"
|
||||
writeType="internet"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Button, Card, DatePicker, Form, Input, Select, Space } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
CloseOutlined, FileTextOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SolutionOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const options = (values: string[]) => values.map((value) => ({ value, label: value }))
|
||||
const money = (v: unknown) => (v == null || v === '' ? '-' : Number(v).toLocaleString())
|
||||
|
||||
type Contract = Record<string, unknown> & { id: number }
|
||||
type SearchResult = { total: number; list: Contract[] }
|
||||
|
||||
const initial = { page: 0, size: 15 } as Record<string, unknown>
|
||||
|
||||
export default function ContractListPage() {
|
||||
const [form] = Form.useForm()
|
||||
const navigate = useNavigate()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['contracts-search', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<SearchResult>>(`${apiPrefix()}/contracts/search`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setFilters({
|
||||
...initial,
|
||||
...values,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 15 })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ size: 15 })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '계약일', dataIndex: 'contractDate', width: 100, sorter: true },
|
||||
{ title: '상태', dataIndex: 'status', width: 80 },
|
||||
{ title: '고객명', dataIndex: 'customerName', width: 100, sorter: true },
|
||||
{ title: '연락처', dataIndex: 'phone', width: 120 },
|
||||
{ title: '상품', dataIndex: 'productName', width: 140, ellipsis: true },
|
||||
{ title: '통신사', dataIndex: 'telecom', width: 70 },
|
||||
{ title: '계약금액', dataIndex: 'amount', width: 110, align: 'right' as const, render: money },
|
||||
{ title: '월정액', dataIndex: 'monthlyFee', width: 100, align: 'right' as const, render: money },
|
||||
{ title: '담당자', dataIndex: 'employee', width: 90 },
|
||||
{
|
||||
title: '관리', width: 58, fixed: 'right' as const,
|
||||
render: (_: unknown, row: Contract) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<FileTextOutlined style={{ color: '#1677ff' }} />}
|
||||
onClick={() => navigate(`/care/contract/write?id=${row.id}`)}
|
||||
/>
|
||||
) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stock-page home-sale-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><SolutionOutlined style={{ marginRight: 8 }} />계약목록</h2>
|
||||
<p>홈상품·렌탈 등 계약 내역을 조회하고 관리합니다.</p>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/care/contract/write')}>계약 등록</Button>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form form={form} layout="inline" className="home-filter" initialValues={{ size: 15 }} onFinish={onSearch}>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={() => form.setFieldValue('dates', undefined)} />
|
||||
</div>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="status"><Select allowClear placeholder="-상태-" className="w-110" options={options(['대기', '진행', '완료', '해지'])} /></Form.Item>
|
||||
<Form.Item name="telecom"><Select allowClear placeholder="-통신사-" className="w-110" options={options(['SKT', 'KT', 'LGU+'])} /></Form.Item>
|
||||
<Form.Item name="customerName"><Input placeholder="고객명" className="w-130" allowClear /></Form.Item>
|
||||
<Form.Item name="productName"><Input placeholder="상품명" className="w-130" allowClear /></Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => { form.setFieldValue('size', size); setFilters((v) => ({ ...v, size, page: 0 })) }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `총 ${total}건`,
|
||||
onChange: (page) => setFilters((v) => ({ ...v, page: page - 1 })) }}
|
||||
locale={{ emptyText: query.isError ? '데이터를 불러오지 못했습니다.' : '등록된 계약이 없습니다.' }}
|
||||
onChange={(_, __, sorter) => {
|
||||
const order = Array.isArray(sorter) ? sorter[0] : sorter
|
||||
if (order?.field) setFilters((v) => ({ ...v, sort: `${String(order.field)},${order.order === 'ascend' ? 'asc' : 'desc'}` }))
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
Button, Card, Checkbox, DatePicker, Form, Input, Select, Space, Tabs, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
CloseOutlined, DownloadOutlined, EditOutlined, FormOutlined, PlusSquareOutlined,
|
||||
ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { money, type Row } from './homesShared'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const STATUS_TABS = [
|
||||
{ key: 'ALL', label: '전체' },
|
||||
{ key: '접수', label: '접수' },
|
||||
{ key: '신청', label: '신청' },
|
||||
{ key: '완료', label: '완료' },
|
||||
{ key: '철회', label: '철회' },
|
||||
]
|
||||
const SEARCH_TYPES = ['고객명', '거래처', '직원', '연락처', '가입상품']
|
||||
|
||||
type ContractPage = {
|
||||
total: number
|
||||
list: Row[]
|
||||
counts?: Record<string, number>
|
||||
agencies?: string[]
|
||||
employees?: string[]
|
||||
}
|
||||
|
||||
function maskName(v: unknown) {
|
||||
const s = String(v ?? '')
|
||||
if (s.length <= 1) return s
|
||||
if (s.length === 2) return `${s[0]}*`
|
||||
return `${s[0]}${'*'.repeat(s.length - 2)}${s[s.length - 1]}`
|
||||
}
|
||||
|
||||
function maskPhone(v: unknown) {
|
||||
const digits = String(v ?? '').replace(/\D/g, '')
|
||||
if (digits.length < 7) return String(v ?? '')
|
||||
return `${digits.slice(0, 3)}-****-${digits.slice(-4)}`
|
||||
}
|
||||
|
||||
function moneyMargin(v: unknown) {
|
||||
if (v == null || v === '') return '-'
|
||||
const n = Number(v)
|
||||
return <span className={n < 0 ? 'contract-margin-neg' : 'contract-margin'}>{n.toLocaleString()}</span>
|
||||
}
|
||||
|
||||
function statusClass(v: string) {
|
||||
if (v === '철회') return 'contract-status-cancel'
|
||||
if (v === '신청') return 'contract-status-apply'
|
||||
if (v === '접수') return 'contract-status-recv'
|
||||
return undefined
|
||||
}
|
||||
|
||||
type Props = {
|
||||
kind: 'INTERNET' | 'HOME'
|
||||
title: string
|
||||
description: string
|
||||
createLabel: string
|
||||
writeType: 'internet' | 'home'
|
||||
}
|
||||
|
||||
export function ContractListPage({ kind, title, description, createLabel, writeType }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [statusForm] = Form.useForm()
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [statusOpen, setStatusOpen] = useState(false)
|
||||
const [sumOpen, setSumOpen] = useState(false)
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({
|
||||
kind,
|
||||
status: 'ALL',
|
||||
page: 0,
|
||||
size: 10 })
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes/contracts', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<ContractPage>>(`${apiPrefix()}/homes/contracts`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/contracts/delete`, { ids })
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
setSelected([])
|
||||
qc.invalidateQueries({ queryKey: ['homes/contracts'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const changeStatus = useMutation({
|
||||
mutationFn: async (values: { status: string }) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/contracts/status`, { ids: selected, status: values.status })
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('상태를 변경했습니다.')
|
||||
setStatusOpen(false)
|
||||
setSelected([])
|
||||
qc.invalidateQueries({ queryKey: ['homes/contracts'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
const clearDates = () => form.setFieldValue('dates', undefined)
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setSelected([])
|
||||
setFilters({
|
||||
kind,
|
||||
status: filters.status || 'ALL',
|
||||
agency: values.agency || undefined,
|
||||
employee: values.employee || undefined,
|
||||
searchType: values.searchType || '고객명',
|
||||
q: values.q || undefined,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 10 })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ searchType: '고객명', size: 10 })
|
||||
setSelected([])
|
||||
setFilters({ kind, status: 'ALL', page: 0, size: 10 })
|
||||
}
|
||||
|
||||
const download = () => {
|
||||
const rows = query.data?.list || []
|
||||
if (!rows.length) {
|
||||
message.warning('다운로드할 목록이 없습니다.')
|
||||
return
|
||||
}
|
||||
const header = ['상태', '계약일', '고객명', '연락처', '가입상품', '정책합산', '정산금액', '판매마진', '거래처', '직원']
|
||||
const lines = rows.map((r) =>
|
||||
[r.status, r.contractDate, r.customerName, r.phone, r.products, r.policySum, r.settleAmount, r.margin, r.agency, r.employee]
|
||||
.map((v) => `"${String(v ?? '').replace(/"/g, '""')}"`)
|
||||
.join(','),
|
||||
)
|
||||
const blob = new Blob(['\uFEFF' + [header.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${title}_${dayjs().format('YYYYMMDD')}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const list = query.data?.list || []
|
||||
const counts = query.data?.counts
|
||||
const agencyOptions = useMemo(
|
||||
() => (query.data?.agencies || []).map((v) => ({ value: v, label: v })),
|
||||
[query.data?.agencies],
|
||||
)
|
||||
const employeeOptions = useMemo(
|
||||
() => (query.data?.employees || []).map((v) => ({ value: v, label: v })),
|
||||
[query.data?.employees],
|
||||
)
|
||||
|
||||
const selectedSums = useMemo(() => {
|
||||
const rows = selected.length ? list.filter((r) => selected.includes(r.id)) : list
|
||||
return {
|
||||
settle: rows.reduce((s, r) => s + Number(r.settleAmount || 0), 0),
|
||||
margin: rows.reduce((s, r) => s + Number(r.margin || 0), 0),
|
||||
count: rows.length }
|
||||
}, [list, selected])
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: (
|
||||
<Checkbox
|
||||
checked={list.length > 0 && selected.length === list.length}
|
||||
indeterminate={selected.length > 0 && selected.length < list.length}
|
||||
onChange={(e) => setSelected(e.target.checked ? list.map((r) => r.id) : [])}
|
||||
/>
|
||||
),
|
||||
width: 48,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<Checkbox
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={(e) => {
|
||||
setSelected((prev) => (e.target.checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)))
|
||||
}}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '상태',
|
||||
dataIndex: 'status',
|
||||
width: 70,
|
||||
render: (v: string) => <span className={statusClass(v)}>{v}</span> },
|
||||
{ title: '계약일', dataIndex: 'contractDate', width: 100 },
|
||||
{ title: '고객명', dataIndex: 'customerName', width: 90, render: maskName },
|
||||
{ title: '연락처', dataIndex: 'phone', width: 130, render: maskPhone },
|
||||
{ title: '가입상품', dataIndex: 'products', width: 180, ellipsis: true },
|
||||
{ title: '정책합산', dataIndex: 'policySum', width: 110, align: 'right' as const, render: money },
|
||||
{ title: '정산금액', dataIndex: 'settleAmount', width: 110, align: 'right' as const, render: money },
|
||||
{ title: '판매마진', dataIndex: 'margin', width: 110, align: 'right' as const, render: moneyMargin },
|
||||
{ title: '거래처', dataIndex: 'agency', width: 120, ellipsis: true },
|
||||
{ title: '직원(하부점)', dataIndex: 'employee', width: 110 },
|
||||
{
|
||||
title: '관리',
|
||||
width: 64,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
className="product-manage-btn"
|
||||
icon={<FormOutlined />}
|
||||
onClick={() => navigate(`/care/contract/write?type=${writeType}&id=${row.id}`)}
|
||||
/>
|
||||
) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stock-page contract-internet-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<EditOutlined className="product-title-icon" />
|
||||
{title}
|
||||
</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('계약을 선택하세요.')
|
||||
return
|
||||
}
|
||||
statusForm.setFieldsValue({ status: '완료' })
|
||||
setStatusOpen(true)
|
||||
}}
|
||||
>
|
||||
상태
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
loading={remove.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('삭제할 계약을 선택하세요.')
|
||||
return
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '계약 삭제',
|
||||
content: `선택한 ${selected.length}건을 삭제하시겠습니까?`,
|
||||
onOk: () => remove.mutateAsync(selected) })
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button icon={<SearchOutlined />} onClick={() => setSumOpen(true)}>정산/마진합계</Button>
|
||||
<Button type="primary" icon={<PlusSquareOutlined />} onClick={() => navigate(`/care/contract/write?type=${writeType}`)}>
|
||||
{createLabel}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form form={form} layout="inline" className="home-filter" initialValues={{ searchType: '고객명', size: 10 }} onFinish={onSearch}>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={clearDates} />
|
||||
</div>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="agency">
|
||||
<Select allowClear placeholder="거래처" className="w-130" options={agencyOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="employee">
|
||||
<Select allowClear placeholder="직원" className="w-130" options={employeeOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-110" options={SEARCH_TYPES.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="q">
|
||||
<Input allowClear placeholder="검색어" className="w-150" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((prev) => ({ ...prev, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card product-internet-card" size="small">
|
||||
<div className="product-internet-tabs">
|
||||
<Tabs
|
||||
activeKey={String(filters.status || 'ALL')}
|
||||
onChange={(key) => {
|
||||
setSelected([])
|
||||
setFilters((prev) => ({ ...prev, status: key, page: 0 }))
|
||||
}}
|
||||
items={STATUS_TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: counts?.[t.key] != null ? `${t.label} ${counts[t.key]}` : t.label }))}
|
||||
/>
|
||||
<div className="product-internet-settings">
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1300 }}
|
||||
pagination={{
|
||||
current: Number(filters.page || 0) + 1,
|
||||
pageSize: Number(filters.size || 10),
|
||||
total: query.data?.total || 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => {
|
||||
setSelected([])
|
||||
setFilters((prev) => ({ ...prev, page: page - 1 }))
|
||||
} }}
|
||||
locale={{ emptyText: '등록되어 있는 자료가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal title="상태 변경" open={statusOpen} onCancel={() => setStatusOpen(false)} footer={null} destroyOnHidden width={360}>
|
||||
<Form form={statusForm} layout="vertical" onFinish={(v) => changeStatus.mutate(v)}>
|
||||
<Form.Item name="status" label="상태" rules={[{ required: true }]}>
|
||||
<Select options={['접수', '신청', '완료', '철회'].map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<div className="product-modal-footer-right">
|
||||
<Button type="primary" htmlType="submit" loading={changeStatus.isPending}>변경합니다</Button>
|
||||
<Button onClick={() => setStatusOpen(false)}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="정산/마진합계" open={sumOpen} onCancel={() => setSumOpen(false)} footer={null} destroyOnHidden width={400}>
|
||||
<Typography.Paragraph type="secondary">
|
||||
{selected.length ? `선택 ${selectedSums.count}건` : `현재 목록 ${selectedSums.count}건`} 기준입니다.
|
||||
</Typography.Paragraph>
|
||||
<div className="contract-sum-box">
|
||||
<div><span>정산금액 합계</span><b>{selectedSums.settle.toLocaleString()}원</b></div>
|
||||
<div>
|
||||
<span>판매마진 합계</span>
|
||||
<b className={selectedSums.margin < 0 ? 'contract-margin-neg' : 'contract-margin'}>
|
||||
{selectedSums.margin.toLocaleString()}원
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
<div className="product-modal-footer-right" style={{ marginTop: 16 }}>
|
||||
<Button onClick={() => setSumOpen(false)}>닫기</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { HomesLogListPage } from './HomesLogListPage'
|
||||
|
||||
const ACTIONS = [
|
||||
'인터넷접수-등록',
|
||||
'인터넷접수-수정',
|
||||
'인터넷접수-삭제',
|
||||
'인터넷접수-상태',
|
||||
'홈렌탈접수-등록',
|
||||
'홈렌탈접수-수정',
|
||||
'홈렌탈접수-삭제',
|
||||
'홈렌탈접수-상태',
|
||||
]
|
||||
|
||||
export default function ContractLogPage() {
|
||||
return (
|
||||
<HomesLogListPage
|
||||
domain="CONTRACT"
|
||||
title="계약이력"
|
||||
description="계약관리의 변동이력을 확인하실 수 있습니다."
|
||||
defaultActions={ACTIONS}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
Button, Card, Checkbox, Col, DatePicker, Form, Input, InputNumber, Radio, Row, Select, Space, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { EditOutlined, PlusOutlined, SettingOutlined, UnorderedListOutlined } from '@ant-design/icons'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { money, options, type PageResult, type Row as DataRow } from './homesShared'
|
||||
|
||||
type ProductLine = {
|
||||
key: string
|
||||
productId?: number
|
||||
name: string
|
||||
months?: number
|
||||
policyAmount?: number
|
||||
}
|
||||
|
||||
function splitPhone(phone?: string) {
|
||||
const d = String(phone ?? '').replace(/\D/g, '')
|
||||
return {
|
||||
phone1: d.slice(0, 3) || '010',
|
||||
phone2: d.slice(3, 7),
|
||||
phone3: d.slice(7, 11) }
|
||||
}
|
||||
|
||||
function joinPhone(v: Record<string, unknown>, prefix = 'phone') {
|
||||
const a = String(v[`${prefix}1`] ?? '').trim()
|
||||
const b = String(v[`${prefix}2`] ?? '').trim()
|
||||
const c = String(v[`${prefix}3`] ?? '').trim()
|
||||
if (!a && !b && !c) return ''
|
||||
return `${a}-${b}-${c}`
|
||||
}
|
||||
|
||||
export default function ContractWritePage() {
|
||||
const [params] = useSearchParams()
|
||||
const type = params.get('type') === 'home' ? 'HOME' : 'INTERNET'
|
||||
const id = params.get('id')
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [productForm] = Form.useForm()
|
||||
const [lines, setLines] = useState<ProductLine[]>([])
|
||||
const [productOpen, setProductOpen] = useState(false)
|
||||
const back = type === 'HOME' ? '/care/contract/home' : '/care/contract/internet'
|
||||
const title = type === 'HOME' ? '홈렌탈 접수등록' : '인터넷 접수등록'
|
||||
const guide = type === 'HOME'
|
||||
? '홈렌탈상품의 계약정보를 아래의 양식 순서대로 입력해주세요. (필수 표시는 필수항목입니다.)'
|
||||
: '인터넷상품의 계약정보를 아래의 양식 순서대로 입력해주세요. (필수 표시는 필수항목입니다.)'
|
||||
const sameContact = Form.useWatch('sameContact', form)
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: ['homes-contract', id],
|
||||
enabled: !!id,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>>>(`${apiPrefix()}/homes/contracts/${id}`)
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const agencies = useQuery({
|
||||
queryKey: ['homes/agencies-options'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<PageResult>>(`${apiPrefix()}/homes/agencies`, { params: { page: 0, size: 200 } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const products = useQuery({
|
||||
queryKey: ['homes/products-for-contract', type],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<PageResult>>(`${apiPrefix()}/homes/products`, {
|
||||
params: { kind: type, category: 'ALL', page: 0, size: 200 } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail.data) return
|
||||
const d = detail.data
|
||||
const phone = splitPhone(String(d.phone ?? ''))
|
||||
const installPhone = splitPhone(String(d.installPhone ?? d.phone ?? ''))
|
||||
const birth = String(d.birthDate ?? '').split('-')
|
||||
form.setFieldsValue({
|
||||
...d,
|
||||
...phone,
|
||||
installPhone1: installPhone.phone1,
|
||||
installPhone2: installPhone.phone2,
|
||||
installPhone3: installPhone.phone3,
|
||||
birthYear: birth[0] || undefined,
|
||||
birthMonth: birth[1] || undefined,
|
||||
birthDay: birth[2] || undefined,
|
||||
contractDate: d.contractDate ? dayjs(String(d.contractDate)) : dayjs(),
|
||||
installDate: d.installDate ? dayjs(String(d.installDate)) : undefined,
|
||||
customerGrade: d.customerGrade || '분류없음',
|
||||
sameContact: d.sameContact ?? false })
|
||||
const names = String(d.products ?? '').split(',').map((v) => v.trim()).filter(Boolean)
|
||||
setLines(names.map((name, i) => ({
|
||||
key: `e-${i}`,
|
||||
name,
|
||||
months: Number(d.months || 0) || undefined,
|
||||
policyAmount: names.length ? Number(d.policySum || 0) / names.length : undefined })))
|
||||
}, [detail.data, form])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sameContact) return
|
||||
const values = form.getFieldsValue()
|
||||
form.setFieldsValue({
|
||||
installPhone1: values.phone1,
|
||||
installPhone2: values.phone2,
|
||||
installPhone3: values.phone3,
|
||||
installZip: values.zipcode,
|
||||
installAddress: values.address,
|
||||
installAddressDetail: values.addressDetail })
|
||||
}, [sameContact, form])
|
||||
|
||||
const policySum = useMemo(
|
||||
() => lines.reduce((s, l) => s + Number(l.policyAmount || 0), 0),
|
||||
[lines],
|
||||
)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
if (!lines.length) throw new Error('계약 상품을 추가하세요.')
|
||||
const birthParts = [values.birthYear, values.birthMonth, values.birthDay].filter(Boolean)
|
||||
const payload: Record<string, unknown> = {
|
||||
...values,
|
||||
kind: type,
|
||||
status: values.status || (id ? detail.data?.status : '접수') || '접수',
|
||||
phone: joinPhone(values, 'phone'),
|
||||
installPhone: joinPhone(values, 'installPhone'),
|
||||
birthDate: birthParts.length === 3
|
||||
? `${values.birthYear}-${String(values.birthMonth).padStart(2, '0')}-${String(values.birthDay).padStart(2, '0')}`
|
||||
: null,
|
||||
contractDate: values.contractDate ? dayjs(values.contractDate as string).format('YYYY-MM-DD') : null,
|
||||
installDate: values.installDate ? dayjs(values.installDate as string).format('YYYY-MM-DD') : null,
|
||||
products: lines.map((l) => l.name).join(', '),
|
||||
months: lines[0]?.months ?? null,
|
||||
policySum,
|
||||
settleAmount: values.settleAmount ?? policySum,
|
||||
margin: values.margin ?? 0 }
|
||||
;['phone1', 'phone2', 'phone3', 'installPhone1', 'installPhone2', 'installPhone3', 'birthYear', 'birthMonth', 'birthDay'].forEach((k) => {
|
||||
delete payload[k]
|
||||
})
|
||||
if (id) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/contracts/${id}`, payload)
|
||||
return unwrap(data)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/contracts`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(id ? '수정했습니다.' : '등록했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes/contracts'] })
|
||||
navigate(back)
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const agencyOptions = useMemo(
|
||||
() => (agencies.data?.list || []).map((a) => ({ value: String(a.name), label: String(a.name) })),
|
||||
[agencies.data],
|
||||
)
|
||||
|
||||
const productOptions = useMemo(
|
||||
() => (products.data?.list || []).map((p: DataRow) => ({
|
||||
value: p.id,
|
||||
label: `${p.company ? `[${p.company}] ` : ''}${p.name}`,
|
||||
raw: p })),
|
||||
[products.data],
|
||||
)
|
||||
|
||||
const addProduct = (values: { productId: number }) => {
|
||||
const found = productOptions.find((o) => o.value === values.productId)
|
||||
if (!found) return
|
||||
const p = found.raw
|
||||
setLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: `p-${p.id}-${Date.now()}`,
|
||||
productId: Number(p.id),
|
||||
name: String(p.name),
|
||||
months: Number(p.months || 0) || undefined,
|
||||
policyAmount: Number(p.policyAmount || 0) },
|
||||
])
|
||||
setProductOpen(false)
|
||||
productForm.resetFields()
|
||||
}
|
||||
|
||||
const searchAddress = (target: 'customer' | 'install') => {
|
||||
message.info('주소검색(데모) — 직접 입력해 주세요.')
|
||||
if (target === 'customer') {
|
||||
form.setFieldsValue({ zipcode: form.getFieldValue('zipcode') || '06236', address: form.getFieldValue('address') || '서울특별시 강남구' })
|
||||
} else {
|
||||
form.setFieldsValue({ installZip: form.getFieldValue('installZip') || '06236', installAddress: form.getFieldValue('installAddress') || '서울특별시 강남구' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stock-page contract-write-page">
|
||||
<div className="contract-write-head">
|
||||
<div className="stock-title contract-write-title">
|
||||
<h2>
|
||||
<EditOutlined className="product-title-icon" />
|
||||
{title}
|
||||
</h2>
|
||||
<div className="contract-write-title-sub">
|
||||
<p>{guide}</p>
|
||||
<Button icon={<UnorderedListOutlined />} onClick={() => navigate(back)}>
|
||||
목록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="contract-write-head-spacer" aria-hidden />
|
||||
</div>
|
||||
|
||||
<div className="contract-write-layout">
|
||||
<Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '110px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
colon={false}
|
||||
className="contract-write-form"
|
||||
initialValues={{
|
||||
customerType: '개인',
|
||||
employee: '홍길동',
|
||||
customerGrade: '분류없음',
|
||||
phone1: '010',
|
||||
installPhone1: '010',
|
||||
payMethod: '자동이체',
|
||||
contractDate: dayjs(),
|
||||
sameContact: false,
|
||||
settleAmount: 0,
|
||||
margin: 0,
|
||||
nationality: '내국인' }}
|
||||
onFinish={(v) => save.mutate(v)}
|
||||
>
|
||||
<Card className="open-section" size="small" title="가입정보">
|
||||
<Form.Item name="customerType" label="고객구분" rules={[{ required: true }]}>
|
||||
<Radio.Group options={[{ value: '개인', label: '개인' }, { value: '사업자', label: '사업자' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="employee" label="접수직원">
|
||||
<Select options={options(['홍길동', '홍총괄', '김대표', 'demo'])} />
|
||||
</Form.Item>
|
||||
<Form.Item label="고객명" required>
|
||||
<Space.Compact className="full-width">
|
||||
<Form.Item name="customerName" noStyle rules={[{ required: true, message: '고객명을 입력하세요' }]}>
|
||||
<Input style={{ width: '50%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="customerGrade" noStyle>
|
||||
<Select style={{ width: '35%' }} options={options(['분류없음', '단골', '단골1', '단골2', '일반'])} />
|
||||
</Form.Item>
|
||||
<Button icon={<SettingOutlined />} onClick={() => message.info('고객분류 설정(데모)')} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="고객연락처" required>
|
||||
<Space.Compact>
|
||||
<Form.Item name="phone1" noStyle rules={[{ required: true }]}>
|
||||
<Select className="w-100" options={options(['010', '011', '016', '017', '018', '019'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone2" noStyle rules={[{ required: true, message: '연락처 필수' }]}>
|
||||
<Input className="w-100" maxLength={4} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone3" noStyle rules={[{ required: true, message: '연락처 필수' }]}>
|
||||
<Input className="w-100" maxLength={4} />
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="법정생일">
|
||||
<Space.Compact>
|
||||
<Form.Item name="birthYear" noStyle><Input className="w-100" placeholder="년" maxLength={4} /></Form.Item>
|
||||
<Form.Item name="birthMonth" noStyle><Input className="w-100" placeholder="월" maxLength={2} /></Form.Item>
|
||||
<Form.Item name="birthDay" noStyle><Input className="w-100" placeholder="일" maxLength={2} /></Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="성별/국적">
|
||||
<Space>
|
||||
<Form.Item name="gender" noStyle>
|
||||
<Select allowClear placeholder="- 성별 -" className="w-130" options={options(['남', '여'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="nationality" noStyle>
|
||||
<Select allowClear placeholder="- 국적 -" className="w-130" options={options(['내국인', '외국인'])} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label="이메일"><Input /></Form.Item>
|
||||
<Form.Item label="고객주소">
|
||||
<Space direction="vertical" className="full-width" size={8}>
|
||||
<Space.Compact className="full-width">
|
||||
<Button onClick={() => searchAddress('customer')}>주소검색</Button>
|
||||
<Form.Item name="zipcode" noStyle><Input style={{ width: 100 }} placeholder="우편번호" /></Form.Item>
|
||||
<Form.Item name="address" noStyle><Input placeholder="주소" /></Form.Item>
|
||||
</Space.Compact>
|
||||
<Form.Item name="addressDetail" noStyle><Input placeholder="상세주소" /></Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="sameContact" valuePropName="checked" label=" ">
|
||||
<Checkbox>상기 고객연락처와 주소가 동일합니다.</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item label="설치연락처">
|
||||
<Space.Compact>
|
||||
<Form.Item name="installPhone1" noStyle>
|
||||
<Select className="w-100" options={options(['010', '011', '016', '017', '018', '019'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="installPhone2" noStyle><Input className="w-100" maxLength={4} /></Form.Item>
|
||||
<Form.Item name="installPhone3" noStyle><Input className="w-100" maxLength={4} /></Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="설치주소">
|
||||
<Space direction="vertical" className="full-width" size={8}>
|
||||
<Space.Compact className="full-width">
|
||||
<Button onClick={() => searchAddress('install')}>주소검색</Button>
|
||||
<Form.Item name="installZip" noStyle><Input style={{ width: 100 }} placeholder="우편번호" /></Form.Item>
|
||||
<Form.Item name="installAddress" noStyle><Input placeholder="주소" /></Form.Item>
|
||||
</Space.Compact>
|
||||
<Form.Item name="installAddressDetail" noStyle><Input placeholder="상세주소" /></Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Card className="open-section" size="small" title="계약정보">
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="contractDate" label="계약일" rules={[{ required: true, message: '계약일을 선택하세요' }]}>
|
||||
<DatePicker className="full-width" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="installDate" label="설치완료일">
|
||||
<DatePicker className="full-width" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="agency" label="거래처">
|
||||
<Select allowClear placeholder="- 선택 -" options={agencyOptions.length ? agencyOptions : options(
|
||||
type === 'HOME'
|
||||
? ['(주)코웨이', '청호나이스', 'SK매직', 'LG전자', '늘푸른통신']
|
||||
: ['늘푸른통신', '대박통신', '스마트홈', 'SK브로드밴드', '(주)코웨이'],
|
||||
)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="payMethod" label="결제방법">
|
||||
<Radio.Group options={[
|
||||
{ value: '자동이체', label: '자동이체' },
|
||||
{ value: '카드', label: '카드' },
|
||||
{ value: '현금', label: '현금' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="feeReduction" label="요금감면">
|
||||
<Select allowClear placeholder="- 선택 -" options={options(['해당없음', '기초생활수급자', '장애인', '국가유공자', '차상위계층'])} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="invoiceType" label="청구서">
|
||||
<Select allowClear placeholder="- 선택 -" options={options(['이메일', '문자', '우편', '앱'])} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="open-section"
|
||||
size="small"
|
||||
title="상품정보"
|
||||
extra={<Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => setProductOpen(true)}>상품추가</Button>}
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<p className="contract-product-empty">아래 상품추가 버튼을 클릭하여 계약 상품을 추가하세요.</p>
|
||||
) : (
|
||||
<CareTable
|
||||
rowKey="key"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={lines}
|
||||
columns={[
|
||||
{ title: '상품명', dataIndex: 'name' },
|
||||
{
|
||||
title: '약정',
|
||||
dataIndex: 'months',
|
||||
width: 100,
|
||||
render: (v: number) => (v ? `${v}개월` : '-') },
|
||||
{
|
||||
title: '정책금액',
|
||||
dataIndex: 'policyAmount',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: money },
|
||||
{
|
||||
title: '관리',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: ProductLine) => (
|
||||
<Button type="link" danger size="small" onClick={() => setLines((prev) => prev.filter((l) => l.key !== row.key))}>
|
||||
삭제
|
||||
</Button>
|
||||
) },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<div className="contract-product-sum">
|
||||
<span>정책합산</span>
|
||||
<b>{policySum.toLocaleString()}원</b>
|
||||
</div>
|
||||
<Row gutter={16} style={{ marginTop: 12 }}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="settleAmount" label="정산금액">
|
||||
<InputNumber className="full-width" controls={false} addonAfter="원" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="margin" label="판매마진">
|
||||
<InputNumber className="full-width" controls={false} addonAfter="원" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="memo" label="비고">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<div className="open-actions">
|
||||
<Button onClick={() => navigate(back)}>목록</Button>
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>{id ? '수정합니다' : '등록'}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<aside className="contract-pending-panel">
|
||||
<div className="contract-pending-head">
|
||||
<span>일정관리 등록</span>
|
||||
<Button size="small" className="contract-pending-btn" onClick={() => message.info('일정등록(데모)')}>일정등록</Button>
|
||||
</div>
|
||||
<div className="contract-pending-body">
|
||||
등록하실 일정관리가 없습니다.<br />(미수금, 할인, 미비서류, 정책약속 등)
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="상품추가"
|
||||
open={productOpen}
|
||||
onCancel={() => setProductOpen(false)}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={420}
|
||||
>
|
||||
<Form form={productForm} layout="vertical" onFinish={addProduct}>
|
||||
<Form.Item name="productId" label="상품" rules={[{ required: true, message: '상품을 선택하세요' }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="- 선택 -"
|
||||
options={productOptions.map((o) => ({ value: o.value, label: o.label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div className="product-modal-footer-right">
|
||||
<Button type="primary" htmlType="submit">추가</Button>
|
||||
<Button onClick={() => setProductOpen(false)}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { EditOutlined, QuestionCircleOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type InquiryRow = {
|
||||
id: number
|
||||
no?: number
|
||||
title?: string
|
||||
answer?: string
|
||||
status?: string
|
||||
authorName?: string
|
||||
createdDate?: string
|
||||
content?: string
|
||||
reply?: string
|
||||
}
|
||||
|
||||
type InquiryData = {
|
||||
total: number
|
||||
list: InquiryRow[]
|
||||
}
|
||||
|
||||
export default function CsInquiryPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [detail, setDetail] = useState<InquiryRow | null>(null)
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({
|
||||
searchType: '제목',
|
||||
page: 0,
|
||||
size: 15,
|
||||
sort: 'createdAt,desc' })
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['cs-inquiries', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<InquiryData>>(`${apiPrefix()}/cs/search`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['cs-inquiries'] })
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/cs`, values)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('사용문의를 등록했습니다.')
|
||||
setOpen(false)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const openDetail = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.get<ApiResponse<InquiryRow>>(`${apiPrefix()}/cs/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (row) => setDetail(row),
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
searchType: values.searchType || '제목',
|
||||
keyword: values.keyword,
|
||||
page: 0,
|
||||
size: Number(values.size ?? prev.size ?? 15) }))
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.setFieldsValue({ searchType: '제목', keyword: undefined, size: 15 })
|
||||
setFilters({ searchType: '제목', page: 0, size: 15, sort: 'createdAt,desc' })
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '번호',
|
||||
dataIndex: 'no',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '제목',
|
||||
dataIndex: 'title',
|
||||
ellipsis: true,
|
||||
render: (v: string, row: InquiryRow) => (
|
||||
<Button type="link" className="cs-notice-title" onClick={() => openDetail.mutate(row.id)}>
|
||||
{v}
|
||||
</Button>
|
||||
) },
|
||||
{
|
||||
title: '답변',
|
||||
dataIndex: 'answer',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '작성자',
|
||||
dataIndex: 'authorName',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '등록일',
|
||||
dataIndex: 'createdDate',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
], [])
|
||||
|
||||
return (
|
||||
<div className="stock-page cs-notice-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><QuestionCircleOutlined style={{ marginRight: 8 }} />사용문의</h2>
|
||||
<p>궁금하신 사항이나 사용상 문제가 있으시면 언제든지 내용을 남겨주세요. 담당자 확인 후 답변드리겠습니다.</p>
|
||||
</div>
|
||||
<Button type="primary" icon={<EditOutlined />} onClick={() => { editForm.resetFields(); setOpen(true) }}>
|
||||
사용문의 등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="farebox-filter"
|
||||
initialValues={{ searchType: '제목', size: 15 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="farebox-filter-row">
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-110" options={['제목', '작성자', '내용', '답변'].map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-230" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button onClick={onReset}>초기화</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="size" className="farebox-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => setFilters((prev) => ({ ...prev, size, page: 0 }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="farebox-table-toolbar">
|
||||
<Typography.Text>목록 {query.data?.total ?? 0}</Typography.Text>
|
||||
</div>
|
||||
<CareTable
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
rowKey="id"
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
current: Number(filters.page ?? 0) + 1,
|
||||
pageSize: Number(filters.size ?? 15),
|
||||
total: query.data?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
onChange={(_p, _f, sorter) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter
|
||||
if (!s?.field) return
|
||||
const fieldMap: Record<string, string> = {
|
||||
no: 'id',
|
||||
answer: 'answer',
|
||||
authorName: 'authorName',
|
||||
createdDate: 'createdAt',
|
||||
title: 'title' }
|
||||
const field = fieldMap[String(s.field)] || String(s.field)
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort: `${field},${s.order === 'ascend' ? 'asc' : 'desc'}`,
|
||||
page: 0 }))
|
||||
}}
|
||||
locale={{ emptyText: '등록된 사용문의가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="사용문의 등록"
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => createMut.mutate(values)}
|
||||
>
|
||||
<Form.Item name="title" label="제목" rules={[{ required: true, message: '제목을 입력하세요.' }]}>
|
||||
<Input maxLength={200} />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="내용" rules={[{ required: true, message: '내용을 입력하세요.' }]}>
|
||||
<Input.TextArea rows={8} />
|
||||
</Form.Item>
|
||||
<div className="bbs-write-actions">
|
||||
<Button type="primary" htmlType="submit" loading={createMut.isPending}>등록</Button>
|
||||
<Button onClick={() => setOpen(false)}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={detail?.title || '사용문의'}
|
||||
open={!!detail}
|
||||
onCancel={() => setDetail(null)}
|
||||
footer={<Button onClick={() => setDetail(null)}>닫기</Button>}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="cs-notice-meta">
|
||||
<span>작성자 {detail?.authorName || '-'}</span>
|
||||
<span>등록일 {detail?.createdDate || '-'}</span>
|
||||
<span>{detail?.answer || detail?.status || '-'}</span>
|
||||
</div>
|
||||
<div className="cs-inquiry-section">
|
||||
<Typography.Text strong>문의내용</Typography.Text>
|
||||
<div className="cs-notice-body" style={{ whiteSpace: 'pre-wrap' }}>{detail?.content || '-'}</div>
|
||||
</div>
|
||||
<div className="cs-inquiry-section">
|
||||
<Typography.Text strong>답변</Typography.Text>
|
||||
<div className="cs-notice-body" style={{ whiteSpace: 'pre-wrap' }}>{detail?.reply || '답변 대기 중입니다.'}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
QuestionCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type NoticeRow = {
|
||||
id: number
|
||||
no?: number
|
||||
title?: string
|
||||
views?: number
|
||||
createdDate?: string
|
||||
authorName?: string
|
||||
contents?: string
|
||||
}
|
||||
|
||||
type NoticeResult = {
|
||||
total: number
|
||||
list: NoticeRow[]
|
||||
}
|
||||
|
||||
const initial = { page: 0, size: 10, searchType: '제목', sort: 'id,desc' } as Record<string, unknown>
|
||||
|
||||
export default function CsNoticePage() {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
const [detail, setDetail] = useState<NoticeRow | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-notices', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<NoticeResult>>(`${apiPrefix()}/homes/notices`, {
|
||||
params: {
|
||||
...filters,
|
||||
q: filters.keyword || undefined } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const openDetail = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.get<ApiResponse<NoticeRow>>(`${apiPrefix()}/homes/notices/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (row) => {
|
||||
setDetail(row)
|
||||
qc.invalidateQueries({ queryKey: ['homes-notices'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
setFilters({
|
||||
...initial,
|
||||
searchType: values.searchType || '제목',
|
||||
keyword: values.keyword || undefined,
|
||||
page: 0,
|
||||
size: Number(values.size ?? filters.size ?? 10),
|
||||
sort: filters.sort })
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ searchType: '제목', size: 10 })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '번호',
|
||||
dataIndex: 'no',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '제목',
|
||||
dataIndex: 'title',
|
||||
ellipsis: true,
|
||||
render: (v: string, row: NoticeRow) => (
|
||||
<Button type="link" className="cs-notice-title" onClick={() => openDetail.mutate(row.id)}>
|
||||
{v}
|
||||
</Button>
|
||||
) },
|
||||
{
|
||||
title: '조회',
|
||||
dataIndex: 'views',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
sorter: true,
|
||||
render: (v?: number) => (v == null ? 0 : Number(v).toLocaleString()) },
|
||||
{
|
||||
title: '등록일',
|
||||
dataIndex: 'createdDate',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="stock-page cs-notice-page homes-cs-notice-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<QuestionCircleOutlined className="product-title-icon" />
|
||||
공지사항
|
||||
</h2>
|
||||
<p>프로그램 사용에 대한 공지사항이나 업데이트를 알려드립니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="farebox-filter"
|
||||
initialValues={{ searchType: '제목', size: 10 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="farebox-filter-row">
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-110" options={['제목', '내용', '작성자'].map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-230" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
|
||||
검색
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={onReset}>
|
||||
초기화
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="size" className="farebox-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((prev) => ({ ...prev, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="farebox-table-toolbar">
|
||||
<Typography.Text>
|
||||
목록 <b>{query.data?.total ?? 0}</b>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<CareTable
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
rowKey="id"
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
bordered
|
||||
pagination={{
|
||||
current: Number(filters.page ?? 0) + 1,
|
||||
pageSize: Number(filters.size ?? 10),
|
||||
total: query.data?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
onChange={(_p, _f, sorter) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter
|
||||
if (!s?.field || !s.order) return
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort: `${String(s.field)},${s.order === 'ascend' ? 'asc' : 'desc'}` }))
|
||||
}}
|
||||
locale={{ emptyText: '등록된 공지사항이 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="공지사항"
|
||||
open={Boolean(detail)}
|
||||
onCancel={() => setDetail(null)}
|
||||
footer={<Button onClick={() => setDetail(null)}>닫기</Button>}
|
||||
width={640}
|
||||
destroyOnHidden
|
||||
centered
|
||||
>
|
||||
{detail ? (
|
||||
<div>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
{detail.title}
|
||||
</Typography.Title>
|
||||
<div className="cs-notice-meta">
|
||||
<span>조회 {detail.views ?? 0}</span>
|
||||
<span>등록일 {detail.createdDate || '-'}</span>
|
||||
</div>
|
||||
<div className="cs-notice-body">
|
||||
{(detail.contents || '')
|
||||
.split('\n')
|
||||
.map((line, idx) => (
|
||||
<p key={idx}>{line || '\u00A0'}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
EditOutlined,
|
||||
PlusSquareOutlined,
|
||||
QuestionCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type QuestionRow = {
|
||||
id: number
|
||||
no?: number
|
||||
title?: string
|
||||
answer?: string
|
||||
status?: string
|
||||
authorName?: string
|
||||
createdDate?: string
|
||||
content?: string
|
||||
reply?: string
|
||||
}
|
||||
|
||||
type QuestionResult = {
|
||||
total: number
|
||||
list: QuestionRow[]
|
||||
}
|
||||
|
||||
const initial = { page: 0, size: 10, searchType: '제목', sort: 'id,desc' } as Record<string, unknown>
|
||||
|
||||
export default function CsQuestionPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [detail, setDetail] = useState<QuestionRow | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-questions', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<QuestionResult>>(`${apiPrefix()}/homes/questions`, {
|
||||
params: {
|
||||
...filters,
|
||||
q: filters.keyword || undefined } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-questions'] })
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/questions`, {
|
||||
title: values.title,
|
||||
content: values.content,
|
||||
status: '접수' })
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('사용문의를 등록했습니다.')
|
||||
setOpen(false)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const openDetail = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.get<ApiResponse<QuestionRow>>(`${apiPrefix()}/homes/questions/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (row) => setDetail(row),
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
setFilters({
|
||||
...initial,
|
||||
searchType: values.searchType || '제목',
|
||||
keyword: values.keyword || undefined,
|
||||
page: 0,
|
||||
size: Number(values.size ?? filters.size ?? 10),
|
||||
sort: filters.sort })
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ searchType: '제목', size: 10 })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '번호',
|
||||
dataIndex: 'no',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '제목',
|
||||
dataIndex: 'title',
|
||||
ellipsis: true,
|
||||
render: (v: string, row: QuestionRow) => (
|
||||
<Button type="link" className="cs-notice-title" onClick={() => openDetail.mutate(row.id)}>
|
||||
{v}
|
||||
</Button>
|
||||
) },
|
||||
{
|
||||
title: '답변',
|
||||
dataIndex: 'answer',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
sorter: true,
|
||||
render: (v?: string) => v || '' },
|
||||
{
|
||||
title: '작성자',
|
||||
dataIndex: 'authorName',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '등록일',
|
||||
dataIndex: 'createdDate',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="stock-page cs-notice-page homes-cs-question-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<QuestionCircleOutlined className="product-title-icon" />
|
||||
사용문의
|
||||
</h2>
|
||||
<p>궁금하신 사항이나 사용상 문제가 있으시면 언제든지 내용을 남겨주세요. 담당자 확인 후 답변드리겠습니다.</p>
|
||||
</div>
|
||||
<Button
|
||||
className="cs-question-register-btn"
|
||||
type="primary"
|
||||
icon={<PlusSquareOutlined />}
|
||||
onClick={() => {
|
||||
editForm.resetFields()
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
사용문의 등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="farebox-filter"
|
||||
initialValues={{ searchType: '제목', size: 10 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="farebox-filter-row">
|
||||
<Form.Item name="searchType">
|
||||
<Select
|
||||
className="w-110"
|
||||
options={['제목', '작성자', '내용', '답변'].map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-230" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
|
||||
검색
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={onReset}>
|
||||
초기화
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="size" className="farebox-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((prev) => ({ ...prev, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="farebox-table-toolbar">
|
||||
<Typography.Text>
|
||||
목록 <b>{query.data?.total ?? 0}</b>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<CareTable
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
rowKey="id"
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
bordered
|
||||
pagination={{
|
||||
current: Number(filters.page ?? 0) + 1,
|
||||
pageSize: Number(filters.size ?? 10),
|
||||
total: query.data?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
onChange={(_p, _f, sorter) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter
|
||||
if (!s?.field || !s.order) return
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort: `${String(s.field)},${s.order === 'ascend' ? 'asc' : 'desc'}`,
|
||||
page: 0 }))
|
||||
}}
|
||||
locale={{ emptyText: '등록되어 있는 사용문의가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="사용문의 등록"
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
footer={null}
|
||||
width={560}
|
||||
destroyOnHidden
|
||||
centered
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => createMut.mutate(values)}
|
||||
>
|
||||
<Form.Item name="title" label="제목" rules={[{ required: true, message: '제목을 입력하세요.' }]}>
|
||||
<Input placeholder="제목을 입력하세요" />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="내용" rules={[{ required: true, message: '내용을 입력하세요.' }]}>
|
||||
<Input.TextArea rows={8} placeholder="문의 내용을 입력하세요" />
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button
|
||||
className="cs-question-register-btn"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={createMut.isPending}
|
||||
icon={<EditOutlined />}
|
||||
>
|
||||
등록
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="사용문의"
|
||||
open={Boolean(detail)}
|
||||
onCancel={() => setDetail(null)}
|
||||
footer={<Button onClick={() => setDetail(null)}>닫기</Button>}
|
||||
width={640}
|
||||
destroyOnHidden
|
||||
centered
|
||||
>
|
||||
{detail ? (
|
||||
<div>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
{detail.title}
|
||||
</Typography.Title>
|
||||
<div className="cs-notice-meta">
|
||||
<span>작성자 {detail.authorName || '-'}</span>
|
||||
<span>등록일 {detail.createdDate || '-'}</span>
|
||||
<span>{detail.answer || '미답변'}</span>
|
||||
</div>
|
||||
<div className="cs-inquiry-section">
|
||||
<Typography.Text strong>문의내용</Typography.Text>
|
||||
<div className="cs-notice-body">
|
||||
{(detail.content || '')
|
||||
.split('\n')
|
||||
.map((line, idx) => (
|
||||
<p key={idx}>{line || '\u00A0'}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{detail.reply ? (
|
||||
<div className="cs-inquiry-section">
|
||||
<Typography.Text strong>답변</Typography.Text>
|
||||
<div className="cs-notice-body">
|
||||
{detail.reply
|
||||
.split('\n')
|
||||
.map((line, idx) => (
|
||||
<p key={idx}>{line || '\u00A0'}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Button, Card, Form, Input, Select } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { FileTextOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
const options = (values: string[]) => values.map((value) => ({ value, label: value }))
|
||||
|
||||
type Customer = Record<string, unknown> & { id: number }
|
||||
type SearchResult = { total: number; list: Customer[] }
|
||||
|
||||
const initial = { page: 0, size: 15 } as Record<string, unknown>
|
||||
|
||||
export default function CustomerListPage() {
|
||||
const [form] = Form.useForm()
|
||||
const navigate = useNavigate()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['customers-list', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<SearchResult>>(`${apiPrefix()}/customers`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
setFilters({ ...initial, ...values, page: 0, size: values.size ?? filters.size ?? 15 })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ size: 15 })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '고객명', dataIndex: 'name', width: 100, sorter: true },
|
||||
{ title: '연락처', dataIndex: 'phone', width: 120 },
|
||||
{ title: '통신사', dataIndex: 'telecom', width: 70 },
|
||||
{ title: '주소', dataIndex: 'address', width: 200, ellipsis: true },
|
||||
{ title: '상태', dataIndex: 'status', width: 80 },
|
||||
{ title: '담당자', dataIndex: 'employee', width: 90 },
|
||||
{ title: '등록일', dataIndex: 'createdDate', width: 100 },
|
||||
{
|
||||
title: '관리', width: 58, fixed: 'right' as const,
|
||||
render: (_: unknown, row: Customer) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<FileTextOutlined style={{ color: '#1677ff' }} />}
|
||||
onClick={() => navigate(`/care/customer/write?id=${row.id}`)}
|
||||
/>
|
||||
) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stock-page home-sale-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><UserOutlined style={{ marginRight: 8 }} />고객목록</h2>
|
||||
<p>홈상품·계약 고객 정보를 조회하고 관리합니다.</p>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/care/customer/write')}>고객 등록</Button>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form form={form} layout="inline" className="home-filter" initialValues={{ size: 15 }} onFinish={onSearch}>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="status"><Select allowClear placeholder="-상태-" className="w-110" options={options(['정상', '휴면', '해지'])} /></Form.Item>
|
||||
<Form.Item name="telecom"><Select allowClear placeholder="-통신사-" className="w-110" options={options(['SKT', 'KT', 'LGU+'])} /></Form.Item>
|
||||
<Form.Item name="name"><Input placeholder="고객명" className="w-130" allowClear /></Form.Item>
|
||||
<Form.Item name="phone"><Input placeholder="연락처" className="w-130" allowClear /></Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => { form.setFieldValue('size', size); setFilters((v) => ({ ...v, size, page: 0 })) }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `총 ${total}건`,
|
||||
onChange: (page) => setFilters((v) => ({ ...v, page: page - 1 })) }}
|
||||
locale={{ emptyText: query.isError ? '데이터를 불러오지 못했습니다.' : '등록된 고객이 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { TeamOutlined } from '@ant-design/icons'
|
||||
import { HomesLogListPage } from './HomesLogListPage'
|
||||
|
||||
const ACTIONS = [
|
||||
'고객-등록',
|
||||
'고객-수정',
|
||||
'고객-삭제',
|
||||
'고객-분류',
|
||||
]
|
||||
|
||||
export default function CustomerLogPage() {
|
||||
return (
|
||||
<HomesLogListPage
|
||||
domain="CUSTOMER"
|
||||
title="고객이력"
|
||||
description="고객관리와 관련된 이력을 확인하실 수 있습니다."
|
||||
defaultActions={ACTIONS}
|
||||
titleIcon={<TeamOutlined className="product-title-icon" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Tabs,
|
||||
Tooltip,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
CloseOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
MailOutlined,
|
||||
PlusSquareOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { options } from './homesShared'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
type CustomerRow = {
|
||||
id: number
|
||||
grade?: string
|
||||
customerType?: string
|
||||
name?: string
|
||||
phone?: string
|
||||
phoneAlt?: string
|
||||
zipcode?: string
|
||||
address?: string
|
||||
addressDetail?: string
|
||||
employee?: string
|
||||
memo?: string
|
||||
email?: string
|
||||
gender?: string
|
||||
nationality?: string
|
||||
birthYear?: string
|
||||
birthMonth?: string
|
||||
birthDay?: string
|
||||
registeredDate?: string
|
||||
contractCount?: number
|
||||
bookingCount?: number
|
||||
}
|
||||
|
||||
type CustomerResult = {
|
||||
total: number
|
||||
list: CustomerRow[]
|
||||
counts?: Record<string, number>
|
||||
grades?: string[]
|
||||
employees?: string[]
|
||||
}
|
||||
|
||||
const initial = { page: 0, size: 10, tab: 'ALL' } as Record<string, unknown>
|
||||
|
||||
const maskName = (name?: string) => {
|
||||
if (!name) return '-'
|
||||
if (name.length <= 1) return name
|
||||
if (name.length === 2) return `${name[0]}*`
|
||||
return `${name[0]}*${name.slice(2)}`
|
||||
}
|
||||
|
||||
const maskPhone = (phone?: string) => {
|
||||
if (!phone) return '-'
|
||||
const digits = phone.replace(/\D/g, '')
|
||||
if (digits.length < 7) return phone
|
||||
if (digits.length === 10) return `${digits.slice(0, 3)}-****-${digits.slice(6)}`
|
||||
return `${digits.slice(0, 3)}-****-${digits.slice(-4)}`
|
||||
}
|
||||
|
||||
const splitPhone = (phone?: string, fallbackFirst = '010') => {
|
||||
const digits = (phone || '').replace(/\D/g, '')
|
||||
if (digits.length >= 10) {
|
||||
return {
|
||||
p1: digits.slice(0, 3),
|
||||
p2: digits.slice(3, digits.length - 4),
|
||||
p3: digits.slice(-4) }
|
||||
}
|
||||
return { p1: fallbackFirst, p2: '', p3: '' }
|
||||
}
|
||||
|
||||
const fullAddress = (row: CustomerRow) => {
|
||||
const parts = [row.zipcode ? `[${row.zipcode}]` : '', row.address, row.addressDetail].filter(Boolean)
|
||||
return parts.join(' ') || ''
|
||||
}
|
||||
|
||||
export default function CustomerPage() {
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [gradeForm] = Form.useForm()
|
||||
const [changeGradeForm] = Form.useForm()
|
||||
const qc = useQueryClient()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [editing, setEditing] = useState<CustomerRow | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [gradeOpen, setGradeOpen] = useState(false)
|
||||
const [changeGradeOpen, setChangeGradeOpen] = useState(false)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-customers', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<CustomerResult>>(`${apiPrefix()}/homes/customers`, {
|
||||
params: {
|
||||
...filters,
|
||||
grade: filters.tab === 'ALL' ? undefined : filters.tab } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const members = useQuery({
|
||||
queryKey: ['homes/members-options'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ list: { id: number; name?: string }[] }>>(`${apiPrefix()}/homes/members`, {
|
||||
params: { page: 0, size: 200 } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ['homes-customers'] })
|
||||
setSelected([])
|
||||
}
|
||||
|
||||
const staffOptions = useMemo(() => {
|
||||
const fromApi = query.data?.employees || []
|
||||
const fromMembers = (members.data?.list || []).map((m) => String(m.name || '')).filter(Boolean)
|
||||
return Array.from(new Set([...fromApi, ...fromMembers, '홍길동'])).map((v) => ({ value: v, label: v }))
|
||||
}, [query.data, members.data])
|
||||
|
||||
const gradeList = useMemo(
|
||||
() => query.data?.grades || ['단골', '단골1', '단골2', '단골3', '단골4'],
|
||||
[query.data?.grades],
|
||||
)
|
||||
|
||||
const gradeOptions = useMemo(
|
||||
() => [{ value: '분류없음', label: '분류없음' }, ...gradeList.map((g) => ({ value: g, label: g }))],
|
||||
[gradeList],
|
||||
)
|
||||
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{ key: 'ALL', label: `전체 ${query.data?.counts?.ALL ?? query.data?.total ?? 0}` },
|
||||
...gradeList.map((g) => ({ key: g, label: `${g} ${query.data?.counts?.[g] ?? 0}` })),
|
||||
],
|
||||
[query.data, gradeList],
|
||||
)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
employee: values.employee,
|
||||
customerType: values.customerType || '개인',
|
||||
name: values.name,
|
||||
grade: values.grade || '분류없음',
|
||||
phone1: values.phone1,
|
||||
phone2: values.phone2,
|
||||
phone3: values.phone3,
|
||||
altPhone1: values.altPhone1,
|
||||
altPhone2: values.altPhone2,
|
||||
altPhone3: values.altPhone3,
|
||||
birthYear: values.birthYear,
|
||||
birthMonth: values.birthMonth,
|
||||
birthDay: values.birthDay,
|
||||
gender: values.gender,
|
||||
nationality: values.nationality,
|
||||
email: values.email,
|
||||
zipcode: values.zipcode,
|
||||
address: values.address,
|
||||
addressDetail: values.addressDetail,
|
||||
memo: values.memo,
|
||||
registeredDate: values.registeredDate
|
||||
? dayjs(values.registeredDate as Dayjs).format('YYYY-MM-DD')
|
||||
: dayjs().format('YYYY-MM-DD') }
|
||||
if (editing) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/customers/${editing.id}`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/customers`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '수정했습니다.' : '고객을 등록했습니다.')
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/customers/delete`, { ids })
|
||||
return unwrap(data as ApiResponse<{ deleted: number }>)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
message.success(`${data.deleted}건을 삭제했습니다.`)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const changeGradeMut = useMutation({
|
||||
mutationFn: async (payload: { ids: number[]; grade: string }) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/customers/grade`, payload)
|
||||
return unwrap(data as ApiResponse<{ updated: number }>)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
message.success(`${data.updated}건의 분류를 변경했습니다.`)
|
||||
setChangeGradeOpen(false)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const saveGrades = useMutation({
|
||||
mutationFn: async (grades: string[]) => {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/customers/grades`, { grades })
|
||||
return unwrap(data as ApiResponse<{ grades: string[] }>)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('분류를 저장했습니다.')
|
||||
setGradeOpen(false)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
const searchType = String(values.searchType || '연락처(뒷4자리)')
|
||||
const keyword = String(values.keyword || '').trim()
|
||||
setFilters({
|
||||
...initial,
|
||||
employee: values.employee || undefined,
|
||||
customerType: values.customerType || undefined,
|
||||
history: values.history || undefined,
|
||||
searchType,
|
||||
phoneTail: searchType.includes('뒷4') && keyword ? keyword : undefined,
|
||||
q: searchType.includes('뒷4') ? undefined : keyword || undefined,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 10,
|
||||
tab: filters.tab })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ size: 10, searchType: '연락처(뒷4자리)' })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setCreating(true)
|
||||
editForm.setFieldsValue({
|
||||
employee: staffOptions[0]?.value || '홍길동',
|
||||
customerType: '개인',
|
||||
name: undefined,
|
||||
grade: '분류없음',
|
||||
phone1: '010',
|
||||
phone2: '',
|
||||
phone3: '',
|
||||
altPhone1: '010',
|
||||
altPhone2: '',
|
||||
altPhone3: '',
|
||||
birthYear: undefined,
|
||||
birthMonth: undefined,
|
||||
birthDay: undefined,
|
||||
gender: undefined,
|
||||
nationality: undefined,
|
||||
email: undefined,
|
||||
zipcode: undefined,
|
||||
address: undefined,
|
||||
addressDetail: undefined,
|
||||
memo: undefined,
|
||||
registeredDate: dayjs() })
|
||||
}
|
||||
|
||||
const openEdit = (row: CustomerRow) => {
|
||||
setCreating(false)
|
||||
setEditing(row)
|
||||
const phone = splitPhone(row.phone)
|
||||
const alt = splitPhone(row.phoneAlt, '010')
|
||||
editForm.setFieldsValue({
|
||||
employee: row.employee,
|
||||
customerType: row.customerType || '개인',
|
||||
name: row.name,
|
||||
grade: row.grade || '분류없음',
|
||||
phone1: phone.p1,
|
||||
phone2: phone.p2,
|
||||
phone3: phone.p3,
|
||||
altPhone1: alt.p1,
|
||||
altPhone2: alt.p2,
|
||||
altPhone3: alt.p3,
|
||||
birthYear: row.birthYear,
|
||||
birthMonth: row.birthMonth,
|
||||
birthDay: row.birthDay,
|
||||
gender: row.gender,
|
||||
nationality: row.nationality,
|
||||
email: row.email,
|
||||
zipcode: row.zipcode,
|
||||
address: row.address,
|
||||
addressDetail: row.addressDetail,
|
||||
memo: row.memo,
|
||||
registeredDate: row.registeredDate ? dayjs(row.registeredDate) : dayjs() })
|
||||
}
|
||||
|
||||
const searchAddress = () => {
|
||||
message.info('주소검색(데모) — 직접 입력해 주세요.')
|
||||
editForm.setFieldsValue({
|
||||
zipcode: editForm.getFieldValue('zipcode') || '06236',
|
||||
address: editForm.getFieldValue('address') || '서울특별시 강남구' })
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/homes/customers/export`, {
|
||||
params: { ...filters, grade: filters.tab === 'ALL' ? undefined : filters.tab },
|
||||
responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '고객관리.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const openGradeSettings = () => {
|
||||
gradeForm.setFieldsValue({ gradesText: gradeList.join('\n') })
|
||||
setGradeOpen(true)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: (
|
||||
<Checkbox
|
||||
checked={!!query.data?.list?.length && selected.length === query.data.list.length}
|
||||
indeterminate={selected.length > 0 && selected.length < (query.data?.list?.length || 0)}
|
||||
onChange={(e) => setSelected(e.target.checked ? (query.data?.list || []).map((r) => r.id) : [])}
|
||||
/>
|
||||
),
|
||||
width: 42,
|
||||
render: (_: unknown, row: CustomerRow) => (
|
||||
<Checkbox
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={(e) => setSelected((prev) => (e.target.checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)))}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '등록일',
|
||||
dataIndex: 'registeredDate',
|
||||
width: 110,
|
||||
sorter: (a: CustomerRow, b: CustomerRow) => String(a.registeredDate || '').localeCompare(String(b.registeredDate || '')) },
|
||||
{
|
||||
title: '분류',
|
||||
dataIndex: 'grade',
|
||||
width: 80,
|
||||
render: (v: string) => (v && v !== '분류없음' ? v : '-') },
|
||||
{ title: '구분', dataIndex: 'customerType', width: 70 },
|
||||
{
|
||||
title: '고객명',
|
||||
dataIndex: 'name',
|
||||
width: 90,
|
||||
render: (v: string) => maskName(v) },
|
||||
{
|
||||
title: '연락처',
|
||||
dataIndex: 'phone',
|
||||
width: 130,
|
||||
render: (v: string) => maskPhone(v) },
|
||||
{
|
||||
title: '보조연락처',
|
||||
dataIndex: 'phoneAlt',
|
||||
width: 130,
|
||||
render: (v: string) => (v ? maskPhone(v) : '-') },
|
||||
{
|
||||
title: '주소',
|
||||
dataIndex: 'address',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (_: string, row: CustomerRow) => {
|
||||
const addr = fullAddress(row)
|
||||
return addr ? (
|
||||
<Tooltip title={addr}>
|
||||
<MailOutlined className="memo-icon" />
|
||||
</Tooltip>
|
||||
) : '-'
|
||||
} },
|
||||
{
|
||||
title: '메모',
|
||||
dataIndex: 'memo',
|
||||
width: 60,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => (v ? (
|
||||
<Tooltip title={v}>
|
||||
<MailOutlined className="memo-icon" />
|
||||
</Tooltip>
|
||||
) : '-') },
|
||||
{
|
||||
title: '계약',
|
||||
dataIndex: 'contractCount',
|
||||
width: 60,
|
||||
align: 'center' as const,
|
||||
render: (v: number) => (v ? v : '-') },
|
||||
{
|
||||
title: '예약',
|
||||
dataIndex: 'bookingCount',
|
||||
width: 60,
|
||||
align: 'center' as const,
|
||||
render: (v: number) => (v ? v : '-') },
|
||||
{ title: '등록직원', dataIndex: 'employee', width: 90 },
|
||||
{
|
||||
title: '관리',
|
||||
width: 64,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: CustomerRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="customer-manage-btn"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(row)}
|
||||
/>
|
||||
) },
|
||||
]
|
||||
|
||||
const modalOpen = creating || Boolean(editing)
|
||||
|
||||
return (
|
||||
<div className="stock-page customer-manage-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<TeamOutlined className="product-title-icon" />
|
||||
고객관리
|
||||
</h2>
|
||||
<p>등록되어 있는 고객의 정보를 관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('분류를 변경할 고객을 선택하세요.')
|
||||
return
|
||||
}
|
||||
changeGradeForm.setFieldsValue({ grade: '단골' })
|
||||
setChangeGradeOpen(true)
|
||||
}}
|
||||
>
|
||||
분류변경
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
loading={deleteMut.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('삭제할 고객을 선택하세요.')
|
||||
return
|
||||
}
|
||||
deleteMut.mutate(selected)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button className="customer-register-btn" type="primary" icon={<PlusSquareOutlined />} onClick={openCreate}>
|
||||
고객 등록
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="balance-filter"
|
||||
initialValues={{ size: 10, searchType: '연락처(뒷4자리)' }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={() => form.setFieldValue('dates', undefined)} />
|
||||
</div>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="employee">
|
||||
<Select allowClear showSearch placeholder="등록직원" className="w-120" options={staffOptions} optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="customerType">
|
||||
<Select allowClear placeholder="구분" className="w-110" options={options(['개인', '사업자'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="history">
|
||||
<Select allowClear placeholder="이력" className="w-110" options={options(['계약', '예약', '없음'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="searchType">
|
||||
<Select
|
||||
className="w-140"
|
||||
options={options(['연락처(뒷4자리)', '고객명', '연락처', '주소'])}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input placeholder="검색어" className="w-140" allowClear />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((v) => ({ ...v, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="stock-table-actions">
|
||||
<Tabs
|
||||
activeKey={String(filters.tab)}
|
||||
onChange={(tab) => setFilters((v) => ({ ...v, tab, page: 0 }))}
|
||||
items={tabs}
|
||||
/>
|
||||
<Space>
|
||||
<Button type="link" icon={<SettingOutlined />} onClick={openGradeSettings}>분류설정</Button>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((v) => ({ ...v, page: page - 1 })) }}
|
||||
locale={{ emptyText: '등록된 자료가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '고객 수정' : '고객 등록'}
|
||||
open={modalOpen}
|
||||
onCancel={() => { setCreating(false); setEditing(null) }}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={620}
|
||||
className="customer-modal"
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '110px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
onFinish={(values) => save.mutate(values)}
|
||||
className="balance-edit-form"
|
||||
>
|
||||
<Form.Item name="employee" label="등록직원" rules={[{ required: true, message: '등록직원을 선택하세요' }]}>
|
||||
<Select options={staffOptions} showSearch optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="customerType" label="고객구분" rules={[{ required: true, message: '고객구분을 선택하세요' }]}>
|
||||
<Radio.Group options={[{ value: '개인', label: '개인' }, { value: '사업자', label: '사업자' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="고객명" rules={[{ required: true, message: '고객명을 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="분류">
|
||||
<Space.Compact className="full-width">
|
||||
<Form.Item name="grade" noStyle>
|
||||
<Select options={gradeOptions} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Button icon={<SettingOutlined />} onClick={openGradeSettings}>설정</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="연락처" required>
|
||||
<Space.Compact>
|
||||
<Form.Item name="phone1" noStyle rules={[{ required: true, message: '연락처를 입력하세요' }]}>
|
||||
<Input style={{ width: 70 }} maxLength={3} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone2" noStyle rules={[{ required: true, message: '연락처를 입력하세요' }]}>
|
||||
<Input style={{ width: 80 }} maxLength={4} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone3" noStyle rules={[{ required: true, message: '연락처를 입력하세요' }]}>
|
||||
<Input style={{ width: 80 }} maxLength={4} />
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="보조연락처">
|
||||
<Space.Compact>
|
||||
<Form.Item name="altPhone1" noStyle><Input style={{ width: 70 }} maxLength={3} /></Form.Item>
|
||||
<Form.Item name="altPhone2" noStyle><Input style={{ width: 80 }} maxLength={4} /></Form.Item>
|
||||
<Form.Item name="altPhone3" noStyle><Input style={{ width: 80 }} maxLength={4} /></Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="생년월일">
|
||||
<Space.Compact>
|
||||
<Form.Item name="birthYear" noStyle><Input style={{ width: 80 }} placeholder="생년" maxLength={4} /></Form.Item>
|
||||
<Form.Item name="birthMonth" noStyle><Input style={{ width: 70 }} placeholder="월" maxLength={2} /></Form.Item>
|
||||
<Form.Item name="birthDay" noStyle><Input style={{ width: 70 }} placeholder="일" maxLength={2} /></Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="성별/국적">
|
||||
<Space>
|
||||
<Form.Item name="gender" noStyle>
|
||||
<Select allowClear placeholder="- 성별 -" className="w-120" options={options(['남', '여'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="nationality" noStyle>
|
||||
<Select allowClear placeholder="- 국적 -" className="w-120" options={options(['내국인', '외국인'])} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label="이메일">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="주소">
|
||||
<div className="booking-address-row">
|
||||
<Button onClick={searchAddress}>주소검색</Button>
|
||||
<Form.Item name="zipcode" noStyle>
|
||||
<Input className="booking-zip" placeholder="우편번호" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="address" noStyle>
|
||||
<Input className="booking-addr-detail" placeholder="기본주소" style={{ marginBottom: 8 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="addressDetail" noStyle>
|
||||
<Input className="booking-addr-detail" placeholder="상세주소" />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="메모">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="customer-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="분류설정"
|
||||
open={gradeOpen}
|
||||
onCancel={() => setGradeOpen(false)}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={420}
|
||||
>
|
||||
<Form
|
||||
form={gradeForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => {
|
||||
const grades = String(values.gradesText || '')
|
||||
.split(/\n|,/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
saveGrades.mutate(grades)
|
||||
}}
|
||||
>
|
||||
<Form.Item name="gradesText" label="분류 (줄바꿈으로 구분)" rules={[{ required: true, message: '분류를 입력하세요' }]}>
|
||||
<Input.TextArea rows={6} placeholder={'단골\n단골1\n단골2\n단골3\n단골4'} />
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="customer-submit-btn" type="primary" htmlType="submit" loading={saveGrades.isPending}>저장</Button>
|
||||
<Button onClick={() => setGradeOpen(false)}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="분류변경"
|
||||
open={changeGradeOpen}
|
||||
onCancel={() => setChangeGradeOpen(false)}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={400}
|
||||
>
|
||||
<Form
|
||||
form={changeGradeForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => changeGradeMut.mutate({ ids: selected, grade: String(values.grade) })}
|
||||
>
|
||||
<Form.Item name="grade" label="변경할 분류" rules={[{ required: true, message: '분류를 선택하세요' }]}>
|
||||
<Select options={gradeOptions} />
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="customer-submit-btn" type="primary" htmlType="submit" loading={changeGradeMut.isPending}>변경</Button>
|
||||
<Button onClick={() => setChangeGradeOpen(false)}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Button, Card, Col, Form, Input, Row, Select, message } from 'antd'
|
||||
import { UnorderedListOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
const options = (values: string[]) => values.map((value) => ({ value, label: value }))
|
||||
|
||||
export default function CustomerWritePage() {
|
||||
const navigate = useNavigate()
|
||||
const [search] = useSearchParams()
|
||||
const editId = search.get('id') ? Number(search.get('id')) : null
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: ['customer-detail', editId],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>>>(`${apiPrefix()}/customers/${editId}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
enabled: Boolean(editId) })
|
||||
|
||||
useEffect(() => {
|
||||
if (detail.data) form.setFieldsValue(detail.data)
|
||||
}, [detail.data, form])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
if (editId) {
|
||||
const { data } = await client.put(`${apiPrefix()}/customers/${editId}`, values)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/customers`, values)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editId ? '고객을 수정했습니다.' : '고객을 등록했습니다.')
|
||||
navigate('/care/customer/list')
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
return (
|
||||
<div className="home-write-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><UserOutlined style={{ marginRight: 8 }} />고객 {editId ? '수정' : '등록'}</h2>
|
||||
<p>고객 기본정보를 입력해주세요. ('필수' 표시는 필수항목입니다.)</p>
|
||||
</div>
|
||||
<Button icon={<UnorderedListOutlined />} onClick={() => navigate('/care/customer/list')}>
|
||||
목록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '100px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
initialValues={{ status: '정상', telecom: 'KT' }}
|
||||
onFinish={(v) => save.mutate(v)}
|
||||
>
|
||||
<Card size="small" title="고객정보" style={{ marginBottom: 12 }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="name" label="고객명" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="phone" label="연락처" rules={[{ required: true }]}>
|
||||
<Input placeholder="010-0000-0000" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="telecom" label="통신사">
|
||||
<Select options={options(['SKT', 'KT', 'LGU+'])} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="status" label="상태">
|
||||
<Select options={options(['정상', '휴면', '해지'])} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="employee" label="담당자">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="email" label="이메일">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="address" label="주소">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="memo" label="메모">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>저장</Button>
|
||||
<Button onClick={() => navigate('/care/customer/list')}>목록</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
EditOutlined, FileTextOutlined, PlusOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type DeliveryRow = {
|
||||
id: number
|
||||
status?: string
|
||||
active?: boolean
|
||||
name?: string
|
||||
phone?: string
|
||||
memo?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
type DeliveryData = {
|
||||
total: number
|
||||
list: DeliveryRow[]
|
||||
}
|
||||
|
||||
export default function DeliveryPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<DeliveryRow | null>(null)
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({
|
||||
searchType: '업체명',
|
||||
page: 0,
|
||||
size: 15,
|
||||
sort: 'name,asc' })
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['deliveries', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<DeliveryData>>(`${apiPrefix()}/deliveries`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => {
|
||||
setSelected([])
|
||||
qc.invalidateQueries({ queryKey: ['deliveries'] })
|
||||
}
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
if (editing?.id) {
|
||||
const { data } = await client.put(`${apiPrefix()}/deliveries/${editing.id}`, values)
|
||||
return unwrap(data)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/deliveries`, values)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '배송처를 수정했습니다.' : '배송처를 등록했습니다.')
|
||||
setOpen(false)
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const statusMut = useMutation({
|
||||
mutationFn: async (status: '사용' | '미사용') => {
|
||||
const { data } = await client.post(`${apiPrefix()}/deliveries/status`, { ids: selected, status })
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (_d, status) => {
|
||||
message.success(`${status} 처리했습니다.`)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
searchType: values.searchType || '업체명',
|
||||
keyword: values.keyword,
|
||||
page: 0,
|
||||
size: Number(values.size ?? prev.size ?? 15) }))
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.setFieldsValue({ searchType: '업체명', keyword: undefined, size: 15 })
|
||||
setFilters({ searchType: '업체명', page: 0, size: 15, sort: 'name,asc' })
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
editForm.setFieldsValue({ name: undefined, phone: undefined, memo: undefined, status: '사용' })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (row: DeliveryRow) => {
|
||||
setEditing(row)
|
||||
editForm.setFieldsValue({
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
memo: row.memo,
|
||||
status: row.status || '사용' })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
const { data } = await client.get(`${apiPrefix()}/deliveries/export`, {
|
||||
params: filters,
|
||||
responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '배송처관리.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '상태',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => <span className={v === '미사용' ? 'farebox-cancelled' : ''}>{v}</span> },
|
||||
{ title: '업체명', dataIndex: 'name', sorter: true },
|
||||
{ title: '연락처', dataIndex: 'phone', width: 160, align: 'center' as const },
|
||||
{ title: '비고', dataIndex: 'memo', width: 200, ellipsis: true },
|
||||
{ title: '등록일', dataIndex: 'createdAt', width: 120, align: 'center' as const, sorter: true },
|
||||
{
|
||||
title: '관리',
|
||||
key: 'manage',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: DeliveryRow) => (
|
||||
<Button type="primary" size="small" className="staff-manage-btn" icon={<FileTextOutlined />} onClick={() => openEdit(row)} />
|
||||
) },
|
||||
], [])
|
||||
|
||||
return (
|
||||
<div className="stock-page delivery-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><SettingOutlined style={{ marginRight: 8 }} />배송처관리</h2>
|
||||
<p>거래하고 있는 배송처를 등록 혹은 관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap>
|
||||
<Button
|
||||
className="farebox-gray-btn"
|
||||
disabled={!selected.length}
|
||||
loading={statusMut.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) { message.warning('항목을 선택하세요.'); return }
|
||||
statusMut.mutate('사용')
|
||||
}}
|
||||
>
|
||||
사용
|
||||
</Button>
|
||||
<Button
|
||||
className="farebox-gray-btn"
|
||||
disabled={!selected.length}
|
||||
loading={statusMut.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) { message.warning('항목을 선택하세요.'); return }
|
||||
statusMut.mutate('미사용')
|
||||
}}
|
||||
>
|
||||
미사용
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>배송처 등록</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="farebox-filter"
|
||||
initialValues={{ searchType: '업체명', size: 15 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="farebox-filter-row">
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-120" options={['업체명', '연락처', '비고'].map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-180" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button onClick={onReset}>초기화</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="size" className="farebox-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => setFilters((prev) => ({ ...prev, size, page: 0 }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="farebox-table-toolbar">
|
||||
<Typography.Text>목록 {query.data?.total ?? 0}</Typography.Text>
|
||||
<Button type="link" icon={<FileTextOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
rowKey="id"
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
bordered
|
||||
rowSelection={{
|
||||
selectedRowKeys: selected,
|
||||
onChange: (keys) => setSelected(keys.map(Number)) }}
|
||||
pagination={{
|
||||
current: Number(filters.page ?? 0) + 1,
|
||||
pageSize: Number(filters.size ?? 15),
|
||||
total: query.data?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
onChange={(_p, _f, sorter) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter
|
||||
if (!s?.field) return
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort: `${String(s.field)},${s.order === 'ascend' ? 'asc' : 'desc'}` }))
|
||||
}}
|
||||
locale={{ emptyText: '배송처가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '배송처 수정' : '배송처 등록'}
|
||||
open={open}
|
||||
onCancel={() => { setOpen(false); setEditing(null) }}
|
||||
footer={null}
|
||||
width={460}
|
||||
destroyOnHidden
|
||||
centered
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
labelCol={{ flex: '88px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
labelAlign="left"
|
||||
colon={false}
|
||||
onFinish={(values) => saveMut.mutate(values)}
|
||||
>
|
||||
<Form.Item name="name" label="업체명" rules={[{ required: true, message: '업체명을 입력하세요.' }]}>
|
||||
<Input placeholder="업체명을 입력하세요" />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="연락처">
|
||||
<Input placeholder="1588-0000" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="상태">
|
||||
<Select options={['사용', '미사용'].map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="비고">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<EditOutlined />}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Table, Button, Card, Empty } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { BarChartOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { money } from './homesShared'
|
||||
|
||||
type StatRow = {
|
||||
key?: number
|
||||
label?: string
|
||||
internetCount?: number
|
||||
homeCount?: number
|
||||
count?: number
|
||||
settleAmount?: number
|
||||
margin?: number
|
||||
policySum?: number
|
||||
}
|
||||
|
||||
type GroupStatData = {
|
||||
year?: number
|
||||
month?: number
|
||||
title?: string
|
||||
rows?: StatRow[]
|
||||
totals?: StatRow
|
||||
}
|
||||
|
||||
const num = (v?: number) => Number(v ?? 0)
|
||||
const cell = (v?: number) => (num(v) === 0 ? '' : money(v))
|
||||
const countCell = (v?: number) => (num(v) === 0 ? '' : String(v))
|
||||
|
||||
type Props = {
|
||||
apiKind: 'member' | 'agency'
|
||||
title: string
|
||||
description: string
|
||||
labelTitle: string
|
||||
}
|
||||
|
||||
export function GroupStatReportPage({ apiKind, title, description, labelTitle }: Props) {
|
||||
const [cursor, setCursor] = useState<Dayjs>(dayjs())
|
||||
|
||||
const params = useMemo(() => ({
|
||||
year: cursor.year(),
|
||||
month: cursor.month() + 1 }), [cursor])
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [`homes-report-${apiKind}`, params],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<GroupStatData>>(`${apiPrefix()}/homes/reports/${apiKind}`, {
|
||||
params })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const rows = query.data?.rows || []
|
||||
const totals = query.data?.totals
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: labelTitle,
|
||||
dataIndex: 'label',
|
||||
width: 140 },
|
||||
{
|
||||
title: '인터넷접수',
|
||||
dataIndex: 'internetCount',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: countCell },
|
||||
{
|
||||
title: '홈렌탈접수',
|
||||
dataIndex: 'homeCount',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: countCell },
|
||||
{
|
||||
title: '계약합계',
|
||||
dataIndex: 'count',
|
||||
width: 100,
|
||||
align: 'right' as const,
|
||||
render: countCell },
|
||||
{
|
||||
title: '정산합계',
|
||||
dataIndex: 'settleAmount',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: cell },
|
||||
{
|
||||
title: '마진합계',
|
||||
dataIndex: 'margin',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: cell },
|
||||
], [labelTitle])
|
||||
|
||||
return (
|
||||
<div className="stock-page kind-stat-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<BarChartOutlined className="product-title-icon kind-stat-icon" />
|
||||
{title}
|
||||
</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="kind-stat-period-card" size="small">
|
||||
<div className="kind-stat-period-nav">
|
||||
<Button type="text" icon={<LeftOutlined />} onClick={() => setCursor((d) => d.subtract(1, 'month'))} />
|
||||
<span className="kind-stat-period-label">{cursor.format('YYYY년 M월')}</span>
|
||||
<Button type="text" icon={<RightOutlined />} onClick={() => setCursor((d) => d.add(1, 'month'))} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card kind-stat-table-card" size="small" loading={query.isLoading}>
|
||||
{rows.length ? (
|
||||
<CareTable
|
||||
rowKey={(r) => String(r.key ?? r.label)}
|
||||
size="small"
|
||||
dataSource={rows}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
scroll={{ x: 700 }}
|
||||
summary={() => (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0}><b>합계</b></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1} align="right"><b>{countCell(totals?.internetCount)}</b></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={2} align="right"><b>{countCell(totals?.homeCount)}</b></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={3} align="right"><b>{countCell(totals?.count)}</b></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={4} align="right"><b>{cell(totals?.settleAmount)}</b></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={5} align="right"><b>{cell(totals?.margin)}</b></Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty className="kind-stat-empty" image={Empty.PRESENTED_IMAGE_SIMPLE} description="자료가 없습니다." />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Area, Column } from '@ant-design/plots'
|
||||
import { Card, Form, Input, Modal, Spin, Tabs, message } from 'antd'
|
||||
import {
|
||||
BookOutlined,
|
||||
CalendarOutlined,
|
||||
CustomerServiceOutlined,
|
||||
EditOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
ScheduleOutlined,
|
||||
TeamOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { homeAccentColors } from '../../theme'
|
||||
import { useTheme } from '../../themeContext'
|
||||
import { money } from './homesShared'
|
||||
|
||||
type SparkBlock = {
|
||||
open?: number
|
||||
today?: number
|
||||
total?: number
|
||||
monthNew?: number
|
||||
monthAmount?: number
|
||||
settleMonth?: number
|
||||
spark?: number[]
|
||||
}
|
||||
|
||||
type ChartPanel = {
|
||||
title?: string
|
||||
label?: string
|
||||
offset?: number
|
||||
apply?: number
|
||||
accept?: number
|
||||
done?: number
|
||||
cancel?: number
|
||||
settleAmount?: number
|
||||
count?: number
|
||||
daily?: { day: number; value: number }[]
|
||||
}
|
||||
|
||||
type StatusBlock = {
|
||||
total?: number
|
||||
items?: Record<string, number>
|
||||
}
|
||||
|
||||
type Postit = {
|
||||
slot: number
|
||||
title?: string
|
||||
contents?: string
|
||||
authorName?: string
|
||||
noteDate?: string
|
||||
}
|
||||
|
||||
type Dash = {
|
||||
pending?: SparkBlock
|
||||
booking?: SparkBlock
|
||||
customer?: SparkBlock
|
||||
abook?: SparkBlock
|
||||
contractTotal?: number
|
||||
customerTotal?: number
|
||||
postits?: Postit[]
|
||||
internet?: ChartPanel
|
||||
home?: ChartPanel
|
||||
internetStatus?: StatusBlock
|
||||
homeStatus?: StatusBlock
|
||||
notices?: { id: number; title?: string; createdDate?: string }[]
|
||||
questions?: { id: number; title?: string; createdDate?: string }[]
|
||||
csPhone?: string
|
||||
}
|
||||
|
||||
function Wave({ color }: { color: string }) {
|
||||
return (
|
||||
<svg className="home-wave" viewBox="0 0 200 42" preserveAspectRatio="none" aria-hidden>
|
||||
<path
|
||||
d="M0 28 C 30 8, 50 38, 80 22 S 130 4, 160 24 S 190 36, 200 18 L200 42 L0 42 Z"
|
||||
fill={color}
|
||||
opacity="0.35"
|
||||
/>
|
||||
<path
|
||||
d="M0 32 C 40 18, 70 36, 100 26 S 150 12, 180 28 S 195 34, 200 26 L200 42 L0 42 Z"
|
||||
fill={color}
|
||||
opacity="0.55"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniBars({ values, color }: { values: number[]; color: string }) {
|
||||
const max = Math.max(1, ...values)
|
||||
return (
|
||||
<div className="home-mini-bars" aria-hidden>
|
||||
{values.map((v, i) => (
|
||||
<span key={i} style={{ height: `${Math.max(12, (v / max) * 100)}%`, background: color }} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HomeDashboardPage() {
|
||||
const { theme } = useTheme()
|
||||
const accents = useMemo(() => homeAccentColors(theme), [theme])
|
||||
const qc = useQueryClient()
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [period, setPeriod] = useState('day')
|
||||
const [editSlot, setEditSlot] = useState<Postit | null>(null)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-dashboard', offset, period],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Dash>>(`${apiPrefix()}/homes/dashboard`, {
|
||||
params: { offset, period } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const savePostit = useMutation({
|
||||
mutationFn: async (values: { contents: string }) => {
|
||||
if (!editSlot) return
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/dashboard/postits/${editSlot.slot}`, {
|
||||
contents: values.contents,
|
||||
authorName: localStorage.getItem('userName') || '홍길동' })
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('저장했습니다.')
|
||||
setEditSlot(null)
|
||||
qc.invalidateQueries({ queryKey: ['homes-dashboard'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="home-loading">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const d = query.data || {}
|
||||
const pending = d.pending || {}
|
||||
const booking = d.booking || {}
|
||||
const customer = d.customer || {}
|
||||
const abook = d.abook || {}
|
||||
const internet = d.internet || {}
|
||||
const home = d.home || {}
|
||||
const internetStatus = d.internetStatus || {}
|
||||
const homeStatus = d.homeStatus || {}
|
||||
|
||||
const columnCfg = (daily: { day: number; value: number }[] | undefined, fill: string) => ({
|
||||
data: daily || [],
|
||||
xField: 'day',
|
||||
yField: 'value',
|
||||
height: 180,
|
||||
autoFit: true,
|
||||
color: fill,
|
||||
columnStyle: { radius: [3, 3, 0, 0] },
|
||||
xAxis: { label: { style: { fill: accents.plot.axisLabelFill, fontSize: 10 } }, line: null, tickLine: null },
|
||||
yAxis: {
|
||||
label: { style: { fill: accents.plot.axisLabelFill, fontSize: 10 } },
|
||||
grid: { line: { style: { stroke: accents.plot.gridStroke } } } },
|
||||
tooltip: { title: (v: number) => `${v}일` } })
|
||||
|
||||
const areaCfg = (daily: { day: number; value: number }[] | undefined, stroke: string, fill: string) => ({
|
||||
data: daily || [],
|
||||
xField: 'day',
|
||||
yField: 'value',
|
||||
height: 180,
|
||||
autoFit: true,
|
||||
smooth: true,
|
||||
color: stroke,
|
||||
areaStyle: { fill },
|
||||
line: { color: stroke, size: 2 },
|
||||
xAxis: { label: { style: { fill: accents.plot.axisLabelFill, fontSize: 10 } }, line: null, tickLine: null },
|
||||
yAxis: {
|
||||
label: { style: { fill: accents.plot.axisLabelFill, fontSize: 10 } },
|
||||
grid: { line: { style: { stroke: accents.plot.gridStroke } } } } })
|
||||
|
||||
return (
|
||||
<div className="home-dashboard">
|
||||
<div className="home-top-row">
|
||||
<Card className="home-card" size="small">
|
||||
<div className="home-stock-split">
|
||||
<Link to="/care/pending/pending" className="home-stock-block">
|
||||
<div className="home-stock-head">
|
||||
<div>
|
||||
<div className="home-card-title">
|
||||
<CalendarOutlined /> 일정관리
|
||||
</div>
|
||||
<div className="home-stock-total">
|
||||
{pending.open ?? 0} <span className="home-stock-hint">/ {pending.total ?? 0}</span>
|
||||
</div>
|
||||
<div className="home-stock-hint">오늘 약속 {pending.today ?? 0}건</div>
|
||||
</div>
|
||||
<MiniBars values={pending.spark || [2, 3, 4, 3, 5, 4]} color={accents.sparkPhone} />
|
||||
</div>
|
||||
<div className="home-stock-meta">
|
||||
<span>
|
||||
미결 <b>{pending.open ?? 0}</b>
|
||||
</span>
|
||||
<span>
|
||||
오늘 <b>{pending.today ?? 0}</b>
|
||||
</span>
|
||||
</div>
|
||||
<Wave color={accents.sparkPhone} />
|
||||
</Link>
|
||||
<Link to="/care/booking/booking" className="home-stock-block">
|
||||
<div className="home-stock-head">
|
||||
<div>
|
||||
<div className="home-card-title">
|
||||
<ScheduleOutlined /> 예약관리
|
||||
</div>
|
||||
<div className="home-stock-total">
|
||||
{booking.open ?? 0} <span className="home-stock-hint">/ {booking.total ?? 0}</span>
|
||||
</div>
|
||||
<div className="home-stock-hint">접수 대기 현황</div>
|
||||
</div>
|
||||
<MiniBars values={booking.spark || [1, 2, 2, 3, 2, 1]} color={accents.sparkUsim} />
|
||||
</div>
|
||||
<div className="home-stock-meta">
|
||||
<span>
|
||||
접수 <b>{booking.open ?? 0}</b>
|
||||
</span>
|
||||
<span>
|
||||
전체 <b>{booking.total ?? 0}</b>
|
||||
</span>
|
||||
</div>
|
||||
<Wave color={accents.sparkUsim} />
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="home-card" size="small">
|
||||
<div className="home-status-split">
|
||||
<Link to="/care/customer/customer" className="home-status-block">
|
||||
<div className="home-status-top">
|
||||
<div>
|
||||
<div className="home-card-title">등록고객</div>
|
||||
<div className="home-status-value">
|
||||
{customer.total ?? d.customerTotal ?? 0}명
|
||||
</div>
|
||||
<div className="home-stock-hint">이달 신규 {customer.monthNew ?? 0}</div>
|
||||
</div>
|
||||
<TeamOutlined className="home-status-icon teal" />
|
||||
</div>
|
||||
<Wave color={accents.waveOut} />
|
||||
</Link>
|
||||
<Link to="/care/abooks/abooks-mon" className="home-status-block">
|
||||
<div className="home-status-top">
|
||||
<div>
|
||||
<div className="home-card-title">이달장부</div>
|
||||
<div className="home-status-value">{money(abook.monthAmount)}</div>
|
||||
<div className="home-stock-hint">정산 {money(abook.settleMonth)}</div>
|
||||
</div>
|
||||
<BookOutlined className="home-status-icon purple" />
|
||||
</div>
|
||||
<Wave color={accents.waveFare} />
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="home-postit-row">
|
||||
{(d.postits || []).map((p) => (
|
||||
<div key={p.slot} className="home-postit">
|
||||
<div className="home-postit-head">
|
||||
<span>{p.title || `포스트잇 #${p.slot}`}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="수정"
|
||||
onClick={() => {
|
||||
setEditSlot(p)
|
||||
form.setFieldsValue({ contents: p.contents })
|
||||
}}
|
||||
>
|
||||
<EditOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="home-postit-body">{p.contents || ''}</div>
|
||||
<div className="home-postit-foot">
|
||||
<span>by {p.authorName || '-'}</span>
|
||||
<span>{p.noteDate || ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="home-chart-row">
|
||||
<Card
|
||||
className="home-card"
|
||||
size="small"
|
||||
title={
|
||||
<div className="home-chart-head">
|
||||
<span>
|
||||
<button type="button" className="home-month-nav" onClick={() => setOffset((o) => o + 1)}>
|
||||
<LeftOutlined />
|
||||
</button>
|
||||
인터넷접수 (영업기준)
|
||||
<button
|
||||
type="button"
|
||||
className="home-month-nav"
|
||||
onClick={() => setOffset((o) => Math.max(0, o - 1))}
|
||||
>
|
||||
<RightOutlined />
|
||||
</button>
|
||||
</span>
|
||||
<Tabs
|
||||
size="small"
|
||||
activeKey={period}
|
||||
onChange={setPeriod}
|
||||
items={[
|
||||
{ key: 'day', label: '매일' },
|
||||
{ key: 'month', label: '매달' },
|
||||
{ key: 'total', label: '전체' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="home-chart-sub">
|
||||
<span>{internet.label}</span>
|
||||
<span className="muted">계약 {internet.count ?? 0}건</span>
|
||||
</div>
|
||||
<div className="home-chart-stats four">
|
||||
<div>
|
||||
<em>신청</em>
|
||||
<b>{internet.apply ?? 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>접수</em>
|
||||
<b>{internet.accept ?? 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>완료</em>
|
||||
<b>{internet.done ?? 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>정산금액</em>
|
||||
<b>{money(internet.settleAmount)}</b>
|
||||
</div>
|
||||
</div>
|
||||
<div className="home-chart-plot">
|
||||
<Column {...columnCfg(internet.daily, accents.columnFill)} />
|
||||
</div>
|
||||
<div className="home-cat-block">
|
||||
<div className="home-cat-total">인터넷현황 {internetStatus.total ?? 0}</div>
|
||||
<div className="home-cat-grid">
|
||||
{Object.entries(internetStatus.items || {}).map(([k, v]) => (
|
||||
<div key={k} className="home-cat-item">
|
||||
<MiniBars values={[2, 3, 4, 3, Number(v) || 1, 2]} color={accents.sparkPhone} />
|
||||
<span>{k}</span>
|
||||
<b>{v}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="home-card"
|
||||
size="small"
|
||||
title={
|
||||
<div className="home-chart-head">
|
||||
<span>홈렌탈접수 (영업기준)</span>
|
||||
<Tabs
|
||||
size="small"
|
||||
activeKey={period}
|
||||
onChange={setPeriod}
|
||||
items={[
|
||||
{ key: 'day', label: '매일' },
|
||||
{ key: 'month', label: '매달' },
|
||||
{ key: 'total', label: '전체' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="home-chart-sub">
|
||||
<span>{home.label}</span>
|
||||
<span className="muted">계약 {home.count ?? 0}건</span>
|
||||
</div>
|
||||
<div className="home-chart-stats four">
|
||||
<div>
|
||||
<em>신청</em>
|
||||
<b>{home.apply ?? 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>접수</em>
|
||||
<b>{home.accept ?? 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>완료</em>
|
||||
<b>{home.done ?? 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>정산금액</em>
|
||||
<b>{money(home.settleAmount)}</b>
|
||||
</div>
|
||||
</div>
|
||||
<div className="home-chart-plot">
|
||||
<Area
|
||||
{...areaCfg(
|
||||
home.daily,
|
||||
accents.columnFill,
|
||||
theme === 'light' ? 'rgba(37,99,235,0.22)' : 'rgba(122,162,247,0.28)',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="home-cat-block">
|
||||
<div className="home-cat-total">홈렌탈현황 {homeStatus.total ?? 0}</div>
|
||||
<div className="home-cat-grid">
|
||||
{Object.entries(homeStatus.items || {}).map(([k, v]) => (
|
||||
<div key={k} className="home-cat-item">
|
||||
<MiniBars values={[1, 2, 3, Number(v) || 1, 2, 3]} color={accents.areaStroke} />
|
||||
<span>{k}</span>
|
||||
<b>{v}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="home-bottom-row">
|
||||
<Card
|
||||
className="home-card home-list-card"
|
||||
size="small"
|
||||
title={
|
||||
<Link to="/care/cs/notice">
|
||||
공지사항
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<ul className="home-list notice">
|
||||
{(d.notices || []).length === 0 ? (
|
||||
<li className="empty">등록된 공지가 없습니다.</li>
|
||||
) : (
|
||||
(d.notices || []).map((n) => (
|
||||
<li key={n.id}>
|
||||
<Link to="/care/cs/notice">{n.title}</Link>
|
||||
<span>{n.createdDate}</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</Card>
|
||||
<Card
|
||||
className="home-card home-list-card"
|
||||
size="small"
|
||||
title={<Link to="/care/cs/question">사용문의</Link>}
|
||||
>
|
||||
<ul className="home-list notice">
|
||||
{(d.questions || []).length === 0 ? (
|
||||
<li className="empty">등록된 문의가 없습니다.</li>
|
||||
) : (
|
||||
(d.questions || []).map((q) => (
|
||||
<li key={q.id}>
|
||||
<Link to="/care/cs/question">{q.title}</Link>
|
||||
<span>{q.createdDate}</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</Card>
|
||||
<Card
|
||||
className="home-card home-cs-card"
|
||||
size="small"
|
||||
title={
|
||||
<span>
|
||||
<CustomerServiceOutlined /> 고객센터
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="home-cs-phone">{d.csPhone || '0000-0000'}</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={editSlot ? `포스트잇 #${editSlot.slot}` : '포스트잇'}
|
||||
open={Boolean(editSlot)}
|
||||
onCancel={() => setEditSlot(null)}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={savePostit.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => savePostit.mutate(v)}>
|
||||
<Form.Item name="contents" label="내용" rules={[{ required: true, message: '내용을 입력하세요' }]}>
|
||||
<Input.TextArea rows={4} maxLength={200} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
Button, Card, Checkbox, DatePicker, Form, Select, Space, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
CloseOutlined, DownloadOutlined, EditOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import type { Row } from './homesShared'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
type LogPageResult = {
|
||||
total: number
|
||||
list: Row[]
|
||||
actions?: string[]
|
||||
staffs?: string[]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
domain: string
|
||||
title: string
|
||||
description: string
|
||||
defaultActions?: string[]
|
||||
titleIcon?: ReactNode
|
||||
}
|
||||
|
||||
export function HomesLogListPage({ domain, title, description, defaultActions = [], titleIcon }: Props) {
|
||||
const [form] = Form.useForm()
|
||||
const qc = useQueryClient()
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({
|
||||
domain,
|
||||
page: 0,
|
||||
size: 10 })
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes/logs', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<LogPageResult>>(`${apiPrefix()}/homes/logs`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/logs/delete`, { ids })
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
setSelected([])
|
||||
qc.invalidateQueries({ queryKey: ['homes/logs'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
const clearDates = () => form.setFieldValue('dates', undefined)
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setSelected([])
|
||||
setFilters({
|
||||
domain,
|
||||
action: values.action || undefined,
|
||||
staff: values.staff || undefined,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 10 })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ size: 10 })
|
||||
setSelected([])
|
||||
setFilters({ domain, page: 0, size: 10 })
|
||||
}
|
||||
|
||||
const download = () => {
|
||||
const rows = query.data?.list || []
|
||||
if (!rows.length) {
|
||||
message.warning('다운로드할 목록이 없습니다.')
|
||||
return
|
||||
}
|
||||
const header = ['구분', '내용', '직원', '처리일']
|
||||
const lines = rows.map((r) =>
|
||||
[r.action, r.content, r.staff, r.processedAt]
|
||||
.map((v) => `"${String(v ?? '').replace(/"/g, '""')}"`)
|
||||
.join(','),
|
||||
)
|
||||
const blob = new Blob(['\uFEFF' + [header.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${title}_${dayjs().format('YYYYMMDD')}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const actionOptions = useMemo(() => {
|
||||
const fromApi = query.data?.actions || []
|
||||
return Array.from(new Set([...defaultActions, ...fromApi])).map((v) => ({ value: v, label: v }))
|
||||
}, [query.data?.actions, defaultActions])
|
||||
|
||||
const staffOptions = useMemo(
|
||||
() => (query.data?.staffs || []).map((v) => ({ value: v, label: v })),
|
||||
[query.data?.staffs],
|
||||
)
|
||||
|
||||
const list = query.data?.list || []
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: (
|
||||
<Checkbox
|
||||
checked={list.length > 0 && selected.length === list.length}
|
||||
indeterminate={selected.length > 0 && selected.length < list.length}
|
||||
onChange={(e) => setSelected(e.target.checked ? list.map((r) => r.id) : [])}
|
||||
/>
|
||||
),
|
||||
width: 48,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<Checkbox
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={(e) => {
|
||||
setSelected((prev) => (e.target.checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)))
|
||||
}}
|
||||
/>
|
||||
) },
|
||||
{ title: '구분', dataIndex: 'action', width: 180 },
|
||||
{ title: '내용', dataIndex: 'content', ellipsis: true },
|
||||
{ title: '직원', dataIndex: 'staff', width: 100 },
|
||||
{ title: '처리일', dataIndex: 'processedAt', width: 160 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={`stock-page product-log-page${domain === 'PENDING' ? ' pending-log-page' : ''}`}>
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
{titleIcon ?? <EditOutlined className="product-title-icon" />}
|
||||
{title}
|
||||
</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
loading={remove.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('삭제할 이력을 선택하세요.')
|
||||
return
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '이력 삭제',
|
||||
content: `선택한 ${selected.length}건의 이력을 삭제하시겠습니까?`,
|
||||
onOk: () => remove.mutateAsync(selected) })
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form form={form} layout="inline" className="home-filter" initialValues={{ size: 10 }} onFinish={onSearch}>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="action">
|
||||
<Select allowClear placeholder="구분" className="w-170" options={actionOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="staff">
|
||||
<Select allowClear placeholder="직원" className="w-130" options={staffOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={clearDates} />
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((prev) => ({ ...prev, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="recovery-table-head">
|
||||
<Typography.Text>목록 <b>{query.data?.total ?? 0}</b></Typography.Text>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={list}
|
||||
columns={columns}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: Number(filters.page || 0) + 1,
|
||||
pageSize: Number(filters.size || 10),
|
||||
total: query.data?.total || 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => {
|
||||
setSelected([])
|
||||
setFilters((prev) => ({ ...prev, page: page - 1 }))
|
||||
} }}
|
||||
locale={{ emptyText: '등록되어 있는 자료가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
EditOutlined, FileTextOutlined, PlusOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type InCompanyRow = {
|
||||
id: number
|
||||
status?: string
|
||||
active?: boolean
|
||||
name?: string
|
||||
telecom?: string
|
||||
phone?: string
|
||||
fax?: string
|
||||
managerName?: string
|
||||
mobile?: string
|
||||
businessName?: string
|
||||
businessNumber?: string
|
||||
createdAt?: string
|
||||
memo?: string
|
||||
}
|
||||
|
||||
type InCompanyData = {
|
||||
total: number
|
||||
list: InCompanyRow[]
|
||||
}
|
||||
|
||||
const telecomOptions = ['SKT', 'KT', 'LGU+', 'LGT', 'SK텔링크', 'KT엠모바일', 'LG헬로비전 (KT)', 'mKT']
|
||||
|
||||
export default function InCompanyPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<InCompanyRow | null>(null)
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({
|
||||
searchType: '업체명',
|
||||
page: 0,
|
||||
size: 15,
|
||||
sort: 'name,asc' })
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['incompanies', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<InCompanyData>>(`${apiPrefix()}/incompanies`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => {
|
||||
setSelected([])
|
||||
qc.invalidateQueries({ queryKey: ['incompanies'] })
|
||||
}
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
if (editing?.id) {
|
||||
const { data } = await client.put(`${apiPrefix()}/incompanies/${editing.id}`, values)
|
||||
return unwrap(data)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/incompanies`, values)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '입고처를 수정했습니다.' : '입고처를 등록했습니다.')
|
||||
setOpen(false)
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const statusMut = useMutation({
|
||||
mutationFn: async (status: '사용' | '미사용') => {
|
||||
const { data } = await client.post(`${apiPrefix()}/incompanies/status`, { ids: selected, status })
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (_d, status) => {
|
||||
message.success(`${status} 처리했습니다.`)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
searchType: values.searchType || '업체명',
|
||||
keyword: values.keyword,
|
||||
page: 0,
|
||||
size: Number(values.size ?? prev.size ?? 15) }))
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.setFieldsValue({ searchType: '업체명', keyword: undefined, size: 15 })
|
||||
setFilters({ searchType: '업체명', page: 0, size: 15, sort: 'name,asc' })
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
editForm.setFieldsValue({
|
||||
name: undefined,
|
||||
telecom: undefined,
|
||||
phone: undefined,
|
||||
fax: undefined,
|
||||
managerName: undefined,
|
||||
mobile: undefined,
|
||||
businessName: undefined,
|
||||
businessNumber: undefined,
|
||||
memo: undefined,
|
||||
status: '사용' })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (row: InCompanyRow) => {
|
||||
setEditing(row)
|
||||
editForm.setFieldsValue({
|
||||
name: row.name,
|
||||
telecom: row.telecom,
|
||||
phone: row.phone,
|
||||
fax: row.fax,
|
||||
managerName: row.managerName,
|
||||
mobile: row.mobile,
|
||||
businessName: row.businessName,
|
||||
businessNumber: row.businessNumber,
|
||||
memo: row.memo,
|
||||
status: row.status || '사용' })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
const { data } = await client.get(`${apiPrefix()}/incompanies/export`, {
|
||||
params: filters,
|
||||
responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '입고처관리.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const requireSelected = (status: '사용' | '미사용') => {
|
||||
if (!selected.length) {
|
||||
message.warning('항목을 선택하세요.')
|
||||
return
|
||||
}
|
||||
statusMut.mutate(status)
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '상태',
|
||||
dataIndex: 'status',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => <span className={v === '미사용' ? 'farebox-cancelled' : ''}>{v}</span> },
|
||||
{ title: '업체명', dataIndex: 'name', width: 140, ellipsis: true, sorter: true },
|
||||
{ title: '통신사', dataIndex: 'telecom', width: 130, ellipsis: true, sorter: true },
|
||||
{ title: '전화', dataIndex: 'phone', width: 120, align: 'center' as const },
|
||||
{ title: '팩스', dataIndex: 'fax', width: 120, align: 'center' as const },
|
||||
{ title: '담당자', dataIndex: 'managerName', width: 90, align: 'center' as const },
|
||||
{ title: '휴대폰', dataIndex: 'mobile', width: 120, align: 'center' as const },
|
||||
{ title: '상호', dataIndex: 'businessName', width: 110, ellipsis: true },
|
||||
{ title: '사업자번호', dataIndex: 'businessNumber', width: 120, align: 'center' as const },
|
||||
{ title: '등록일', dataIndex: 'createdAt', width: 110, align: 'center' as const, sorter: true },
|
||||
{ title: '비고', dataIndex: 'memo', width: 120, ellipsis: true },
|
||||
{
|
||||
title: '관리',
|
||||
key: 'manage',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, row: InCompanyRow) => (
|
||||
<Button type="primary" size="small" className="staff-manage-btn" icon={<FileTextOutlined />} onClick={() => openEdit(row)} />
|
||||
) },
|
||||
], [])
|
||||
|
||||
return (
|
||||
<div className="stock-page incompany-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><SettingOutlined style={{ marginRight: 8 }} />입고처관리</h2>
|
||||
<p>거래하고 있는 입고처를 등록 혹은 관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap>
|
||||
<Button
|
||||
className="farebox-gray-btn"
|
||||
disabled={!selected.length}
|
||||
loading={statusMut.isPending}
|
||||
onClick={() => requireSelected('사용')}
|
||||
>
|
||||
사용
|
||||
</Button>
|
||||
<Button
|
||||
className="farebox-gray-btn"
|
||||
disabled={!selected.length}
|
||||
loading={statusMut.isPending}
|
||||
onClick={() => requireSelected('미사용')}
|
||||
>
|
||||
미사용
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>입고처 등록</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="farebox-filter"
|
||||
initialValues={{ searchType: '업체명', size: 15 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="farebox-filter-row">
|
||||
<Form.Item name="searchType">
|
||||
<Select
|
||||
className="w-120"
|
||||
options={['업체명', '통신사', '담당자', '상호', '사업자번호'].map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-180" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button onClick={onReset}>초기화</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="size" className="farebox-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => setFilters((prev) => ({ ...prev, size, page: 0 }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="farebox-table-toolbar">
|
||||
<Typography.Text>목록 {query.data?.total ?? 0}</Typography.Text>
|
||||
<Button type="link" icon={<FileTextOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
rowKey="id"
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
bordered
|
||||
rowSelection={{
|
||||
selectedRowKeys: selected,
|
||||
onChange: (keys) => setSelected(keys.map(Number)) }}
|
||||
pagination={{
|
||||
current: Number(filters.page ?? 0) + 1,
|
||||
pageSize: Number(filters.size ?? 15),
|
||||
total: query.data?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
onChange={(_p, _f, sorter) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter
|
||||
if (!s?.field) return
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort: `${String(s.field)},${s.order === 'ascend' ? 'asc' : 'desc'}` }))
|
||||
}}
|
||||
scroll={{ x: 1400 }}
|
||||
locale={{ emptyText: '입고처가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '입고처 수정' : '입고처 등록'}
|
||||
open={open}
|
||||
onCancel={() => { setOpen(false); setEditing(null) }}
|
||||
footer={null}
|
||||
width={560}
|
||||
destroyOnHidden
|
||||
centered
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
labelCol={{ flex: '100px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
labelAlign="left"
|
||||
colon={false}
|
||||
onFinish={(values) => saveMut.mutate(values)}
|
||||
>
|
||||
<Form.Item name="name" label="업체명" rules={[{ required: true, message: '업체명을 입력하세요.' }]}>
|
||||
<Input placeholder="업체명을 입력하세요" />
|
||||
</Form.Item>
|
||||
<Form.Item name="telecom" label="통신사">
|
||||
<Select allowClear placeholder="-통신사-" options={telecomOptions.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="전화">
|
||||
<Input placeholder="02-0000-0000" />
|
||||
</Form.Item>
|
||||
<Form.Item name="fax" label="팩스">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="managerName" label="담당자">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="mobile" label="휴대폰">
|
||||
<Input placeholder="010-0000-0000" />
|
||||
</Form.Item>
|
||||
<Form.Item name="businessName" label="상호">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="businessNumber" label="사업자번호">
|
||||
<Input placeholder="000-00-00000" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="상태">
|
||||
<Select options={['사용', '미사용'].map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="비고">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<EditOutlined />}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { EditOutlined, SearchOutlined, UnorderedListOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type BoardRow = {
|
||||
id: number
|
||||
no?: number
|
||||
title?: string
|
||||
authorName?: string
|
||||
views?: number
|
||||
commentCount?: number
|
||||
createdDate?: string
|
||||
contents?: string
|
||||
}
|
||||
|
||||
type BoardData = {
|
||||
total: number
|
||||
list: BoardRow[]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
code: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export default function InternalBoardPage({ code, title }: Props) {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<BoardRow | null>(null)
|
||||
const [detail, setDetail] = useState<BoardRow | null>(null)
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({
|
||||
code,
|
||||
searchType: '제목',
|
||||
page: 0,
|
||||
size: 15,
|
||||
sort: 'createdAt,desc' })
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['internal-board', code, filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<BoardData>>(`${apiPrefix()}/bbs/search`, {
|
||||
params: { ...filters, code } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['internal-board', code] })
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const body = { ...values, code }
|
||||
if (editing?.id) {
|
||||
const { data } = await client.put(`${apiPrefix()}/bbs/${editing.id}`, body)
|
||||
return unwrap(data)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/bbs`, body)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '게시물을 수정했습니다.' : '게시물을 등록했습니다.')
|
||||
setOpen(false)
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const openDetail = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.get<ApiResponse<BoardRow>>(`${apiPrefix()}/bbs/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (row) => {
|
||||
setDetail(row)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
code,
|
||||
searchType: values.searchType || '제목',
|
||||
keyword: values.keyword,
|
||||
page: 0,
|
||||
size: Number(values.size ?? prev.size ?? 15) }))
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.setFieldsValue({ searchType: '제목', keyword: undefined, size: 15 })
|
||||
setFilters({ code, searchType: '제목', page: 0, size: 15, sort: 'createdAt,desc' })
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (row: BoardRow) => {
|
||||
setEditing(row)
|
||||
editForm.setFieldsValue({ title: row.title, contents: row.contents })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '번호',
|
||||
dataIndex: 'no',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '제목',
|
||||
dataIndex: 'title',
|
||||
ellipsis: true,
|
||||
render: (v: string, row: BoardRow) => (
|
||||
<Button type="link" className="cs-notice-title" onClick={() => openDetail.mutate(row.id)}>
|
||||
{v}
|
||||
{row.commentCount && row.commentCount > 0 ? (
|
||||
<span className="bbs-comment-badge">{row.commentCount}</span>
|
||||
) : null}
|
||||
</Button>
|
||||
) },
|
||||
{
|
||||
title: '직원',
|
||||
dataIndex: 'authorName',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '조회',
|
||||
dataIndex: 'views',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '등록일',
|
||||
dataIndex: 'createdDate',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
], [])
|
||||
|
||||
return (
|
||||
<div className="stock-page cs-notice-page internal-board-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><UnorderedListOutlined style={{ marginRight: 8 }} />{title}</h2>
|
||||
</div>
|
||||
<Button type="primary" icon={<EditOutlined />} onClick={openCreate}>게시물 등록</Button>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="farebox-filter"
|
||||
initialValues={{ searchType: '제목', size: 15 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="farebox-filter-row">
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-110" options={['제목', '직원', '내용'].map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-230" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button onClick={onReset}>초기화</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="size" className="farebox-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => setFilters((prev) => ({ ...prev, size, page: 0 }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="farebox-table-toolbar">
|
||||
<Typography.Text>목록 {query.data?.total ?? 0}</Typography.Text>
|
||||
</div>
|
||||
<CareTable
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
rowKey="id"
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
current: Number(filters.page ?? 0) + 1,
|
||||
pageSize: Number(filters.size ?? 15),
|
||||
total: query.data?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
onChange={(_p, _f, sorter) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter
|
||||
if (!s?.field) return
|
||||
const fieldMap: Record<string, string> = {
|
||||
no: 'id',
|
||||
authorName: 'authorName',
|
||||
views: 'views',
|
||||
createdDate: 'createdAt',
|
||||
title: 'title' }
|
||||
const field = fieldMap[String(s.field)] || String(s.field)
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort: `${field},${s.order === 'ascend' ? 'asc' : 'desc'}`,
|
||||
page: 0 }))
|
||||
}}
|
||||
locale={{ emptyText: '등록되어 있는 게시물이 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '게시물 수정' : '게시물 등록'}
|
||||
open={open}
|
||||
onCancel={() => { setOpen(false); setEditing(null) }}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
width={720}
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => saveMut.mutate(values)}
|
||||
>
|
||||
<Form.Item name="title" label="제목" rules={[{ required: true, message: '제목을 입력하세요.' }]}>
|
||||
<Input maxLength={200} />
|
||||
</Form.Item>
|
||||
<Form.Item name="contents" label="내용" rules={[{ required: true, message: '내용을 입력하세요.' }]}>
|
||||
<Input.TextArea rows={10} />
|
||||
</Form.Item>
|
||||
<div className="bbs-write-actions">
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={detail?.title || title}
|
||||
open={!!detail}
|
||||
onCancel={() => setDetail(null)}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={() => { if (detail) openEdit(detail); setDetail(null) }}>수정</Button>
|
||||
<Button onClick={() => setDetail(null)}>닫기</Button>
|
||||
</Space>
|
||||
}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="cs-notice-meta">
|
||||
<span>직원 {detail?.authorName || '-'}</span>
|
||||
<span>등록일 {detail?.createdDate || '-'}</span>
|
||||
<span>조회 {detail?.views ?? 0}</span>
|
||||
</div>
|
||||
<div
|
||||
className="cs-notice-body"
|
||||
style={{ whiteSpace: 'pre-wrap' }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: detail?.contents?.includes('<') ? (detail.contents || '') : (detail?.contents || '-').replace(/\n/g, '<br/>') }}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CompanyBoardPage() {
|
||||
return <InternalBoardPage code="company" title="사내게시판" />
|
||||
}
|
||||
|
||||
export function DataBoardPage() {
|
||||
return <InternalBoardPage code="grouppds" title="자료게시판" />
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { HomesLogListPage } from './HomesLogListPage'
|
||||
import { FileTextOutlined } from '@ant-design/icons'
|
||||
|
||||
const ACTIONS = [
|
||||
'정산서-발행',
|
||||
'정산서-재발행',
|
||||
'정산서-삭제',
|
||||
'정산서-등록',
|
||||
'정산서-수정',
|
||||
]
|
||||
|
||||
export default function InvoiceLogPage() {
|
||||
return (
|
||||
<HomesLogListPage
|
||||
domain="INVOICE"
|
||||
title="정산서이력"
|
||||
description="정산서업무의 변동이력을 확인하실 수 있습니다."
|
||||
defaultActions={ACTIONS}
|
||||
titleIcon={<FileTextOutlined className="product-title-icon" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { Button, Card, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { CalculatorOutlined, FileExcelOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { money } from './homesShared'
|
||||
|
||||
type MyInvoiceRow = {
|
||||
id: number
|
||||
yearMonth?: string
|
||||
employee?: string
|
||||
internetCount?: number
|
||||
internetAmount?: number
|
||||
homeCount?: number
|
||||
homeAmount?: number
|
||||
balanceCount?: number
|
||||
balanceAmount?: number
|
||||
settleAmount?: number
|
||||
amount?: number
|
||||
status?: string
|
||||
}
|
||||
|
||||
type MyInvoiceResult = {
|
||||
total: number
|
||||
employee?: string
|
||||
list: MyInvoiceRow[]
|
||||
}
|
||||
|
||||
const num = (v: unknown) => {
|
||||
const n = Number(v ?? 0)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
const cell = (v: unknown) => (num(v) === 0 ? '' : num(v).toLocaleString())
|
||||
const moneyCell = (v: unknown) => (num(v) === 0 ? '' : money(v))
|
||||
|
||||
const formatYm = (ym?: string) => {
|
||||
if (!ym) return '-'
|
||||
const d = dayjs(`${ym}-01`)
|
||||
return d.isValid() ? d.format('YYYY년 M월') : ym
|
||||
}
|
||||
|
||||
export default function InvoiceMyPage() {
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-invoice-my'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<MyInvoiceResult>>(`${apiPrefix()}/homes/invoices/my`)
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const download = async (row: MyInvoiceRow) => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/homes/invoices/${row.id}/export`, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `나의정산서_${row.yearMonth || row.id}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '기준월',
|
||||
dataIndex: 'yearMonth',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => formatYm(v) },
|
||||
{ title: '직원', dataIndex: 'employee', width: 100, align: 'center' as const },
|
||||
{
|
||||
title: '인터넷',
|
||||
dataIndex: 'internetCount',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: cell },
|
||||
{
|
||||
title: '인터넷금액',
|
||||
dataIndex: 'internetAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: moneyCell },
|
||||
{
|
||||
title: '홈렌탈',
|
||||
dataIndex: 'homeCount',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: cell },
|
||||
{
|
||||
title: '홈렌탈금액',
|
||||
dataIndex: 'homeAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: moneyCell },
|
||||
{
|
||||
title: '후정산',
|
||||
dataIndex: 'balanceCount',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: cell },
|
||||
{
|
||||
title: '후정산금액',
|
||||
dataIndex: 'balanceAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: moneyCell },
|
||||
{
|
||||
title: '정산금액',
|
||||
dataIndex: 'settleAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: MyInvoiceRow) => moneyCell(row.settleAmount ?? row.amount) },
|
||||
{
|
||||
title: '엑셀',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: MyInvoiceRow) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
className="invoice-excel-link"
|
||||
icon={<FileExcelOutlined />}
|
||||
onClick={() => download(row)}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '상태',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => v || '-' },
|
||||
], [])
|
||||
|
||||
return (
|
||||
<div className="stock-page invoice-my-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<CalculatorOutlined className="product-title-icon" />
|
||||
나의정산서
|
||||
</h2>
|
||||
<p>최근 12개월 이내 발행된 정산서를 확인하실 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
scroll={{ x: 1100 }}
|
||||
locale={{ emptyText: '등록된 자료가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { Button, Card, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
CalculatorOutlined,
|
||||
DownloadOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { money } from './homesShared'
|
||||
|
||||
type PublishRow = {
|
||||
id?: number | null
|
||||
employee: string
|
||||
yearMonth: string
|
||||
internetCount?: number
|
||||
internetAmount?: number
|
||||
homeCount?: number
|
||||
homeAmount?: number
|
||||
balanceCount?: number
|
||||
balanceAmount?: number
|
||||
settleAmount?: number
|
||||
issued?: boolean
|
||||
status?: string
|
||||
consent?: string
|
||||
taxInvoice?: string
|
||||
withdrawStatus?: string
|
||||
mismatched?: boolean
|
||||
}
|
||||
|
||||
type PublishResult = {
|
||||
yearMonth: string
|
||||
total: number
|
||||
list: PublishRow[]
|
||||
}
|
||||
|
||||
const num = (v: unknown) => {
|
||||
const n = Number(v ?? 0)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
const cell = (v: unknown) => (num(v) === 0 ? '' : num(v).toLocaleString())
|
||||
const moneyCell = (v: unknown, mismatched?: boolean) => {
|
||||
if (num(v) === 0) return ''
|
||||
return <span className={mismatched ? 'invoice-mismatch' : undefined}>{money(v)}</span>
|
||||
}
|
||||
|
||||
export default function InvoicePublishPage() {
|
||||
const qc = useQueryClient()
|
||||
const [cursor, setCursor] = useState(() => dayjs().startOf('month'))
|
||||
const yearMonth = cursor.format('YYYY-MM')
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-invoice-publish', yearMonth],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<PublishResult>>(`${apiPrefix()}/homes/invoices/publish`, {
|
||||
params: { yearMonth } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const issue = useMutation({
|
||||
mutationFn: async (row: PublishRow) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/invoices/publish/issue`, {
|
||||
yearMonth: row.yearMonth || yearMonth,
|
||||
employee: row.employee })
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('정산서를 발행했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes-invoice-publish'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message || '발행에 실패했습니다.') })
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/homes/invoices/publish/export`, {
|
||||
params: { yearMonth },
|
||||
responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `정산서발행_${yearMonth}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '직원',
|
||||
dataIndex: 'employee',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
render: (v: string, row: PublishRow) => (
|
||||
<span className={row.mismatched ? 'invoice-mismatch' : undefined}>{v}</span>
|
||||
) },
|
||||
{
|
||||
title: '인터넷',
|
||||
dataIndex: 'internetCount',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => (
|
||||
<span className={row.mismatched ? 'invoice-mismatch' : undefined}>{cell(v)}</span>
|
||||
) },
|
||||
{
|
||||
title: '인터넷금액',
|
||||
dataIndex: 'internetAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => moneyCell(v, row.mismatched) },
|
||||
{
|
||||
title: '홈렌탈',
|
||||
dataIndex: 'homeCount',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => (
|
||||
<span className={row.mismatched ? 'invoice-mismatch' : undefined}>{cell(v)}</span>
|
||||
) },
|
||||
{
|
||||
title: '홈렌탈금액',
|
||||
dataIndex: 'homeAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => moneyCell(v, row.mismatched) },
|
||||
{
|
||||
title: '후정산',
|
||||
dataIndex: 'balanceCount',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => (
|
||||
<span className={row.mismatched ? 'invoice-mismatch' : undefined}>{cell(v)}</span>
|
||||
) },
|
||||
{
|
||||
title: '후정산금액',
|
||||
dataIndex: 'balanceAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => moneyCell(v, row.mismatched) },
|
||||
{
|
||||
title: '정산금액',
|
||||
dataIndex: 'settleAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => moneyCell(v, row.mismatched) },
|
||||
{
|
||||
title: '정산서발행',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: PublishRow) => (
|
||||
row.issued ? (
|
||||
<Button type="link" size="small" className="invoice-issued-link" onClick={() => issue.mutate(row)}>
|
||||
재발행
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
className="invoice-issue-link"
|
||||
loading={issue.isPending}
|
||||
onClick={() => issue.mutate(row)}
|
||||
>
|
||||
발행
|
||||
</Button>
|
||||
)
|
||||
) },
|
||||
{
|
||||
title: '동의',
|
||||
dataIndex: 'consent',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '계산서',
|
||||
dataIndex: 'taxInvoice',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '출금상태',
|
||||
dataIndex: 'withdrawStatus',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => v || '-' },
|
||||
], [issue])
|
||||
|
||||
return (
|
||||
<div className="stock-page invoice-publish-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<CalculatorOutlined className="product-title-icon" />
|
||||
정산서발행
|
||||
</h2>
|
||||
<p>
|
||||
직원 및 하부점에 정산서를 발행 및 관리하실 수 있습니다. (계약 진행상태가 완료인 자료만 정산서를 발행하실 수 있습니다.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card invoice-month-card" size="small">
|
||||
<div className="invoice-month-bar">
|
||||
<div className="invoice-month-nav">
|
||||
<button type="button" aria-label="이전 달" onClick={() => setCursor((c) => c.subtract(1, 'month'))}>
|
||||
<LeftOutlined />
|
||||
</button>
|
||||
<span>{cursor.format('YYYY년 M월')}</span>
|
||||
<button type="button" aria-label="다음 달" onClick={() => setCursor((c) => c.add(1, 'month'))}>
|
||||
<RightOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<CareTable
|
||||
rowKey={(row) => `${row.employee}-${row.yearMonth}`}
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
scroll={{ x: 1200 }}
|
||||
locale={{ emptyText: '등록된 자료가 없습니다.' }}
|
||||
rowClassName={(row) => (row.mismatched ? 'invoice-row-mismatch' : '')}
|
||||
/>
|
||||
<div className="invoice-notes">
|
||||
<Typography.Text type="secondary">
|
||||
* 정산서발행 시 자료의 양에 따라 처리시간이 오래 걸릴 수 있습니다.
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
* 붉은색 표기는 추가/수정으로 인해 발행정산서와 일치하지 않는 내용입니다. (필요한 경우 재발행하시면 해결됩니다.)
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Table, Button, Card, Empty } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { BarChartOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { money } from './homesShared'
|
||||
|
||||
type StatRow = {
|
||||
key?: number
|
||||
label?: string
|
||||
day?: number
|
||||
month?: number
|
||||
count?: number
|
||||
policySum?: number
|
||||
settleAmount?: number
|
||||
margin?: number
|
||||
}
|
||||
|
||||
type KindStatData = {
|
||||
mode?: 'daily' | 'monthly'
|
||||
year?: number
|
||||
month?: number
|
||||
title?: string
|
||||
rows?: StatRow[]
|
||||
totals?: StatRow
|
||||
}
|
||||
|
||||
const num = (v?: number) => Number(v ?? 0)
|
||||
const cell = (v?: number) => (num(v) === 0 ? '' : money(v))
|
||||
const countCell = (v?: number) => (num(v) === 0 ? '' : String(v))
|
||||
|
||||
type Props = {
|
||||
kind: 'internet' | 'home'
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function KindStatReportPage({ kind, title, description }: Props) {
|
||||
const [mode, setMode] = useState<'daily' | 'monthly'>('daily')
|
||||
const [cursor, setCursor] = useState<Dayjs>(dayjs())
|
||||
|
||||
const params = useMemo(() => ({
|
||||
mode,
|
||||
year: cursor.year(),
|
||||
month: mode === 'daily' ? cursor.month() + 1 : undefined }), [mode, cursor])
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [`homes-report-${kind}`, params],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<KindStatData>>(`${apiPrefix()}/homes/reports/${kind}`, {
|
||||
params })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const rows = query.data?.rows || []
|
||||
const totals = query.data?.totals
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: mode === 'daily' ? '일자' : '월',
|
||||
dataIndex: 'label',
|
||||
width: 90,
|
||||
align: 'center' as const },
|
||||
{
|
||||
title: '건수',
|
||||
dataIndex: 'count',
|
||||
width: 90,
|
||||
align: 'right' as const,
|
||||
render: countCell },
|
||||
{
|
||||
title: '정책합산',
|
||||
dataIndex: 'policySum',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: cell },
|
||||
{
|
||||
title: '정산합계',
|
||||
dataIndex: 'settleAmount',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: cell },
|
||||
{
|
||||
title: '마진합계',
|
||||
dataIndex: 'margin',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: cell },
|
||||
], [mode])
|
||||
|
||||
const shift = (dir: -1 | 1) => {
|
||||
setCursor((d) => (mode === 'daily' ? d.add(dir, 'month') : d.add(dir, 'year')))
|
||||
}
|
||||
|
||||
const periodLabel = mode === 'daily'
|
||||
? cursor.format('YYYY년 M월')
|
||||
: cursor.format('YYYY년')
|
||||
|
||||
return (
|
||||
<div className="stock-page kind-stat-page">
|
||||
<div className="stock-title kind-stat-title-row">
|
||||
<div>
|
||||
<h2>
|
||||
<BarChartOutlined className="product-title-icon kind-stat-icon" />
|
||||
{title}
|
||||
</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="kind-stat-view-toggle">
|
||||
<Button className={mode === 'daily' ? 'kind-stat-view-active' : ''} onClick={() => setMode('daily')}>
|
||||
일별통계
|
||||
</Button>
|
||||
<Button className={mode === 'monthly' ? 'kind-stat-view-active' : ''} onClick={() => setMode('monthly')}>
|
||||
월별통계
|
||||
</Button>
|
||||
</div>
|
||||
<div className="kind-stat-note">* 철회된 계약은 제외됩니다.</div>
|
||||
</div>
|
||||
|
||||
<Card className="kind-stat-period-card" size="small">
|
||||
<div className="kind-stat-period-nav">
|
||||
<Button type="text" icon={<LeftOutlined />} onClick={() => shift(-1)} />
|
||||
<span className="kind-stat-period-label">{periodLabel}</span>
|
||||
<Button type="text" icon={<RightOutlined />} onClick={() => shift(1)} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card kind-stat-table-card" size="small" loading={query.isLoading}>
|
||||
{rows.length ? (
|
||||
<CareTable
|
||||
rowKey={(r) => String(r.key ?? r.label)}
|
||||
size="small"
|
||||
dataSource={rows}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
scroll={{ x: 600 }}
|
||||
summary={() => (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} align="center"><b>합계</b></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1} align="right"><b>{countCell(totals?.count)}</b></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={2} align="right"><b>{cell(totals?.policySum)}</b></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={3} align="right"><b>{cell(totals?.settleAmount)}</b></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={4} align="right"><b>{cell(totals?.margin)}</b></Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty className="kind-stat-empty" image={Empty.PRESENTED_IMAGE_SIMPLE} description="자료가 없습니다." />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Button, Card, DatePicker, Form, Input, InputNumber, Select, Space, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { DownloadOutlined, RightOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { resourceApi } from '../../api/resources'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const sortFields = ['종류', '모델명', '요금제', '개통일', '출고처', '지원방법', '고객명', '통신사']
|
||||
const defaultSorts = ['종류', '모델명', '요금제', '개통일', '출고처', '지원방법', '고객명']
|
||||
const options = (values: string[]) => values.map((value) => ({ value, label: value }))
|
||||
const money = (v: unknown) => (v == null || v === '' ? '0' : Number(v).toLocaleString())
|
||||
|
||||
type AdjustRow = {
|
||||
id: number
|
||||
opendate?: string
|
||||
incompanyName?: string
|
||||
outcompanyName?: string
|
||||
name?: string
|
||||
openhow?: string
|
||||
pmodel?: string
|
||||
pserial?: string
|
||||
decltype?: string
|
||||
plan?: string
|
||||
sellprice?: number
|
||||
basicprice1?: number
|
||||
basicprice2?: number
|
||||
basicprice3?: number
|
||||
}
|
||||
|
||||
export default function OpenAdjustPage() {
|
||||
const [form] = Form.useForm()
|
||||
const [searched, setSearched] = useState(false)
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({})
|
||||
const [rows, setRows] = useState<AdjustRow[]>([])
|
||||
const [batch, setBatch] = useState({ basicprice1: undefined as number | undefined, basicprice2: undefined as number | undefined, basicprice3: undefined as number | undefined })
|
||||
|
||||
const companies = useQuery({ queryKey: ['adjust-companies'], queryFn: () => resourceApi.list('companies', { size: 200 }) })
|
||||
const inOptions = useMemo(
|
||||
() => (companies.data?.items ?? []).filter((c) => String(c.type) === 'IN').map((c) => ({ value: c.id, label: String(c.name) })),
|
||||
[companies.data],
|
||||
)
|
||||
const outOptions = useMemo(
|
||||
() => (companies.data?.items ?? []).filter((c) => ['SHOP', 'DEALER', 'OUT'].includes(String(c.type))).map((c) => ({ value: c.id, label: String(c.name) })),
|
||||
[companies.data],
|
||||
)
|
||||
|
||||
const enabled = searched && !!filters.dateFrom && !!filters.dateTo
|
||||
const query = useQuery({
|
||||
queryKey: ['open-adjust', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ total: number; list: AdjustRow[] }>>(`${apiPrefix()}/opens/adjust`, { params: filters })
|
||||
return unwrap(data)
|
||||
},
|
||||
enabled })
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data?.list) {
|
||||
setRows(query.data.list.map((row) => ({
|
||||
...row,
|
||||
basicprice1: Number(row.basicprice1 ?? 0),
|
||||
basicprice2: Number(row.basicprice2 ?? 0),
|
||||
basicprice3: Number(row.basicprice3 ?? 0) })))
|
||||
}
|
||||
}, [query.data])
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => {
|
||||
if (to.diff(from, 'day') > 30) {
|
||||
message.warning('최대 31일까지 조회할 수 있습니다.')
|
||||
return
|
||||
}
|
||||
form.setFieldsValue({ dates: [from, to] })
|
||||
}
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
if (!dates?.[0] || !dates?.[1]) {
|
||||
message.warning('개통일을 선택하세요.')
|
||||
return
|
||||
}
|
||||
if (dates[1].diff(dates[0], 'day') > 30) {
|
||||
message.warning('최대 31일까지 조회할 수 있습니다.')
|
||||
return
|
||||
}
|
||||
const next: Record<string, unknown> = {
|
||||
dateFrom: dates[0].format('YYYY-MM-DD'),
|
||||
dateTo: dates[1].format('YYYY-MM-DD'),
|
||||
openhow: values.openhow,
|
||||
telecom: values.telecom,
|
||||
incompanyId: values.incompanyId,
|
||||
outcompanyId: values.outcompanyId,
|
||||
salesperson: values.salesperson,
|
||||
model: values.model,
|
||||
plan: values.plan }
|
||||
for (let i = 1; i <= 7; i++) next[`sort${i}`] = values[`sort${i}`]
|
||||
setSearched(true)
|
||||
setFilters(next)
|
||||
}
|
||||
|
||||
const updateRow = (id: number, field: 'basicprice1' | 'basicprice2' | 'basicprice3', value: number | null) => {
|
||||
setRows((prev) => prev.map((row) => (row.id === id ? { ...row, [field]: value ?? 0 } : row)))
|
||||
}
|
||||
|
||||
const applyBatch = (field: 'basicprice1' | 'basicprice2' | 'basicprice3') => {
|
||||
const value = batch[field]
|
||||
if (value == null) {
|
||||
message.warning('금액을 입력하세요.')
|
||||
return
|
||||
}
|
||||
setRows((prev) => prev.map((row) => ({ ...row, [field]: value })))
|
||||
message.success('일괄 입력했습니다.')
|
||||
}
|
||||
|
||||
const saveOne = useMutation({
|
||||
mutationFn: async (row: AdjustRow) => {
|
||||
const { data } = await client.put(`${apiPrefix()}/opens/${row.id}`, {
|
||||
basicprice1: row.basicprice1,
|
||||
basicprice2: row.basicprice2,
|
||||
basicprice3: row.basicprice3 })
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => message.success('변경했습니다.'),
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const saveAll = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.post(`${apiPrefix()}/opens/adjust/batch`, {
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
basicprice1: row.basicprice1,
|
||||
basicprice2: row.basicprice2,
|
||||
basicprice3: row.basicprice3 })) })
|
||||
return unwrap(data as ApiResponse<{ updated: number }>)
|
||||
},
|
||||
onSuccess: (data) => message.success(`${data.updated}건을 변경했습니다.`),
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const download = async () => {
|
||||
if (!filters.dateFrom || !filters.dateTo) {
|
||||
message.warning('개통일을 선택하세요.')
|
||||
return
|
||||
}
|
||||
const { data } = await client.get(`${apiPrefix()}/opens/adjust/export`, { params: filters, responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '일괄개통정산.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '개통일', dataIndex: 'opendate', width: 100 },
|
||||
{ title: '입고처', dataIndex: 'incompanyName', width: 100, ellipsis: true },
|
||||
{ title: '출고처', dataIndex: 'outcompanyName', width: 100, ellipsis: true },
|
||||
{ title: '고객명', dataIndex: 'name', width: 80 },
|
||||
{ title: '종류', dataIndex: 'openhow', width: 70 },
|
||||
{ title: '모델명', dataIndex: 'pmodel', width: 140, ellipsis: true },
|
||||
{ title: '일련번호', dataIndex: 'pserial', width: 110, ellipsis: true },
|
||||
{ title: '지원방법', dataIndex: 'decltype', width: 90, render: (v: string) => v || '-' },
|
||||
{ title: '요금제', dataIndex: 'plan', width: 110, ellipsis: true },
|
||||
{ title: '정산금액', dataIndex: 'sellprice', width: 90, align: 'right' as const, render: money },
|
||||
{
|
||||
title: '기본정책',
|
||||
dataIndex: 'basicprice1',
|
||||
width: 110,
|
||||
render: (_: unknown, row: AdjustRow) => (
|
||||
<InputNumber
|
||||
className="adjust-cell-input"
|
||||
controls={false}
|
||||
value={row.basicprice1}
|
||||
onChange={(v) => updateRow(row.id, 'basicprice1', v)}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '구두정책',
|
||||
dataIndex: 'basicprice2',
|
||||
width: 110,
|
||||
render: (_: unknown, row: AdjustRow) => (
|
||||
<InputNumber
|
||||
className="adjust-cell-input"
|
||||
controls={false}
|
||||
value={row.basicprice2}
|
||||
onChange={(v) => updateRow(row.id, 'basicprice2', v)}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '추가정책',
|
||||
dataIndex: 'basicprice3',
|
||||
width: 110,
|
||||
render: (_: unknown, row: AdjustRow) => (
|
||||
<InputNumber
|
||||
className="adjust-cell-input"
|
||||
controls={false}
|
||||
value={row.basicprice3}
|
||||
onChange={(v) => updateRow(row.id, 'basicprice3', v)}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '개별',
|
||||
width: 70,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, row: AdjustRow) => (
|
||||
<Button size="small" onClick={() => saveOne.mutate(row)} loading={saveOne.isPending}>변경</Button>
|
||||
) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stock-page adjust-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>일괄개통정산</h2>
|
||||
<p>등록된 개통정보에 일괄적으로 정책금액(정산)을 변경하실 수 있습니다. (유심개통 제외)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card adjust-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '80px' }}
|
||||
initialValues={{
|
||||
sort1: '종류', sort2: '모델명', sort3: '요금제', sort4: '개통일',
|
||||
sort5: '출고처', sort6: '지원방법', sort7: '고객명' }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="adjust-filter-row">
|
||||
<span className="adjust-label">개통일</span>
|
||||
<Form.Item name="dates" noStyle>
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Typography.Text type="danger" className="adjust-hint">* 최대 31일 조회</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="adjust-filter-row">
|
||||
<span className="adjust-label">검색조건</span>
|
||||
<Form.Item name="openhow" noStyle>
|
||||
<Select allowClear placeholder="-종류-" className="w-110" options={options(['신규', '번호이동', '보상', '기변', '메이징', '가개통', '선불개통'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="telecom" noStyle>
|
||||
<Select allowClear placeholder="-통신사-" className="w-110" options={options(['SKT', 'KT', 'LGU+'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="incompanyId" noStyle>
|
||||
<Select allowClear placeholder="-입고처-" className="w-130" options={inOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="outcompanyId" noStyle>
|
||||
<Select allowClear placeholder="-출고처-" className="w-130" options={outOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="salesperson" noStyle>
|
||||
<Select allowClear placeholder="-영업사원-" className="w-120" options={options(['김영업', '이영업'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="model" noStyle>
|
||||
<Input placeholder="-모델명-" className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="plan" noStyle>
|
||||
<Input placeholder="-요금제-" className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="adjust-filter-row adjust-sort-row">
|
||||
<span className="adjust-label">정렬순서</span>
|
||||
{defaultSorts.map((_, index) => (
|
||||
<span key={index} className="adjust-sort-item">
|
||||
{index > 0 && <RightOutlined className="adjust-sort-arrow" />}
|
||||
<Form.Item name={`sort${index + 1}`} noStyle>
|
||||
<Select className="w-110" options={sortFields.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="adjust-search-action">
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />} size="large">개통정보 검색</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{enabled && (
|
||||
<Card className="stock-table-card adjust-result-card" size="small">
|
||||
<div className="adjust-result-head">
|
||||
<Typography.Text>검색된 개통 <b>{query.data?.total ?? rows.length}</b> 건</Typography.Text>
|
||||
<Space>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
<Button type="link" disabled>엑셀대량처리</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="adjust-batch-bar">
|
||||
<span className="adjust-batch-guide">원하시는 금액을 입력 후 엔터키를 눌러주세요.</span>
|
||||
<Space>
|
||||
<InputNumber
|
||||
className="adjust-batch-input"
|
||||
controls={false}
|
||||
placeholder="기본정책"
|
||||
value={batch.basicprice1}
|
||||
onChange={(v) => setBatch((s) => ({ ...s, basicprice1: v ?? undefined }))}
|
||||
onPressEnter={() => applyBatch('basicprice1')}
|
||||
addonAfter={<Button size="small" type="link" onClick={() => applyBatch('basicprice1')}>일괄입력</Button>}
|
||||
/>
|
||||
<InputNumber
|
||||
className="adjust-batch-input"
|
||||
controls={false}
|
||||
placeholder="구두정책"
|
||||
value={batch.basicprice2}
|
||||
onChange={(v) => setBatch((s) => ({ ...s, basicprice2: v ?? undefined }))}
|
||||
onPressEnter={() => applyBatch('basicprice2')}
|
||||
addonAfter={<Button size="small" type="link" onClick={() => applyBatch('basicprice2')}>일괄입력</Button>}
|
||||
/>
|
||||
<InputNumber
|
||||
className="adjust-batch-input"
|
||||
controls={false}
|
||||
placeholder="추가정책"
|
||||
value={batch.basicprice3}
|
||||
onChange={(v) => setBatch((s) => ({ ...s, basicprice3: v ?? undefined }))}
|
||||
onPressEnter={() => applyBatch('basicprice3')}
|
||||
addonAfter={<Button size="small" type="link" onClick={() => applyBatch('basicprice3')}>일괄입력</Button>}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={rows}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
scroll={{ x: 1500 }}
|
||||
/>
|
||||
|
||||
<div className="adjust-submit">
|
||||
<Button
|
||||
className="adjust-submit-btn"
|
||||
type="primary"
|
||||
size="large"
|
||||
loading={saveAll.isPending}
|
||||
disabled={!rows.length}
|
||||
onClick={() => saveAll.mutate()}
|
||||
>
|
||||
상기 입력된 값으로 모두 변경합니다.
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Tabs,
|
||||
Tooltip,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
CloseOutlined, DownloadOutlined, EditOutlined, MailOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { resourceApi } from '../../api/resources'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const tabs = [
|
||||
['ALL', '전체'],
|
||||
['정산차감', '정산차감'],
|
||||
['유심차감', '유심차감'],
|
||||
['홈상품차감', '홈상품차감'],
|
||||
['정산추가', '정산추가'],
|
||||
] as const
|
||||
const gubunOptions = ['정산차감', '유심차감', '홈상품차감', '정산추가']
|
||||
const searchTypes = ['개통번호(뒷4자리)', '고객명', '사유', '출고처']
|
||||
const options = (values: string[]) => values.map((value) => ({ value, label: value }))
|
||||
const isDeduct = (gubun?: string) => Boolean(gubun && gubun.includes('차감'))
|
||||
|
||||
type BalanceRow = Record<string, unknown> & { id: number; gubun?: string; amount?: number; memo?: string }
|
||||
type BalanceSearch = { total: number; list: BalanceRow[]; counts: Record<string, number> }
|
||||
|
||||
const initial = {
|
||||
page: 0,
|
||||
size: 15,
|
||||
tab: 'ALL',
|
||||
dateBasis: '정산일기준',
|
||||
searchType: '개통번호(뒷4자리)' } as Record<string, unknown>
|
||||
|
||||
export default function OpenBalancePage() {
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const clientQuery = useQueryClient()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
const [editing, setEditing] = useState<BalanceRow | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [amountSign, setAmountSign] = useState<'+' | '-'>('+')
|
||||
|
||||
const companies = useQuery({ queryKey: ['balance-companies'], queryFn: () => resourceApi.list('companies', { size: 200 }) })
|
||||
const inOptions = useMemo(
|
||||
() => (companies.data?.items ?? []).filter((c) => String(c.type) === 'IN').map((c) => ({ value: c.id, label: String(c.name) })),
|
||||
[companies.data],
|
||||
)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['balance-search', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<BalanceSearch>>(`${apiPrefix()}/balances/search`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => clientQuery.invalidateQueries({ queryKey: ['balance-search'] })
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const raw = Number(values.amountAbs ?? 0)
|
||||
const signed = amountSign === '-' ? -Math.abs(raw) : Math.abs(raw)
|
||||
const payload: Record<string, unknown> = {
|
||||
...values,
|
||||
amount: signed,
|
||||
basedate: values.basedate ? dayjs(values.basedate as Dayjs).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD'),
|
||||
opendate: values.opendate ? dayjs(values.opendate as Dayjs).format('YYYY-MM-DD') : undefined }
|
||||
delete payload.amountAbs
|
||||
if (editing) {
|
||||
const { data } = await client.put(`${apiPrefix()}/balances/${editing.id}`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/balances`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '수정했습니다.' : '후정산을 등록했습니다.')
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message || '저장에 실패했습니다.') })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setFilters({
|
||||
...initial,
|
||||
...values,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 15,
|
||||
tab: filters.tab })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ dateBasis: '정산일기준', searchType: '개통번호(뒷4자리)', size: 15 })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setCreating(true)
|
||||
setAmountSign('+')
|
||||
editForm.setFieldsValue({
|
||||
gubun: '정산차감',
|
||||
basedate: dayjs(),
|
||||
amountAbs: undefined,
|
||||
outcompanyName: undefined,
|
||||
reason: undefined,
|
||||
memo: undefined,
|
||||
incompanyId: undefined })
|
||||
}
|
||||
|
||||
const openEdit = (row: BalanceRow) => {
|
||||
setCreating(false)
|
||||
setEditing(row)
|
||||
const amount = Number(row.amount ?? 0)
|
||||
setAmountSign(amount < 0 ? '-' : '+')
|
||||
editForm.setFieldsValue({
|
||||
...row,
|
||||
amountAbs: Math.abs(amount),
|
||||
basedate: row.basedate ? dayjs(String(row.basedate)) : dayjs(),
|
||||
opendate: row.opendate ? dayjs(String(row.opendate)) : undefined })
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/balances/export`, { params: filters, responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '개통후정산.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: <Checkbox />, width: 42, render: () => <Checkbox /> },
|
||||
{ title: '개통일', dataIndex: 'opendate', width: 100, render: (v: string) => v || '-' },
|
||||
{ title: '출고처', dataIndex: 'outcompanyName', width: 120, ellipsis: true },
|
||||
{ title: '고객명', dataIndex: 'customerName', width: 90, render: (v: string) => v || '-' },
|
||||
{ title: '개통번호', dataIndex: 'openphone', width: 120, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '구분',
|
||||
dataIndex: 'gubun',
|
||||
width: 100,
|
||||
render: (v: string) => <span className={isDeduct(v) ? 'balance-deduct' : ''}>{v}</span> },
|
||||
{ title: '기준일', dataIndex: 'basedate', width: 100 },
|
||||
{ title: '사유', dataIndex: 'reason', width: 140, ellipsis: true },
|
||||
{
|
||||
title: '정산금액',
|
||||
dataIndex: 'amount',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (v: unknown, row: BalanceRow) => {
|
||||
if (v == null || v === '') return '-'
|
||||
const n = Number(v)
|
||||
const cls = isDeduct(row.gubun) || n < 0 ? 'balance-amount-deduct' : 'balance-amount'
|
||||
return <span className={cls}>{n.toLocaleString()}</span>
|
||||
} },
|
||||
{
|
||||
title: '비고',
|
||||
dataIndex: 'memo',
|
||||
width: 54,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => (v ? <Tooltip title={v}><MailOutlined className="memo-icon" /></Tooltip> : '-') },
|
||||
{
|
||||
title: '관리',
|
||||
width: 58,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, row: BalanceRow) => (
|
||||
<Button size="small" type="text" icon={<EditOutlined style={{ color: '#1677ff' }} />} onClick={() => openEdit(row)} />
|
||||
) },
|
||||
]
|
||||
|
||||
const modalOpen = creating || Boolean(editing)
|
||||
|
||||
return (
|
||||
<div className="stock-page balance-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>개통후정산</h2>
|
||||
<p>등록된 개통정산 외 추가로 후정산을 등록 및 관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Button type="primary" icon={<EditOutlined />} onClick={openCreate}>후정산등록</Button>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="balance-filter"
|
||||
initialValues={{ dateBasis: '정산일기준', searchType: '개통번호(뒷4자리)', size: 15 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dateBasis">
|
||||
<Select className="w-120" options={options(['정산일기준', '개통일기준'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={() => form.setFieldValue('dates', undefined)} />
|
||||
</div>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="telecom">
|
||||
<Select allowClear placeholder="-통신사-" className="w-110" options={options(['SKT', 'KT', 'LGU+'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="incompanyId">
|
||||
<Select allowClear placeholder="-입고처-" className="w-150" options={inOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="outCategory">
|
||||
<Select allowClear placeholder="-출고분류-" className="w-120" options={options(['판매점', '도매'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="outcompanyName">
|
||||
<Input placeholder="출고처" className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-150" options={searchTypes.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((v) => ({ ...v, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="stock-table-actions">
|
||||
<Tabs
|
||||
activeKey={String(filters.tab)}
|
||||
onChange={(tab) => setFilters((v) => ({ ...v, tab, page: 0 }))}
|
||||
items={tabs.map(([key, label]) => ({
|
||||
key,
|
||||
label: `${label} ${query.data?.counts?.[key] ?? 0}` }))}
|
||||
/>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `총 ${total}건`,
|
||||
onChange: (page) => setFilters((v) => ({ ...v, page: page - 1 })) }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '개통후정산 수정' : '개통후정산 등록'}
|
||||
open={modalOpen}
|
||||
onCancel={() => { setCreating(false); setEditing(null) }}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={520}
|
||||
className="balance-modal"
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '110px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
onFinish={(values) => save.mutate(values)}
|
||||
className="balance-edit-form"
|
||||
>
|
||||
<Form.Item name="outcompanyName" label="출고처" rules={[{ required: true, message: '출고처를 입력하세요' }]}>
|
||||
<Input placeholder="출고처명을 입력해주세요." />
|
||||
</Form.Item>
|
||||
<Form.Item name="basedate" label="후정산일" rules={[{ required: true, message: '후정산일을 선택하세요' }]}>
|
||||
<DatePicker className="full-width" />
|
||||
</Form.Item>
|
||||
<Form.Item name="gubun" label="구분" rules={[{ required: true }]}>
|
||||
<Radio.Group options={gubunOptions.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item label="정산금액" required>
|
||||
<Space.Compact className="balance-amount-input">
|
||||
<Button onClick={() => setAmountSign((s) => (s === '+' ? '-' : '+'))}>{amountSign}</Button>
|
||||
<Form.Item name="amountAbs" noStyle rules={[{ required: true, message: '정산금액을 입력하세요' }]}>
|
||||
<InputNumber controls={false} min={0} className="full-width" />
|
||||
</Form.Item>
|
||||
<Button disabled>원</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="사유" rules={[{ required: true, message: '사유를 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="비고">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item name="incompanyId" label="입고처 (옵션)">
|
||||
<Select allowClear placeholder="- 선택 -" options={inOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="customerName" label="고객명"><Input placeholder="선택" /></Form.Item>
|
||||
<Form.Item name="openphone" label="개통번호"><Input placeholder="010-****-0000" /></Form.Item>
|
||||
<Form.Item name="opendate" label="개통일"><DatePicker className="full-width" /></Form.Item>
|
||||
<Form.Item name="telecom" label="통신사">
|
||||
<Select allowClear placeholder="- 선택 -" options={options(['SKT', 'KT', 'LGU+'])} />
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending} icon={<PlusOutlined />}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Button, Card, DatePicker, Form, Input, Select, Space, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import {
|
||||
CloseOutlined, DeleteOutlined, DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const searchTypes = ['개통번호(뒷4자리)', '고객명', '처리직원', '비고']
|
||||
|
||||
type DeleteHistoryRow = Record<string, unknown> & { id: number; no?: number }
|
||||
type DeleteHistoryData = { total: number; list: DeleteHistoryRow[] }
|
||||
|
||||
const initial = { page: 0, size: 15, searchType: '개통번호(뒷4자리)' } as Record<string, unknown>
|
||||
|
||||
const deleteHistoryApi = {
|
||||
async search(params: Record<string, unknown>) {
|
||||
const { data } = await client.get<ApiResponse<DeleteHistoryData>>(`${apiPrefix()}/deletehistories/search`, { params })
|
||||
return unwrap(data)
|
||||
},
|
||||
async remove(ids: number[]) {
|
||||
const { data } = await client.post<ApiResponse>(`${apiPrefix()}/deletehistories/delete`, { ids })
|
||||
return unwrap(data)
|
||||
} }
|
||||
|
||||
export default function OpenDeleteHistoryPage() {
|
||||
const [form] = Form.useForm()
|
||||
const qc = useQueryClient()
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['open-delete-history', filters],
|
||||
queryFn: () => deleteHistoryApi.search(filters) })
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => deleteHistoryApi.remove(selected),
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
setSelected([])
|
||||
qc.invalidateQueries({ queryKey: ['open-delete-history'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setSelected([])
|
||||
setFilters({
|
||||
...initial,
|
||||
outcompanyName: values.outcompanyName ? String(values.outcompanyName) : undefined,
|
||||
searchType: values.searchType || '개통번호(뒷4자리)',
|
||||
keyword: values.keyword ? String(values.keyword) : undefined,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 15 })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ searchType: '개통번호(뒷4자리)', size: 15 })
|
||||
setSelected([])
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const clearDates = () => form.setFieldValue('dates', undefined)
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/deletehistories/export`, { params: filters, responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '개통삭제이력.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ title: '번호', dataIndex: 'no', width: 64, align: 'center' as const },
|
||||
{ title: '철회일', dataIndex: 'deleteDate', width: 110 },
|
||||
{ title: '출고처', dataIndex: 'outcompanyName', width: 130, ellipsis: true },
|
||||
{ title: '고객명', dataIndex: 'customerName', width: 100 },
|
||||
{ title: '개통일', dataIndex: 'openDate', width: 110 },
|
||||
{ title: '개통번호', dataIndex: 'openphone', width: 130 },
|
||||
{ title: '처리직원', dataIndex: 'processor', width: 90 },
|
||||
{ title: '처리일', dataIndex: 'processedAt', width: 140 },
|
||||
{ title: '비고', dataIndex: 'memo', width: 200, ellipsis: true, render: (v: string) => v || '-' },
|
||||
], [])
|
||||
|
||||
return (
|
||||
<div className="stock-page delete-history-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><DeleteOutlined style={{ marginRight: 8 }} />개통 삭제이력</h2>
|
||||
<p>모바일 개통과 관련된 이력을 확인하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap align="start">
|
||||
<Typography.Text type="secondary" className="sheet-notice">* 대표권한은 이력을 삭제하실 수 있습니다.</Typography.Text>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
loading={remove.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('삭제할 이력을 선택하세요.')
|
||||
return
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '이력 삭제',
|
||||
content: `선택한 ${selected.length}건의 이력을 삭제하시겠습니까?`,
|
||||
onOk: () => remove.mutateAsync() })
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="delete-history-filter"
|
||||
initialValues={{ searchType: '개통번호(뒷4자리)', size: 15 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={clearDates} />
|
||||
<Form.Item name="outcompanyName">
|
||||
<Input placeholder="출고처" className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-150" options={searchTypes.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((prev) => ({ ...prev, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="recovery-table-head">
|
||||
<Typography.Text>목록 <b>{query.data?.total ?? 0}</b></Typography.Text>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selected,
|
||||
onChange: (keys) => setSelected(keys.map(Number)) }}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `총 ${total}건`,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
locale={{ emptyText: '등록되어 있는 자료가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Button, Card, DatePicker, Form, Input, Select, Space, Tooltip, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import {
|
||||
CloseOutlined, DownloadOutlined, MailOutlined, ReloadOutlined, SearchOutlined, SwapOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const searchTypes = ['개통번호(뒷4자리)', '고객명', '교품일련번호', '기존일련번호']
|
||||
const kindOptions = ['단말기', '유심']
|
||||
const options = (values: string[]) => values.map((value) => ({ value, label: value }))
|
||||
|
||||
type ExchangeRow = Record<string, unknown> & { id: number; no?: number; memo?: string }
|
||||
type ExchangeData = { total: number; list: ExchangeRow[] }
|
||||
|
||||
const initial = { page: 0, size: 15, searchType: '개통번호(뒷4자리)' } as Record<string, unknown>
|
||||
|
||||
const exchangeApi = {
|
||||
async search(params: Record<string, unknown>) {
|
||||
const { data } = await client.get<ApiResponse<ExchangeData>>(`${apiPrefix()}/exchanges/search`, { params })
|
||||
return unwrap(data)
|
||||
},
|
||||
async remove(ids: number[]) {
|
||||
const { data } = await client.post<ApiResponse>(`${apiPrefix()}/exchanges/delete`, { ids })
|
||||
return unwrap(data)
|
||||
} }
|
||||
|
||||
export default function OpenExchangePage() {
|
||||
const [form] = Form.useForm()
|
||||
const qc = useQueryClient()
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['open-exchange', filters],
|
||||
queryFn: () => exchangeApi.search(filters) })
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => exchangeApi.remove(selected),
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
setSelected([])
|
||||
qc.invalidateQueries({ queryKey: ['open-exchange'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setSelected([])
|
||||
setFilters({
|
||||
...initial,
|
||||
kind: values.kind || undefined,
|
||||
outcompanyName: values.outcompanyName ? String(values.outcompanyName) : undefined,
|
||||
searchType: values.searchType || '개통번호(뒷4자리)',
|
||||
keyword: values.keyword ? String(values.keyword) : undefined,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 15 })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ searchType: '개통번호(뒷4자리)', size: 15 })
|
||||
setSelected([])
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const clearDates = () => form.setFieldValue('dates', undefined)
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/exchanges/export`, { params: filters, responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '개통교품이력.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ title: '번호', dataIndex: 'no', width: 64, align: 'center' as const },
|
||||
{ title: '교품일', dataIndex: 'exchangeDate', width: 110 },
|
||||
{ title: '출고처', dataIndex: 'outcompanyName', width: 120, ellipsis: true },
|
||||
{ title: '고객명', dataIndex: 'customerName', width: 90 },
|
||||
{ title: '개통일', dataIndex: 'openDate', width: 110 },
|
||||
{ title: '개통번호', dataIndex: 'openphone', width: 130 },
|
||||
{ title: '종류', dataIndex: 'kind', width: 72, align: 'center' as const },
|
||||
{ title: '교품', dataIndex: 'newLabel', width: 200, ellipsis: true },
|
||||
{ title: '기존', dataIndex: 'oldLabel', width: 200, ellipsis: true },
|
||||
{ title: '처리직원', dataIndex: 'processor', width: 90 },
|
||||
{ title: '처리일', dataIndex: 'processedAt', width: 140 },
|
||||
{
|
||||
title: '비고',
|
||||
dataIndex: 'memo',
|
||||
width: 54,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => (v ? <Tooltip title={v}><MailOutlined className="memo-icon" /></Tooltip> : '-') },
|
||||
], [])
|
||||
|
||||
return (
|
||||
<div className="stock-page exchange-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><SwapOutlined style={{ marginRight: 8 }} />개통 교품이력</h2>
|
||||
<p>모바일 개통과 관련된 이력을 확인하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap align="start">
|
||||
<Typography.Text type="secondary" className="sheet-notice">* 대표권한은 이력을 삭제하실 수 있습니다.</Typography.Text>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
loading={remove.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('삭제할 이력을 선택하세요.')
|
||||
return
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '이력 삭제',
|
||||
content: `선택한 ${selected.length}건의 이력을 삭제하시겠습니까?`,
|
||||
onOk: () => remove.mutateAsync() })
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="exchange-filter"
|
||||
initialValues={{ searchType: '개통번호(뒷4자리)', size: 15 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={clearDates} />
|
||||
</div>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="kind">
|
||||
<Select allowClear placeholder="종류" className="w-110" options={options(kindOptions)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="outcompanyName">
|
||||
<Input placeholder="출고처" className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-150" options={searchTypes.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((prev) => ({ ...prev, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="recovery-table-head">
|
||||
<Typography.Text>목록 <b>{query.data?.total ?? 0}</b></Typography.Text>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selected,
|
||||
onChange: (keys) => setSelected(keys.map(Number)) }}
|
||||
scroll={{ x: 1500 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `총 ${total}건`,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
locale={{ emptyText: '등록되어 있는 자료가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Button, Card, DatePicker, Form, Input, Select, Space, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { CloseOutlined, DownloadOutlined, FileProtectOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { resourceApi } from '../../api/resources'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const searchTypes = ['고객명', '미비서류', '휴대폰(뒷4자리)']
|
||||
const options = (values: string[]) => values.map((value) => ({ value, label: value }))
|
||||
const money = (v: unknown) => (v == null || v === '' ? '-' : Number(v).toLocaleString())
|
||||
|
||||
type HomeIndocRow = Record<string, unknown> & { id: number }
|
||||
type HomeIndocFilters = {
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
telecom?: string
|
||||
incompanyId?: number | string
|
||||
outcompanyName?: string
|
||||
searchType?: string
|
||||
keyword?: string
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export default function OpenHomeIndocPage() {
|
||||
const [form] = Form.useForm()
|
||||
const [searched, setSearched] = useState(false)
|
||||
const [filters, setFilters] = useState<HomeIndocFilters>({ page: 0, size: 15, searchType: '고객명' })
|
||||
|
||||
const companies = useQuery({ queryKey: ['homeindoc-companies'], queryFn: () => resourceApi.list('companies', { size: 200 }) })
|
||||
const inOptions = useMemo(
|
||||
() => (companies.data?.items ?? []).filter((c) => String(c.type) === 'IN').map((c) => ({ value: c.id, label: String(c.name) })),
|
||||
[companies.data],
|
||||
)
|
||||
|
||||
const enabled = searched && !!filters.dateFrom && !!filters.dateTo
|
||||
const query = useQuery({
|
||||
queryKey: ['homeindoc-search', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ total: number; list: HomeIndocRow[] }>>(`${apiPrefix()}/homeindocs/search`, { params: filters })
|
||||
return unwrap(data)
|
||||
},
|
||||
enabled })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
if (!dates?.[0] || !dates?.[1]) {
|
||||
message.warning('날짜를 선택하세요.')
|
||||
return
|
||||
}
|
||||
setSearched(true)
|
||||
setFilters({
|
||||
dateFrom: dates[0].format('YYYY-MM-DD'),
|
||||
dateTo: dates[1].format('YYYY-MM-DD'),
|
||||
telecom: values.telecom ? String(values.telecom) : undefined,
|
||||
incompanyId: values.incompanyId as number | string | undefined,
|
||||
outcompanyName: values.outcompanyName ? String(values.outcompanyName) : undefined,
|
||||
searchType: String(values.searchType || '고객명'),
|
||||
keyword: values.keyword ? String(values.keyword) : undefined,
|
||||
page: 0,
|
||||
size: Number(values.size ?? 15) })
|
||||
}
|
||||
|
||||
const clearDates = () => {
|
||||
form.setFieldValue('dates', undefined)
|
||||
setSearched(false)
|
||||
setFilters((prev) => ({ page: 0, size: prev.size, searchType: prev.searchType || '고객명' }))
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
if (!filters.dateFrom || !filters.dateTo) {
|
||||
message.warning('날짜를 선택하세요.')
|
||||
return
|
||||
}
|
||||
const { data } = await client.get(`${apiPrefix()}/homeindocs/export`, { params: filters, responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '홈상품미비서류.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '설치일', dataIndex: 'installDate', width: 100 },
|
||||
{ title: '통신사', dataIndex: 'telecom', width: 72 },
|
||||
{ title: '입고처', dataIndex: 'incompanyName', width: 110, ellipsis: true },
|
||||
{ title: '출고처', dataIndex: 'outcompanyName', width: 110, ellipsis: true },
|
||||
{ title: '고객명', dataIndex: 'customerName', width: 90 },
|
||||
{ title: '휴대폰', dataIndex: 'phone', width: 120 },
|
||||
{ title: '가입상품', dataIndex: 'products', width: 140, ellipsis: true },
|
||||
{ title: '미비서류', dataIndex: 'missingDocs', width: 160, ellipsis: true },
|
||||
{ title: '차감금', dataIndex: 'deductAmount', width: 90, align: 'right' as const, render: money },
|
||||
{ title: '차감사유', dataIndex: 'deductReason', width: 120, ellipsis: true },
|
||||
{ title: '마감일', dataIndex: 'deadline', width: 100 },
|
||||
{ title: '상태', dataIndex: 'status', width: 70 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stock-page home-indoc-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><FileProtectOutlined style={{ marginRight: 8 }} />홈상품미비서류</h2>
|
||||
<p>홈상품 미비서류에 등록된 내역을 모두 확인하실 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="home-indoc-filter"
|
||||
initialValues={{ searchType: '고객명', size: 15 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={clearDates} />
|
||||
<Form.Item name="telecom">
|
||||
<Select allowClear placeholder="-통신사-" className="w-110" options={options(['SKT', 'KT', 'LGU+'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="incompanyId">
|
||||
<Select allowClear placeholder="-입고처-" className="w-150" options={inOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="outcompanyName">
|
||||
<Input placeholder="출고처" className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-130" options={searchTypes.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
if (searched) setFilters((prev) => ({ ...prev, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card sheet-result-card" size="small">
|
||||
{!enabled ? (
|
||||
<div className="sheet-empty">미비서류내역은 검색 후 확인하실 수 있습니다.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="recovery-table-head">
|
||||
<Typography.Text>목록 <b>{query.data?.total ?? 0}</b></Typography.Text>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1400 }}
|
||||
pagination={{
|
||||
current: filters.page + 1,
|
||||
pageSize: filters.size,
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `총 ${total}건`,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Button, Card, Checkbox, DatePicker, Form, Input, Select, Space, Tabs, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
CloseOutlined, DownloadOutlined, FileTextOutlined, HomeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { resourceApi } from '../../api/resources'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const productTabs = [
|
||||
'ALL', '인터넷', 'TV', '집전화', '인터넷전화', '신용카드', '홈IOT', '동반', '기타', '후결합', '재약정', '소호', '원스톱', '렌탈',
|
||||
] as const
|
||||
const tabLabel: Record<string, string> = {
|
||||
ALL: '전체', 인터넷: '인터넷', TV: 'TV', 집전화: '집전화', 인터넷전화: '인터넷전화', 신용카드: '신용카드',
|
||||
홈IOT: '홈IOT', 동반: '동판', 기타: '기타', 후결합: '후결합', 재약정: '재약정', 소호: '소호', 원스톱: '원스톱', 렌탈: '렌탈' }
|
||||
const options = (values: string[]) => values.map((value) => ({ value, label: value }))
|
||||
const money = (v: unknown) => (v == null || v === '' ? '-' : Number(v).toLocaleString())
|
||||
|
||||
type HomeSale = Record<string, unknown> & { id: number; status?: string; settleAmount?: number }
|
||||
type HomeSearch = { total: number; list: HomeSale[]; counts: Record<string, number> }
|
||||
|
||||
const initial = { page: 0, size: 15, tab: 'ALL', dateBasis: '설치일기준' } as Record<string, unknown>
|
||||
|
||||
export default function OpenHomeSalePage() {
|
||||
const [form] = Form.useForm()
|
||||
const navigate = useNavigate()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
|
||||
const companies = useQuery({ queryKey: ['homesale-companies'], queryFn: () => resourceApi.list('companies', { size: 200 }) })
|
||||
const inOptions = useMemo(
|
||||
() => (companies.data?.items ?? []).filter((c) => String(c.type) === 'IN').map((c) => ({ value: c.id, label: String(c.name) })),
|
||||
[companies.data],
|
||||
)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homesale-search', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<HomeSearch>>(`${apiPrefix()}/homesales/search`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setFilters({
|
||||
...initial,
|
||||
...values,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 15,
|
||||
tab: filters.tab })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ dateBasis: '설치일기준', size: 15 })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/homesales/export`, { params: filters, responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '홈상품판매.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: <Checkbox />, width: 42, render: () => <Checkbox /> },
|
||||
{ title: '설치일', dataIndex: 'installDate', width: 100, sorter: true },
|
||||
{ title: '출고처', dataIndex: 'outcompanyName', width: 110, ellipsis: true, sorter: true },
|
||||
{
|
||||
title: '상태', dataIndex: 'status', width: 70,
|
||||
render: (v: string) => <span className={v === '대기' ? 'home-status-wait' : 'home-status-done'}>{v}</span> },
|
||||
{ title: '입고처', dataIndex: 'incompanyName', width: 100, ellipsis: true },
|
||||
{ title: '통신사', dataIndex: 'telecom', width: 70, sorter: true },
|
||||
{ title: '고객명', dataIndex: 'customerName', width: 90, sorter: true },
|
||||
{ title: '휴대폰', dataIndex: 'phone', width: 120 },
|
||||
{ title: '가입상품', dataIndex: 'products', width: 140, ellipsis: true },
|
||||
{
|
||||
title: '정산', dataIndex: 'settleAmount', width: 100, align: 'right' as const, sorter: true,
|
||||
render: (v: unknown) => {
|
||||
const n = Number(v ?? 0)
|
||||
return <span className={n < 0 ? 'home-settle-neg' : ''}>{money(v)}</span>
|
||||
} },
|
||||
{ title: '마진', dataIndex: 'margin', width: 90, align: 'right' as const, render: money },
|
||||
{ title: '접수일', dataIndex: 'receiptDate', width: 100, sorter: true },
|
||||
{
|
||||
title: '관리', width: 58, fixed: 'right' as const,
|
||||
render: (_: unknown, row: HomeSale) => (
|
||||
<Button size="small" type="text" icon={<FileTextOutlined style={{ color: '#1677ff' }} />} onClick={() => navigate(`/care/open/home-write?id=${row.id}`)} />
|
||||
) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stock-page home-sale-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><HomeOutlined style={{ marginRight: 8 }} />홈상품판매</h2>
|
||||
<p>인터넷, TV, 전화 등 홈상품판매와 관련된 모든 업무를 관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/care/open/home-write')}>홈상품판매 등록</Button>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form form={form} layout="inline" className="home-filter" initialValues={{ dateBasis: '설치일기준', size: 15 }} onFinish={onSearch}>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dateBasis">
|
||||
<Select className="w-120" options={options(['설치일기준', '접수일기준'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={() => form.setFieldValue('dates', undefined)} />
|
||||
<Button onClick={() => form.getFieldInstance('dates')?.focus?.()}>날짜설정</Button>
|
||||
</div>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="telecom"><Select allowClear placeholder="-통신사-" className="w-110" options={options(['SKT', 'KT', 'LGU+'])} /></Form.Item>
|
||||
<Form.Item name="incompanyId"><Select allowClear placeholder="-입고처-" className="w-150" options={inOptions} /></Form.Item>
|
||||
<Form.Item name="outCategory"><Select allowClear placeholder="-출고분류-" className="w-120" options={options(['판매점', '도매'])} /></Form.Item>
|
||||
<Form.Item name="outcompanyName"><Input placeholder="출고처" className="w-130" allowClear /></Form.Item>
|
||||
<Form.Item name="employee"><Select allowClear placeholder="-직원-" className="w-110" options={options(['demo'])} /></Form.Item>
|
||||
</div>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="status"><Select allowClear placeholder="-상태-" className="w-110" options={options(['대기', '완료'])} /></Form.Item>
|
||||
<Form.Item name="customerName"><Input placeholder="고객명" className="w-130" allowClear /></Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => { form.setFieldValue('size', size); setFilters((v) => ({ ...v, size, page: 0 })) }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="stock-table-actions">
|
||||
<Tabs
|
||||
activeKey={String(filters.tab)}
|
||||
onChange={(tab) => setFilters((v) => ({ ...v, tab, page: 0 }))}
|
||||
items={productTabs.map((key) => ({
|
||||
key,
|
||||
label: `${tabLabel[key]} ${query.data?.counts?.[key] ?? 0}` }))}
|
||||
/>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1400 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `총 ${total}건`,
|
||||
onChange: (page) => setFilters((v) => ({ ...v, page: page - 1 })) }}
|
||||
onChange={(_, __, sorter) => {
|
||||
const order = Array.isArray(sorter) ? sorter[0] : sorter
|
||||
if (order?.field) setFilters((v) => ({ ...v, sort: `${String(order.field)},${order.order === 'ascend' ? 'asc' : 'desc'}` }))
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import dayjs from 'dayjs'
|
||||
import { Button, Card, Checkbox, Col, DatePicker, Form, Input, InputNumber, Radio, Row, Select, Space, message } from 'antd'
|
||||
import { HomeOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { resourceApi } from '../../api/resources'
|
||||
|
||||
const productOptions = [
|
||||
'인터넷', 'TV', '집전화', '인터넷전화', '신용카드', '기타', '홈IOT', '동반', '후결합', '재약정', '소호', '원스톱', '렌탈',
|
||||
]
|
||||
const options = (values: string[]) => values.map((value) => ({ value, label: value }))
|
||||
|
||||
export default function OpenHomeSaleWritePage() {
|
||||
const navigate = useNavigate()
|
||||
const [search] = useSearchParams()
|
||||
const editId = search.get('id') ? Number(search.get('id')) : null
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const companies = useQuery({ queryKey: ['homesale-write-companies'], queryFn: () => resourceApi.list('companies', { size: 200 }) })
|
||||
const inOptions = useMemo(
|
||||
() => (companies.data?.items ?? []).filter((c) => String(c.type) === 'IN').map((c) => ({ value: c.id, label: String(c.name) })),
|
||||
[companies.data],
|
||||
)
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: ['homesale-detail', editId],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>>>(`${apiPrefix()}/homesales/${editId}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
enabled: Boolean(editId) })
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail.data) return
|
||||
const row = detail.data
|
||||
const phone = String(row.phone ?? '').replace(/\D/g, '')
|
||||
form.setFieldsValue({
|
||||
...row,
|
||||
installDate: row.installDate ? dayjs(String(row.installDate)) : dayjs(),
|
||||
receiptDate: row.receiptDate ? dayjs(String(row.receiptDate)) : dayjs(),
|
||||
products: String(row.products ?? '').split(',').map((v) => v.trim()).filter(Boolean),
|
||||
phone1: phone.slice(0, 3) || '010',
|
||||
phone2: phone.slice(3, 7),
|
||||
phone3: phone.slice(7, 11),
|
||||
status: row.status === '대기' ? '판매대기' : '판매완료' })
|
||||
}, [detail.data, form])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const products = Array.isArray(values.products) ? values.products.join(',') : String(values.products ?? '')
|
||||
const payload: Record<string, unknown> = {
|
||||
...values,
|
||||
products,
|
||||
status: values.status === '판매대기' ? '대기' : '완료',
|
||||
phone: `${values.phone1}-${values.phone2}-${values.phone3}`,
|
||||
installDate: values.installDate ? dayjs(values.installDate as dayjs.Dayjs).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD'),
|
||||
receiptDate: values.receiptDate ? dayjs(values.receiptDate as dayjs.Dayjs).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD'),
|
||||
settleAmount: values.settleAmount ?? 0,
|
||||
margin: values.margin ?? 0,
|
||||
extraPolicy: values.extraPolicy ?? 0 }
|
||||
delete payload.phone1
|
||||
delete payload.phone2
|
||||
delete payload.phone3
|
||||
if (editId) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homesales/${editId}`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homesales`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editId ? '수정했습니다.' : '홈상품판매를 등록했습니다.')
|
||||
navigate('/care/open/home')
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
return (
|
||||
<div className="home-write-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><HomeOutlined style={{ marginRight: 8 }} />홈상품판매 등록</h2>
|
||||
<p>판매하신 정보를 아래의 양식에 맞게 차례대로 입력해주세요. ('필수' 표시는 필수항목입니다.)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '100px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
initialValues={{
|
||||
status: '판매완료',
|
||||
installDate: dayjs(),
|
||||
receiptDate: dayjs(),
|
||||
phone1: '010',
|
||||
missingDocs: '관계없음',
|
||||
products: [],
|
||||
extraPolicy: 0,
|
||||
settleAmount: 0 }}
|
||||
onFinish={(values) => save.mutate(values)}
|
||||
>
|
||||
<Card className="open-section" size="small" title="가입정보">
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="status" label="상태" rules={[{ required: true }]}>
|
||||
<Radio.Group options={[{ value: '판매대기', label: '판매대기' }, { value: '판매완료', label: '판매완료' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="installDate" label="설치일" rules={[{ required: true }]}>
|
||||
<DatePicker className="full-width" />
|
||||
</Form.Item>
|
||||
<Form.Item name="receiptDate" label="접수일" rules={[{ required: true }]}>
|
||||
<DatePicker className="full-width" />
|
||||
</Form.Item>
|
||||
<Form.Item name="outcompanyName" label="출고처" rules={[{ required: true, message: '출고처를 입력하세요' }]}>
|
||||
<Input placeholder="출고처명을 입력해주세요." />
|
||||
</Form.Item>
|
||||
<Form.Item name="incompanyId" label="입고처">
|
||||
<Select allowClear placeholder="- 선택 -" options={inOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="customerName" label="고객명" rules={[{ required: true, message: '고객명을 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="휴대폰" required>
|
||||
<Space.Compact>
|
||||
<Form.Item name="phone1" noStyle rules={[{ required: true }]}>
|
||||
<Select className="w-100" options={options(['010', '011', '016', '017', '018', '019'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone2" noStyle rules={[{ required: true }]}>
|
||||
<Input className="w-100" maxLength={4} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone3" noStyle rules={[{ required: true }]}>
|
||||
<Input className="w-100" maxLength={4} />
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item name="telecom" label="통신사">
|
||||
<Select allowClear placeholder="- 선택 -" options={options(['SKT', 'KT', 'LGU+'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="outCategory" label="출고분류">
|
||||
<Select allowClear options={options(['판매점', '도매'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="employee" label="직원">
|
||||
<Select allowClear options={options(['demo'])} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="address" label="주소">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Card className="open-section" size="small" title="상품">
|
||||
<Form.Item name="products" rules={[{ required: true, message: '판매하신 상품을 선택해주세요.' }]}>
|
||||
<Checkbox.Group className="home-product-checks" options={productOptions.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<p className="home-product-hint">판매하신 상품을 선택해주세요.</p>
|
||||
</Card>
|
||||
|
||||
<Card className="open-section" size="small" title="정산정보">
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="missingDocs" label="미비서류">
|
||||
<Select options={options(['관계없음', '신분증', '통장사본', '기타'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="extraPolicy" label="추가정책">
|
||||
<InputNumber className="full-width" controls={false} addonAfter="원" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="settleAmount"
|
||||
label="정산금액"
|
||||
extra={<span className="unitcost-extra">* 각 상품 정책금액 + 추가정책 (마이너스 표기)</span>}
|
||||
>
|
||||
<InputNumber className="full-width home-settle-input" controls={false} addonAfter="원" />
|
||||
</Form.Item>
|
||||
<Form.Item name="margin" label="마진">
|
||||
<InputNumber className="full-width" controls={false} addonAfter="원" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="memo" label="메모">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<div className="open-actions">
|
||||
<Button onClick={() => navigate('/care/open/home')}>목록</Button>
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>{editId ? '수정' : '등록'}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Button, Card, DatePicker, Form, Input, Select, Space, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { CloseOutlined, DownloadOutlined, FileProtectOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { resourceApi } from '../../api/resources'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const searchTypes = ['개통번호(뒷4자리)', '고객명', '미비서류']
|
||||
const options = (values: string[]) => values.map((value) => ({ value, label: value }))
|
||||
const money = (v: unknown) => (v == null || v === '' ? '-' : Number(v).toLocaleString())
|
||||
|
||||
type IndocRow = Record<string, unknown> & { id: number }
|
||||
type IndocFilters = {
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
telecom?: string
|
||||
incompanyId?: number | string
|
||||
outcompanyName?: string
|
||||
searchType?: string
|
||||
keyword?: string
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export default function OpenIndocPage() {
|
||||
const [form] = Form.useForm()
|
||||
const [searched, setSearched] = useState(false)
|
||||
const [filters, setFilters] = useState<IndocFilters>({ page: 0, size: 15, searchType: '개통번호(뒷4자리)' })
|
||||
|
||||
const companies = useQuery({ queryKey: ['indoc-companies'], queryFn: () => resourceApi.list('companies', { size: 200 }) })
|
||||
const inOptions = useMemo(
|
||||
() => (companies.data?.items ?? []).filter((c) => String(c.type) === 'IN').map((c) => ({ value: c.id, label: String(c.name) })),
|
||||
[companies.data],
|
||||
)
|
||||
|
||||
const enabled = searched && !!filters.dateFrom && !!filters.dateTo
|
||||
const query = useQuery({
|
||||
queryKey: ['indoc-search', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ total: number; list: IndocRow[] }>>(`${apiPrefix()}/indocs/search`, { params: filters })
|
||||
return unwrap(data)
|
||||
},
|
||||
enabled })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
if (!dates?.[0] || !dates?.[1]) {
|
||||
message.warning('날짜를 선택하세요.')
|
||||
return
|
||||
}
|
||||
setSearched(true)
|
||||
setFilters({
|
||||
dateFrom: dates[0].format('YYYY-MM-DD'),
|
||||
dateTo: dates[1].format('YYYY-MM-DD'),
|
||||
telecom: values.telecom ? String(values.telecom) : undefined,
|
||||
incompanyId: values.incompanyId as number | string | undefined,
|
||||
outcompanyName: values.outcompanyName ? String(values.outcompanyName) : undefined,
|
||||
searchType: String(values.searchType || '개통번호(뒷4자리)'),
|
||||
keyword: values.keyword ? String(values.keyword) : undefined,
|
||||
page: 0,
|
||||
size: Number(values.size ?? 15) })
|
||||
}
|
||||
|
||||
const clearDates = () => {
|
||||
form.setFieldValue('dates', undefined)
|
||||
setSearched(false)
|
||||
setFilters((prev) => ({ page: 0, size: prev.size, searchType: prev.searchType || '개통번호(뒷4자리)' }))
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
if (!filters.dateFrom || !filters.dateTo) {
|
||||
message.warning('날짜를 선택하세요.')
|
||||
return
|
||||
}
|
||||
const { data } = await client.get(`${apiPrefix()}/indocs/export`, { params: filters, responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '미비서류.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '개통일', dataIndex: 'opendate', width: 100 },
|
||||
{ title: '통신사', dataIndex: 'telecom', width: 72 },
|
||||
{ title: '입고처', dataIndex: 'incompanyName', width: 110, ellipsis: true },
|
||||
{ title: '출고처', dataIndex: 'outcompanyName', width: 110, ellipsis: true },
|
||||
{ title: '고객명', dataIndex: 'customerName', width: 90 },
|
||||
{ title: '개통번호', dataIndex: 'openphone', width: 120 },
|
||||
{ title: '미비서류', dataIndex: 'missingDocs', width: 180, ellipsis: true },
|
||||
{ title: '차감금', dataIndex: 'deductAmount', width: 90, align: 'right' as const, render: money },
|
||||
{ title: '차감사유', dataIndex: 'deductReason', width: 120, ellipsis: true },
|
||||
{ title: '마감일', dataIndex: 'deadline', width: 100 },
|
||||
{ title: '상태', dataIndex: 'status', width: 80 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stock-page indoc-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><FileProtectOutlined style={{ marginRight: 8 }} />미비서류</h2>
|
||||
<p>미비서류에 등록된 내역을 모두 확인하실 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="indoc-filter"
|
||||
initialValues={{ searchType: '개통번호(뒷4자리)', size: 15 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={clearDates} />
|
||||
</div>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="telecom">
|
||||
<Select allowClear placeholder="-통신사-" className="w-110" options={options(['SKT', 'KT', 'LGU+'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="incompanyId">
|
||||
<Select allowClear placeholder="-입고처-" className="w-150" options={inOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="outcompanyName">
|
||||
<Input placeholder="출고처" className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-150" options={searchTypes.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-150" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
</Form.Item>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
if (searched) setFilters((prev) => ({ ...prev, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card sheet-result-card" size="small">
|
||||
{!enabled ? (
|
||||
<div className="sheet-empty">미비서류내역은 검색 후 확인하실 수 있습니다.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="recovery-table-head">
|
||||
<Typography.Text>목록 <b>{query.data?.total ?? 0}</b></Typography.Text>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1300 }}
|
||||
pagination={{
|
||||
current: filters.page + 1,
|
||||
pageSize: filters.size,
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `총 ${total}건`,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { Button, Card, Checkbox, DatePicker, Form, Input, Select, Space, Tabs, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { DownloadOutlined, EditOutlined, FileTextOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix } from '../../api/client'
|
||||
import { resourceApi } from '../../api/resources'
|
||||
import { openApi, type OpenRecord } from '../../api/opens'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const tabs = [
|
||||
['ALL', '전체'], ['신규', '신규'], ['번호이동', '번호이동'], ['보상', '보상'],
|
||||
['기변', '기변'], ['메이징', '메이징'], ['가개통', '가개통'], ['선불개통', '선불개통'],
|
||||
] as const
|
||||
const options = (values: string[]) => values.map((value) => ({ value, label: value }))
|
||||
const money = (v: unknown) => (v == null || v === '' ? '-' : Number(v).toLocaleString())
|
||||
const initial = { page: 0, size: 15, tab: 'ALL' } as Record<string, unknown>
|
||||
|
||||
export default function OpenManagePage() {
|
||||
const [form] = Form.useForm()
|
||||
const navigate = useNavigate()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
const companies = useQuery({ queryKey: ['open-companies'], queryFn: () => resourceApi.list('companies', { size: 100 }) })
|
||||
const query = useQuery({ queryKey: ['open-search', filters], queryFn: () => openApi.search(filters) })
|
||||
const inOptions = useMemo(() => (companies.data?.items ?? []).filter((c) => String(c.type) === 'IN').map((c) => ({ value: c.id, label: String(c.name) })), [companies.data])
|
||||
|
||||
const search = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [dayjs.Dayjs, dayjs.Dayjs] | undefined
|
||||
const next: Record<string, unknown> = {
|
||||
...initial, ...values,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 15,
|
||||
tab: filters.tab }
|
||||
delete next.dates
|
||||
setFilters(next)
|
||||
}
|
||||
const reset = () => { form.resetFields(); setFilters(initial) }
|
||||
const download = async () => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/opens/export`, { params: filters, responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data); const a = document.createElement('a'); a.href = url; a.download = '개통관리.csv'; a.click(); URL.revokeObjectURL(url)
|
||||
} catch { message.error('엑셀 다운로드에 실패했습니다.') }
|
||||
}
|
||||
const columns = [
|
||||
{ title: <Checkbox />, width: 42, render: () => <Checkbox /> },
|
||||
{ title: '개통일', dataIndex: 'opendate', width: 96, sorter: true },
|
||||
{ title: '통신사', dataIndex: 'telecom', width: 72, sorter: true },
|
||||
{ title: '출고처', dataIndex: 'outcompanyName', width: 110, ellipsis: true, sorter: true },
|
||||
{ title: '고객명', dataIndex: 'name', width: 90 },
|
||||
{ title: '구분', dataIndex: 'gubun', width: 64 },
|
||||
{ title: '종류', dataIndex: 'openhow', width: 80 },
|
||||
{ title: '개통번호', dataIndex: 'openphone', width: 120 },
|
||||
{ title: '상태', dataIndex: 'openStatus', width: 64 },
|
||||
{ title: '모델명', dataIndex: 'pmodel', width: 130, ellipsis: true, sorter: true },
|
||||
{ title: '일련번호', dataIndex: 'pserial', width: 120, ellipsis: true },
|
||||
{ title: '입고처', dataIndex: 'incompanyName', width: 110, ellipsis: true },
|
||||
{ title: '정산', dataIndex: 'sellprice', width: 90, align: 'right' as const, sorter: true, render: (v: unknown) => <span className="open-settle">{money(v)}</span> },
|
||||
{ title: '차감', dataIndex: 'declprice1', width: 80, align: 'right' as const, render: money },
|
||||
{ title: '마진', dataIndex: 'dealermargin1', width: 80, align: 'right' as const, render: money },
|
||||
{ title: '관리', width: 58, fixed: 'right' as const, render: (_: unknown, row: OpenRecord) => (
|
||||
<Button size="small" type="text" icon={<FileTextOutlined style={{ color: '#1677ff' }} />} onClick={() => navigate(`/care/open/open-form-write1?id=${row.id}`)} />
|
||||
) },
|
||||
]
|
||||
|
||||
return <div className="stock-page open-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>개통관리</h2>
|
||||
<p>모바일 개통과 관련된 모든 업무를 관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<EditOutlined />} onClick={() => navigate('/care/open/open-form-write1')}>할부 개통</Button>
|
||||
<Button type="primary" icon={<EditOutlined />} onClick={() => navigate('/care/open/open-form-write2')}>현금 개통</Button>
|
||||
<Button icon={<EditOutlined />} onClick={() => navigate('/care/open/open-form-write3')}>유심 개통</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form form={form} layout="inline" onFinish={search}>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates"><RangePicker className="w-230" /></Form.Item>
|
||||
<Space.Compact>
|
||||
<Button onClick={() => form.setFieldValue('dates', [dayjs(), dayjs()])}>오늘</Button>
|
||||
<Button onClick={() => form.setFieldValue('dates', [dayjs().subtract(6, 'day'), dayjs()])}>1주일</Button>
|
||||
<Button onClick={() => form.setFieldValue('dates', [dayjs().startOf('month'), dayjs().endOf('month')])}>당월</Button>
|
||||
<Button onClick={() => form.setFieldValue('dates', [dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month')])}>전월</Button>
|
||||
<Button onClick={() => form.setFieldValue('dates', [dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month')])}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => form.setFieldValue('dates', undefined)} />
|
||||
<Button onClick={() => form.getFieldInstance('dates')?.focus?.()}>날짜설정</Button>
|
||||
</div>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="gubun"><Select allowClear placeholder="-구분-" className="w-110" options={options(['할부', '현금', '유심'])} /></Form.Item>
|
||||
<Form.Item name="history"><Select allowClear placeholder="-이력-" className="w-110" options={options(['OPEN', 'PENDING', 'CANCEL'])} /></Form.Item>
|
||||
<Form.Item name="telecom"><Select allowClear placeholder="-통신사-" className="w-110" options={options(['SKT', 'KT', 'LGU+'])} /></Form.Item>
|
||||
<Form.Item name="incompanyId"><Select allowClear placeholder="-입고처-" className="w-150" options={inOptions} /></Form.Item>
|
||||
<Form.Item name="outCategory"><Select allowClear placeholder="-출고분류-" className="w-120" options={options(['판매점', '도매'])} /></Form.Item>
|
||||
<Form.Item name="outcompanyName"><Input placeholder="출고처" className="w-130" /></Form.Item>
|
||||
</div>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="salesperson"><Select allowClear placeholder="-영업사원-" className="w-120" options={options(['김영업', '이영업'])} /></Form.Item>
|
||||
<Form.Item name="employee"><Select allowClear placeholder="-직원-" className="w-110" options={options(['demo'])} /></Form.Item>
|
||||
<Form.Item name="openhow"><Select allowClear placeholder="-종류-" className="w-120" options={options(['신규', '번호이동', '보상', '기변', '메이징', '가개통', '선불개통'])} /></Form.Item>
|
||||
<Form.Item name="openStatus"><Select allowClear placeholder="-상태-" className="w-110" options={options(['정상', '취소', '보류'])} /></Form.Item>
|
||||
<Form.Item name="model"><Input placeholder="모델명" className="w-130" /></Form.Item>
|
||||
<Form.Item name="phoneTail"><Input placeholder="개통번호(뒷4자리)" className="w-150" maxLength={4} /></Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" initialValue={15} className="open-size-item"><Select className="w-130" options={[15, 30, 50, 100].map((v) => ({ value: v, label: `목록 ${v}개씩 보기` }))} /></Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="stock-table-actions">
|
||||
<Tabs
|
||||
activeKey={String(filters.tab)}
|
||||
onChange={(tab) => setFilters((v) => ({ ...v, tab, page: 0 }))}
|
||||
items={tabs.map(([key, label]) => ({
|
||||
key,
|
||||
label: key === 'ALL' ? `${label} ${query.data?.counts?.[key] ?? 0}` : `${label} ${query.data?.counts?.[key] ?? 0}` }))}
|
||||
/>
|
||||
<Space>
|
||||
<Button type="link">엑셀설정</Button>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1700 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `총 ${total}건`,
|
||||
onChange: (page) => setFilters((v) => ({ ...v, page: page - 1 })) }}
|
||||
onChange={(_, __, sorter) => {
|
||||
const order = Array.isArray(sorter) ? sorter[0] : sorter
|
||||
if (order?.field) setFilters((v) => ({ ...v, sort: `${String(order.field)},${order.order === 'ascend' ? 'asc' : 'desc'}` }))
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Button, Card, DatePicker, Form, Input, Select, Space, Tooltip, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import {
|
||||
CloseOutlined, DownloadOutlined, MailOutlined, ReloadOutlined, SearchOutlined, UserSwitchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const searchTypes = ['개통번호(뒷4자리)', '고객명', '기존명의자', '기존연락처']
|
||||
|
||||
type NameChangeRow = Record<string, unknown> & { id: number; no?: number; memo?: string }
|
||||
type NameChangeData = { total: number; list: NameChangeRow[] }
|
||||
|
||||
const initial = { page: 0, size: 15, searchType: '개통번호(뒷4자리)' } as Record<string, unknown>
|
||||
|
||||
const nameChangeApi = {
|
||||
async search(params: Record<string, unknown>) {
|
||||
const { data } = await client.get<ApiResponse<NameChangeData>>(`${apiPrefix()}/namechanges/search`, { params })
|
||||
return unwrap(data)
|
||||
},
|
||||
async remove(ids: number[]) {
|
||||
const { data } = await client.post<ApiResponse>(`${apiPrefix()}/namechanges/delete`, { ids })
|
||||
return unwrap(data)
|
||||
} }
|
||||
|
||||
export default function OpenNameChangePage() {
|
||||
const [form] = Form.useForm()
|
||||
const qc = useQueryClient()
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['open-name-change', filters],
|
||||
queryFn: () => nameChangeApi.search(filters) })
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => nameChangeApi.remove(selected),
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
setSelected([])
|
||||
qc.invalidateQueries({ queryKey: ['open-name-change'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setSelected([])
|
||||
setFilters({
|
||||
...initial,
|
||||
outcompanyName: values.outcompanyName ? String(values.outcompanyName) : undefined,
|
||||
searchType: values.searchType || '개통번호(뒷4자리)',
|
||||
keyword: values.keyword ? String(values.keyword) : undefined,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 15 })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ searchType: '개통번호(뒷4자리)', size: 15 })
|
||||
setSelected([])
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const clearDates = () => form.setFieldValue('dates', undefined)
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/namechanges/export`, { params: filters, responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '개통명변이력.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ title: '번호', dataIndex: 'no', width: 64, align: 'center' as const },
|
||||
{ title: '명변일', dataIndex: 'changeDate', width: 110 },
|
||||
{ title: '출고처', dataIndex: 'outcompanyName', width: 120, ellipsis: true },
|
||||
{ title: '고객명', dataIndex: 'customerName', width: 90 },
|
||||
{ title: '개통일', dataIndex: 'openDate', width: 110 },
|
||||
{ title: '개통번호', dataIndex: 'openphone', width: 130 },
|
||||
{ title: '기존명의자', dataIndex: 'oldOwner', width: 100 },
|
||||
{ title: '기존연락처', dataIndex: 'oldPhone', width: 130 },
|
||||
{ title: '기존유심', dataIndex: 'oldUsim', width: 140, ellipsis: true },
|
||||
{ title: '처리직원', dataIndex: 'processor', width: 90 },
|
||||
{ title: '처리일', dataIndex: 'processedAt', width: 140 },
|
||||
{
|
||||
title: '비고',
|
||||
dataIndex: 'memo',
|
||||
width: 54,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => (v ? <Tooltip title={v}><MailOutlined className="memo-icon" /></Tooltip> : '-') },
|
||||
], [])
|
||||
|
||||
return (
|
||||
<div className="stock-page name-change-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><UserSwitchOutlined style={{ marginRight: 8 }} />개통 명변이력</h2>
|
||||
<p>모바일 개통과 관련된 이력을 확인하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap align="start">
|
||||
<Typography.Text type="secondary" className="sheet-notice">* 대표권한은 이력을 삭제하실 수 있습니다.</Typography.Text>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
loading={remove.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('삭제할 이력을 선택하세요.')
|
||||
return
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '이력 삭제',
|
||||
content: `선택한 ${selected.length}건의 이력을 삭제하시겠습니까?`,
|
||||
onOk: () => remove.mutateAsync() })
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="name-change-filter"
|
||||
initialValues={{ searchType: '개통번호(뒷4자리)', size: 15 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={clearDates} />
|
||||
<Form.Item name="outcompanyName">
|
||||
<Input placeholder="출고처" className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-150" options={searchTypes.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((prev) => ({ ...prev, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="recovery-table-head">
|
||||
<Typography.Text>목록 <b>{query.data?.total ?? 0}</b></Typography.Text>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selected,
|
||||
onChange: (keys) => setSelected(keys.map(Number)) }}
|
||||
scroll={{ x: 1500 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `총 ${total}건`,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
locale={{ emptyText: '등록되어 있는 자료가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Button, Card, DatePicker, Form, Input, Select, Space, Tabs, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { CloseOutlined, DownloadOutlined, MobileOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { resourceApi } from '../../api/resources'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const tabs = [
|
||||
['ALL', '전체'],
|
||||
['반납', '반납'],
|
||||
['미반납', '미반납'],
|
||||
] as const
|
||||
const searchTypes = ['개통번호(뒷4자리)', '고객명', '모델명', '일련번호']
|
||||
const options = (values: string[]) => values.map((value) => ({ value, label: value }))
|
||||
const money = (v: unknown) => (v == null || v === '' ? '-' : Number(v).toLocaleString())
|
||||
|
||||
type ReturnRow = Record<string, unknown> & { id: number; returnStatus?: string }
|
||||
type ReturnSearch = { total: number; list: ReturnRow[]; counts: Record<string, number> }
|
||||
|
||||
const initial = { page: 0, size: 15, tab: 'ALL', searchType: '개통번호(뒷4자리)' } as Record<string, unknown>
|
||||
|
||||
export default function OpenReturnPage() {
|
||||
const [form] = Form.useForm()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
|
||||
const companies = useQuery({ queryKey: ['return-companies'], queryFn: () => resourceApi.list('companies', { size: 200 }) })
|
||||
const inOptions = useMemo(
|
||||
() => (companies.data?.items ?? []).filter((c) => String(c.type) === 'IN').map((c) => ({ value: c.id, label: String(c.name) })),
|
||||
[companies.data],
|
||||
)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['return-search', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<ReturnSearch>>(`${apiPrefix()}/returns/search`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setFilters({
|
||||
...initial,
|
||||
...values,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 15,
|
||||
tab: filters.tab,
|
||||
searchType: values.searchType || '개통번호(뒷4자리)' })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ searchType: '개통번호(뒷4자리)', size: 15 })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const clearDates = () => form.setFieldValue('dates', undefined)
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/returns/export`, { params: filters, responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '반납단말기.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '개통일', dataIndex: 'opendate', width: 110 },
|
||||
{ title: '출고처', dataIndex: 'outcompanyName', width: 130, ellipsis: true },
|
||||
{ title: '고객명', dataIndex: 'customerName', width: 90 },
|
||||
{ title: '개통번호', dataIndex: 'openphone', width: 130 },
|
||||
{
|
||||
title: '구분',
|
||||
dataIndex: 'returnStatus',
|
||||
width: 80,
|
||||
render: (v: string) => (
|
||||
<span className={v === '미반납' ? 'return-status-pending' : 'return-status-done'}>{v || '미반납'}</span>
|
||||
) },
|
||||
{ title: '모델명', dataIndex: 'model', width: 130, ellipsis: true },
|
||||
{ title: '일련번호', dataIndex: 'serial', width: 120, ellipsis: true },
|
||||
{ title: '차감금액', dataIndex: 'deductAmount', width: 100, align: 'right' as const, render: money },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stock-page return-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><MobileOutlined style={{ marginRight: 8 }} />반납단말기</h2>
|
||||
<p>반납단말기에 등록된 내역을 모두 확인하실 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="return-filter"
|
||||
initialValues={{ searchType: '개통번호(뒷4자리)', size: 15 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={clearDates} />
|
||||
<Form.Item name="telecom">
|
||||
<Select allowClear placeholder="-통신사-" className="w-110" options={options(['SKT', 'KT', 'LGU+'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="incompanyId">
|
||||
<Select allowClear placeholder="-입고처-" className="w-150" options={inOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="outcompanyName">
|
||||
<Input placeholder="출고처" className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-150" options={searchTypes.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="stock-table-actions return-table-actions">
|
||||
<Tabs
|
||||
activeKey={String(filters.tab)}
|
||||
onChange={(tab) => setFilters((v) => ({ ...v, tab, page: 0 }))}
|
||||
items={tabs.map(([key, label]) => ({
|
||||
key,
|
||||
label: `${label} ${query.data?.counts?.[key] ?? 0}` }))}
|
||||
/>
|
||||
<Space>
|
||||
<Select
|
||||
className="w-150"
|
||||
value={Number(filters.size)}
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => setFilters((v) => ({ ...v, size, page: 0 }))}
|
||||
/>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1000 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `총 ${total}건`,
|
||||
onChange: (page) => setFilters((v) => ({ ...v, page: page - 1 })) }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Button, Card, DatePicker, Form, Input, Select, Space, Tooltip, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import {
|
||||
CloseOutlined, DownloadOutlined, MailOutlined, ReloadOutlined, SearchOutlined, WalletOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const searchTypes = ['개통번호(뒷4자리)', '고객명', '처리직원', '비고']
|
||||
const money = (v: unknown) => (v == null || v === '' ? '-' : Number(v).toLocaleString())
|
||||
|
||||
type SettleHistoryRow = Record<string, unknown> & { id: number; no?: number; memo?: string }
|
||||
type SettleHistoryData = { total: number; list: SettleHistoryRow[] }
|
||||
|
||||
const initial = { page: 0, size: 15, searchType: '개통번호(뒷4자리)' } as Record<string, unknown>
|
||||
|
||||
const settleHistoryApi = {
|
||||
async search(params: Record<string, unknown>) {
|
||||
const { data } = await client.get<ApiResponse<SettleHistoryData>>(`${apiPrefix()}/settlehistories/search`, { params })
|
||||
return unwrap(data)
|
||||
},
|
||||
async remove(ids: number[]) {
|
||||
const { data } = await client.post<ApiResponse>(`${apiPrefix()}/settlehistories/delete`, { ids })
|
||||
return unwrap(data)
|
||||
} }
|
||||
|
||||
export default function OpenSettleHistoryPage() {
|
||||
const [form] = Form.useForm()
|
||||
const qc = useQueryClient()
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['open-settle-history', filters],
|
||||
queryFn: () => settleHistoryApi.search(filters) })
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => settleHistoryApi.remove(selected),
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
setSelected([])
|
||||
qc.invalidateQueries({ queryKey: ['open-settle-history'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setSelected([])
|
||||
setFilters({
|
||||
...initial,
|
||||
outcompanyName: values.outcompanyName ? String(values.outcompanyName) : undefined,
|
||||
searchType: values.searchType || '개통번호(뒷4자리)',
|
||||
keyword: values.keyword ? String(values.keyword) : undefined,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 15 })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ searchType: '개통번호(뒷4자리)', size: 15 })
|
||||
setSelected([])
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const clearDates = () => form.setFieldValue('dates', undefined)
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/settlehistories/export`, { params: filters, responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '개통정산금이력.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ title: '번호', dataIndex: 'no', width: 64, align: 'center' as const },
|
||||
{ title: '변경일', dataIndex: 'changeDate', width: 110 },
|
||||
{ title: '출고처', dataIndex: 'outcompanyName', width: 120, ellipsis: true },
|
||||
{ title: '고객명', dataIndex: 'customerName', width: 90 },
|
||||
{ title: '개통일', dataIndex: 'openDate', width: 110 },
|
||||
{ title: '개통번호', dataIndex: 'openphone', width: 130 },
|
||||
{
|
||||
title: '기존정산금', dataIndex: 'oldAmount', width: 110, align: 'right' as const,
|
||||
render: (v: unknown) => <span className={Number(v) < 0 ? 'settle-amt-neg' : ''}>{money(v)}</span> },
|
||||
{
|
||||
title: '변경정산금', dataIndex: 'newAmount', width: 110, align: 'right' as const,
|
||||
render: (v: unknown) => <span className={Number(v) < 0 ? 'settle-amt-neg' : ''}>{money(v)}</span> },
|
||||
{ title: '처리직원', dataIndex: 'processor', width: 90 },
|
||||
{ title: '처리일', dataIndex: 'processedAt', width: 140 },
|
||||
{
|
||||
title: '비고',
|
||||
dataIndex: 'memo',
|
||||
width: 54,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => (v ? <Tooltip title={v}><MailOutlined className="memo-icon" /></Tooltip> : '-') },
|
||||
], [])
|
||||
|
||||
return (
|
||||
<div className="stock-page settle-history-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><WalletOutlined style={{ marginRight: 8 }} />개통 정산금이력</h2>
|
||||
<p>모바일 개통과 관련된 이력을 확인하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap align="start">
|
||||
<Typography.Text type="secondary" className="sheet-notice">* 대표권한은 이력을 삭제하실 수 있습니다.</Typography.Text>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
loading={remove.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('삭제할 이력을 선택하세요.')
|
||||
return
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '이력 삭제',
|
||||
content: `선택한 ${selected.length}건의 이력을 삭제하시겠습니까?`,
|
||||
onOk: () => remove.mutateAsync() })
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="settle-history-filter"
|
||||
initialValues={{ searchType: '개통번호(뒷4자리)', size: 15 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={clearDates} />
|
||||
<Form.Item name="outcompanyName">
|
||||
<Input placeholder="출고처" className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-150" options={searchTypes.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((prev) => ({ ...prev, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="recovery-table-head">
|
||||
<Typography.Text>목록 <b>{query.data?.total ?? 0}</b></Typography.Text>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selected,
|
||||
onChange: (keys) => setSelected(keys.map(Number)) }}
|
||||
scroll={{ x: 1400 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `총 ${total}건`,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
locale={{ emptyText: '등록되어 있는 자료가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Button, Card, DatePicker, Select, Space, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { ArrowLeftOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => ({ value: i, label: `${String(i).padStart(2, '0')}시` }))
|
||||
const minutes = [0, 10, 20, 30, 40, 50].map((v) => ({ value: v, label: `${String(v).padStart(2, '0')}분` }))
|
||||
const money = (v: unknown) => (v == null || v === '' ? '-' : Number(v).toLocaleString())
|
||||
|
||||
type OpenRow = Record<string, unknown> & { id: number }
|
||||
|
||||
export default function OpenUnitCostApplyPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const tableId = Number(id)
|
||||
const [from, setFrom] = useState<Dayjs>(dayjs())
|
||||
const [to, setTo] = useState<Dayjs>(dayjs())
|
||||
const [fromHour, setFromHour] = useState<number | undefined>()
|
||||
const [fromMinute, setFromMinute] = useState<number | undefined>()
|
||||
const [toHour, setToHour] = useState<number | undefined>()
|
||||
const [toMinute, setToMinute] = useState<number | undefined>()
|
||||
const [searched, setSearched] = useState(false)
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({})
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
|
||||
const enabled = searched && !!filters.dateFrom && !!filters.dateTo
|
||||
const query = useQuery({
|
||||
queryKey: ['unitcost-apply', tableId, filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ total: number; list: OpenRow[]; table: Record<string, unknown> }>>(
|
||||
`${apiPrefix()}/unitcosts/${tableId}/apply-search`,
|
||||
{ params: filters },
|
||||
)
|
||||
return unwrap(data)
|
||||
},
|
||||
enabled: enabled && Number.isFinite(tableId) })
|
||||
|
||||
const apply = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selected.length) throw new Error('적용할 개통을 선택하세요.')
|
||||
const { data } = await client.post(`${apiPrefix()}/unitcosts/${tableId}/apply`, { ids: selected })
|
||||
return unwrap(data as ApiResponse<{ updated: number }>)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
message.success(`${data.updated}건에 정책단가를 적용했습니다.`)
|
||||
setSelected([])
|
||||
query.refetch()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const onSearch = () => {
|
||||
if (!from || !to) {
|
||||
message.warning('개통일을 선택하세요.')
|
||||
return
|
||||
}
|
||||
if (to.diff(from, 'day') > 2) {
|
||||
message.warning('최대 3일까지 조회할 수 있습니다.')
|
||||
return
|
||||
}
|
||||
setSearched(true)
|
||||
setSelected([])
|
||||
setFilters({
|
||||
dateFrom: from.format('YYYY-MM-DD'),
|
||||
dateTo: to.format('YYYY-MM-DD'),
|
||||
fromHour,
|
||||
fromMinute,
|
||||
toHour,
|
||||
toMinute })
|
||||
}
|
||||
|
||||
const tableLabel = query.data?.table
|
||||
? `${query.data.table.telecom ?? ''} (${query.data.table.incompanyName ?? ''})`
|
||||
: ''
|
||||
|
||||
const columns = [
|
||||
{ title: '개통일', dataIndex: 'opendate', width: 100 },
|
||||
{ title: '통신사', dataIndex: 'telecom', width: 70 },
|
||||
{ title: '출고처', dataIndex: 'outcompanyName', width: 110, ellipsis: true },
|
||||
{ title: '고객명', dataIndex: 'name', width: 90 },
|
||||
{ title: '종류', dataIndex: 'openhow', width: 80 },
|
||||
{ title: '모델명', dataIndex: 'pmodel', width: 140, ellipsis: true },
|
||||
{ title: '요금제', dataIndex: 'plan', width: 120, ellipsis: true },
|
||||
{ title: '기본정책', dataIndex: 'basicprice1', width: 100, align: 'right' as const, render: money },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stock-page unitcost-apply-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>정책단가표</h2>
|
||||
<p>통신사별로 정책단가표를 작성하여 기본정책금액을 일괄적으로 적용하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/care/open/unitcost')}>목록</Button>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small" title={`${tableLabel} 정책적용`}>
|
||||
<Space wrap>
|
||||
<DatePicker value={from} onChange={(v) => v && setFrom(v)} />
|
||||
<Select allowClear placeholder="-시-" className="w-100" options={hours} value={fromHour} onChange={setFromHour} />
|
||||
<Select allowClear placeholder="-분-" className="w-100" options={minutes} value={fromMinute} onChange={setFromMinute} />
|
||||
<span>~</span>
|
||||
<DatePicker value={to} onChange={(v) => v && setTo(v)} />
|
||||
<Select allowClear placeholder="-시-" className="w-100" options={hours} value={toHour} onChange={setToHour} />
|
||||
<Select allowClear placeholder="-분-" className="w-100" options={minutes} value={toMinute} onChange={setToMinute} />
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={onSearch}>검색</Button>
|
||||
<Typography.Text type="danger">* 최대 3일</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card sheet-result-card" size="small">
|
||||
{!enabled ? (
|
||||
<div className="sheet-empty">
|
||||
<div>단가표를 적용할 개통일자를 선택 후 검색해주세요.</div>
|
||||
<div className="unitcost-apply-sub">(신규/MNP/기변/보상 개통자료만 검색됩니다. 유심개통제외)</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="recovery-table-head">
|
||||
<Typography.Text>검색된 개통 <b>{query.data?.total ?? 0}</b> 건</Typography.Text>
|
||||
<Button type="primary" loading={apply.isPending} onClick={() => apply.mutate()}>선택 개통에 정책적용</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selected,
|
||||
onChange: (keys) => setSelected(keys.map(Number)) }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `총 ${total}건` }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
EditOutlined, PlusOutlined, QuestionCircleOutlined, TableOutlined, DollarOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { resourceApi } from '../../api/resources'
|
||||
|
||||
const planGroups = ['A', 'B', 'C', 'D', 'E', 'F', 'G'] as const
|
||||
type UnitCost = Record<string, unknown> & {
|
||||
id: number
|
||||
telecom?: string
|
||||
incompanyName?: string
|
||||
memo?: string
|
||||
category?: string
|
||||
policyCount?: number
|
||||
planGroupA?: number | null
|
||||
planGroupB?: number | null
|
||||
planGroupC?: number | null
|
||||
planGroupD?: number | null
|
||||
planGroupE?: number | null
|
||||
planGroupF?: number | null
|
||||
planGroupG?: number | null
|
||||
}
|
||||
|
||||
function planCount(row: UnitCost, group: string) {
|
||||
return row[`planGroup${group}` as keyof UnitCost] as number | null | undefined
|
||||
}
|
||||
|
||||
function PlanGroupCell({ value }: { value?: number | null }) {
|
||||
if (value == null) return <span className="unitcost-unused">- 미사용 -</span>
|
||||
if (value === 0) return <Button type="link" size="small" icon={<PlusOutlined />} />
|
||||
return <span>{value}개</span>
|
||||
}
|
||||
|
||||
export default function OpenUnitCostPage() {
|
||||
const navigate = useNavigate()
|
||||
const clientQuery = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [policyForm] = Form.useForm()
|
||||
const [editing, setEditing] = useState<UnitCost | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [policyTarget, setPolicyTarget] = useState<UnitCost | null>(null)
|
||||
|
||||
const companies = useQuery({ queryKey: ['unitcost-companies'], queryFn: () => resourceApi.list('companies', { size: 200 }) })
|
||||
const inOptions = useMemo(
|
||||
() => (companies.data?.items ?? []).filter((c) => String(c.type) === 'IN').map((c) => ({ value: c.id, label: String(c.name) })),
|
||||
[companies.data],
|
||||
)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['unitcosts'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ total: number; list: UnitCost[]; max: number }>>(`${apiPrefix()}/unitcosts`)
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => clientQuery.invalidateQueries({ queryKey: ['unitcosts'] })
|
||||
|
||||
const policies = useQuery({
|
||||
queryKey: ['unitcost-policies', policyTarget?.id],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ list: Record<string, unknown>[] }>>(`${apiPrefix()}/unitcosts/${policyTarget!.id}/policies`)
|
||||
return unwrap(data)
|
||||
},
|
||||
enabled: Boolean(policyTarget) })
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
...values,
|
||||
planGroupA: Number(values.planGroupCount ?? 1),
|
||||
planGroupB: 0,
|
||||
planGroupC: 0,
|
||||
planGroupD: 0,
|
||||
incompanyName: inOptions.find((o) => o.value === values.incompanyId)?.label }
|
||||
delete payload.planGroupCount
|
||||
if (editing) {
|
||||
const { data } = await client.put(`${apiPrefix()}/unitcosts/${editing.id}`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/unitcosts`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '수정했습니다.' : '단가표를 생성했습니다.')
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const addPolicy = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/unitcosts/${policyTarget!.id}/policies`, values)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('정책단가를 등록했습니다.')
|
||||
policyForm.resetFields()
|
||||
clientQuery.invalidateQueries({ queryKey: ['unitcost-policies', policyTarget?.id] })
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setCreating(true)
|
||||
form.setFieldsValue({ category: '표준', planGroupCount: 1, telecom: undefined, memo: undefined, incompanyId: undefined })
|
||||
}
|
||||
|
||||
const openEdit = (row: UnitCost) => {
|
||||
setCreating(false)
|
||||
setEditing(row)
|
||||
form.setFieldsValue({
|
||||
telecom: row.telecom,
|
||||
memo: row.memo,
|
||||
incompanyId: row.incompanyId,
|
||||
category: row.category || '표준',
|
||||
planGroupCount: row.planGroupA || 1 })
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: <Checkbox />, width: 42, align: 'center' as const, render: () => <Checkbox /> },
|
||||
{ title: '통신사', dataIndex: 'telecom', width: 80, align: 'center' as const },
|
||||
{ title: '입고처', dataIndex: 'incompanyName', width: 100, align: 'center' as const },
|
||||
...planGroups.map((g) => ({
|
||||
title: `요금제${g}군`,
|
||||
key: `planGroup${g}`,
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: UnitCost) => <PlanGroupCell value={planCount(row, g)} /> })),
|
||||
{
|
||||
title: '정책단가',
|
||||
dataIndex: 'policyCount',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
render: (count: number, row: UnitCost) => (
|
||||
<Button size="small" onClick={() => setPolicyTarget(row)}>
|
||||
{count > 0 ? `정책단가 ${count}개` : '정책단가 작성'}
|
||||
</Button>
|
||||
) },
|
||||
{ title: '비고', dataIndex: 'memo', width: 160, ellipsis: true },
|
||||
{
|
||||
title: '정책적용',
|
||||
width: 140,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: UnitCost) => (
|
||||
<Button type="primary" size="small" icon={<DollarOutlined />} onClick={() => navigate(`/care/open/unitcost-apply/${row.id}`)}>
|
||||
개통정책적용
|
||||
</Button>
|
||||
) },
|
||||
{
|
||||
title: '관리',
|
||||
width: 60,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: UnitCost) => (
|
||||
<Button type="text" size="small" icon={<EditOutlined style={{ color: '#1677ff' }} />} onClick={() => openEdit(row)} />
|
||||
) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stock-page unitcost-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><TableOutlined style={{ marginRight: 8 }} />정책단가표</h2>
|
||||
<p>통신3사의 정책단가표를 작성하여 기본정책금액을 일괄적으로 적용하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<QuestionCircleOutlined />} onClick={() => message.info('단가표를 생성한 뒤 정책단가를 작성하고, 개통정책적용으로 일괄 반영하세요.')}>사용방법</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>단가표 생성</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="unitcost-head">
|
||||
<Typography.Text>등록된 단가표 <b>{query.data?.total ?? 0}</b></Typography.Text>
|
||||
<Typography.Text type="danger" className="unitcost-note">* 단가표는 최대 20개 등록이 가능하며 정책단가를 수정해서 사용해주세요.</Typography.Text>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
scroll={{ x: 1400 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '단가표 수정' : '단가표 생성'}
|
||||
open={creating || Boolean(editing)}
|
||||
onCancel={() => { setCreating(false); setEditing(null) }}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={480}
|
||||
>
|
||||
<Form form={form} layout="horizontal" labelCol={{ flex: '110px' }} onFinish={(v) => save.mutate(v)} className="unitcost-form">
|
||||
<Form.Item name="telecom" label="통신사" rules={[{ required: true, message: '통신사를 선택하세요' }]}
|
||||
extra={<span className="unitcost-extra">* 통신 3사만 지원합니다.</span>}>
|
||||
<Select placeholder="- 선택 -" options={['SKT', 'KT', 'LGU+'].map((v) => ({ value: v, label: v }))} disabled={Boolean(editing)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="planGroupCount" label="요금제군" rules={[{ required: true, message: '요금제군을 선택하세요' }]}>
|
||||
<Select placeholder="- 선택 -" options={[1, 2, 3, 4, 5, 6, 7].map((v) => ({ value: v, label: `${v}개` }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="비고"><Input /></Form.Item>
|
||||
<Form.Item name="incompanyId" label="입고처"
|
||||
extra={<span className="unitcost-extra">* 지정입고처의 개통만 적용할 경우 선택해주세요.</span>}>
|
||||
<Select allowClear placeholder="- 선택 -" options={inOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="단가표구분" rules={[{ required: true }]}>
|
||||
<Radio.Group options={[{ value: '표준', label: '표준' }, { value: '공시/선택구분', label: '공시/선택구분' }]} />
|
||||
</Form.Item>
|
||||
<div className="unitcost-modal-footer">
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>{editing ? '수정합니다' : '생성합니다'}</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`정책단가 작성 - ${policyTarget?.telecom ?? ''} (${policyTarget?.incompanyName ?? ''})`}
|
||||
open={Boolean(policyTarget)}
|
||||
onCancel={() => setPolicyTarget(null)}
|
||||
footer={null}
|
||||
width={720}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={policyForm} layout="inline" className="unitcost-policy-form" onFinish={(v) => addPolicy.mutate(v)}
|
||||
initialValues={{ planGroup: 'A', amount: 100000 }}>
|
||||
<Form.Item name="planGroup" label="군" rules={[{ required: true }]}>
|
||||
<Select className="w-100" options={planGroups.map((g) => ({ value: g, label: `${g}군` }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="model" label="모델"><Input placeholder="모델명 포함" className="w-130" /></Form.Item>
|
||||
<Form.Item name="planName" label="요금제"><Input placeholder="요금제" className="w-130" /></Form.Item>
|
||||
<Form.Item name="amount" label="금액" rules={[{ required: true }]}>
|
||||
<InputNumber className="w-130" controls={false} addonAfter="원" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={addPolicy.isPending}>추가</Button>
|
||||
</Form>
|
||||
<CareTable
|
||||
className="unitcost-policy-table"
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={policies.isLoading}
|
||||
dataSource={policies.data?.list}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '군', dataIndex: 'planGroup', width: 60 },
|
||||
{ title: '모델', dataIndex: 'model' },
|
||||
{ title: '요금제', dataIndex: 'planName' },
|
||||
{ title: '금액', dataIndex: 'amount', align: 'right' as const, render: (v: number) => Number(v ?? 0).toLocaleString() },
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Button, Card, DatePicker, Form, Input, Select, Space, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import {
|
||||
CloseOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const searchTypes = ['개통번호(뒷4자리)', '고객명', '사유 및 비고']
|
||||
|
||||
type WithdrawalRow = Record<string, unknown> & { id: number; no?: number }
|
||||
type WithdrawalData = { total: number; list: WithdrawalRow[] }
|
||||
|
||||
const initial = { page: 0, size: 15, searchType: '개통번호(뒷4자리)' } as Record<string, unknown>
|
||||
|
||||
const withdrawalApi = {
|
||||
async search(params: Record<string, unknown>) {
|
||||
const { data } = await client.get<ApiResponse<WithdrawalData>>(`${apiPrefix()}/withdrawals/search`, { params })
|
||||
return unwrap(data)
|
||||
},
|
||||
async remove(ids: number[]) {
|
||||
const { data } = await client.post<ApiResponse>(`${apiPrefix()}/withdrawals/delete`, { ids })
|
||||
return unwrap(data)
|
||||
} }
|
||||
|
||||
export default function OpenWithdrawPage() {
|
||||
const [form] = Form.useForm()
|
||||
const qc = useQueryClient()
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['open-withdraw', filters],
|
||||
queryFn: () => withdrawalApi.search(filters) })
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => withdrawalApi.remove(selected),
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
setSelected([])
|
||||
qc.invalidateQueries({ queryKey: ['open-withdraw'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setSelected([])
|
||||
setFilters({
|
||||
...initial,
|
||||
outcompanyName: values.outcompanyName ? String(values.outcompanyName) : undefined,
|
||||
searchType: values.searchType || '개통번호(뒷4자리)',
|
||||
keyword: values.keyword ? String(values.keyword) : undefined,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 15 })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ searchType: '개통번호(뒷4자리)', size: 15 })
|
||||
setSelected([])
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const clearDates = () => form.setFieldValue('dates', undefined)
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/withdrawals/export`, { params: filters, responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '개통철회이력.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ title: '번호', dataIndex: 'no', width: 64, align: 'center' as const },
|
||||
{ title: '철회일', dataIndex: 'withdrawDate', width: 110 },
|
||||
{ title: '출고처', dataIndex: 'outcompanyName', width: 130, ellipsis: true },
|
||||
{ title: '고객명', dataIndex: 'customerName', width: 90 },
|
||||
{ title: '개통일', dataIndex: 'openDate', width: 110 },
|
||||
{ title: '개통번호', dataIndex: 'openphone', width: 130 },
|
||||
{ title: '처리직원', dataIndex: 'processor', width: 90 },
|
||||
{ title: '처리일', dataIndex: 'processedAt', width: 140 },
|
||||
{ title: '사유 및 비고', dataIndex: 'reason', width: 160, ellipsis: true, render: (v: string) => v || '-' },
|
||||
], [])
|
||||
|
||||
return (
|
||||
<div className="stock-page withdraw-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><RollbackOutlined style={{ marginRight: 8 }} />개통 철회이력</h2>
|
||||
<p>모바일 개통과 관련된 이력을 확인하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap align="start">
|
||||
<Typography.Text type="secondary" className="sheet-notice">* 대표권한은 이력을 삭제하실 수 있습니다.</Typography.Text>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
loading={remove.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('삭제할 이력을 선택하세요.')
|
||||
return
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '이력 삭제',
|
||||
content: `선택한 ${selected.length}건의 이력을 삭제하시겠습니까?`,
|
||||
onOk: () => remove.mutateAsync() })
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="withdraw-filter"
|
||||
initialValues={{ searchType: '개통번호(뒷4자리)', size: 15 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={clearDates} />
|
||||
<Form.Item name="outcompanyName">
|
||||
<Input placeholder="출고처" className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-150" options={searchTypes.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((prev) => ({ ...prev, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="recovery-table-head">
|
||||
<Typography.Text>목록 <b>{query.data?.total ?? 0}</b></Typography.Text>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selected,
|
||||
onChange: (keys) => setSelected(keys.map(Number)) }}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `총 ${total}건`,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
locale={{ emptyText: '등록되어 있는 자료가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
AutoComplete, Button, Card, Checkbox, Col, DatePicker, Form, Input, InputNumber, Radio, Row, Select, Space, message } from 'antd'
|
||||
import { openApi } from '../../api/opens'
|
||||
import { resourceApi } from '../../api/resources'
|
||||
import { stockApi } from '../../api/stocks'
|
||||
|
||||
const options = (values: string[]) => values.map((value) => ({ value, label: value }))
|
||||
const hours = Array.from({ length: 24 }, (_, i) => ({ value: i, label: `${String(i).padStart(2, '0')}시` }))
|
||||
const minutes = [0, 10, 20, 30, 40, 50].map((v) => ({ value: v, label: `${String(v).padStart(2, '0')}분` }))
|
||||
|
||||
type OpenType = '할부' | '현금' | '유심'
|
||||
const typeFromPath = (path?: string): OpenType => {
|
||||
if (path?.includes('write2')) return '현금'
|
||||
if (path?.includes('write3')) return '유심'
|
||||
return '할부'
|
||||
}
|
||||
const pathForType = (type: OpenType) => ({
|
||||
할부: '/care/open/open-form-write1',
|
||||
현금: '/care/open/open-form-write2',
|
||||
유심: '/care/open/open-form-write3' }[type])
|
||||
|
||||
function MoneyField({ name, label, tip }: { name: string; label: string; tip?: string }) {
|
||||
return (
|
||||
<Form.Item name={name} label={label} tooltip={tip}>
|
||||
<InputNumber className="open-money" controls={false} addonAfter="원" />
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OpenWritePage() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [search] = useSearchParams()
|
||||
const editId = search.get('id') ? Number(search.get('id')) : null
|
||||
const [form] = Form.useForm()
|
||||
const [openType, setOpenType] = useState<OpenType>(typeFromPath(location.pathname))
|
||||
const [phoneInfo, setPhoneInfo] = useState<Record<string, unknown> | null>(null)
|
||||
const [usimInfo, setUsimInfo] = useState<Record<string, unknown> | null>(null)
|
||||
const [phoneOptions, setPhoneOptions] = useState<{ value: string; label: string; stock: Record<string, unknown> }[]>([])
|
||||
const [usimOptions, setUsimOptions] = useState<{ value: string; label: string; stock: Record<string, unknown> }[]>([])
|
||||
const [keepDays, setKeepDays] = useState<number | null>(null)
|
||||
const [keepBase, setKeepBase] = useState(dayjs())
|
||||
|
||||
const companies = useQuery({ queryKey: ['open-write-companies'], queryFn: () => resourceApi.list('companies', { size: 200 }) })
|
||||
const detail = useQuery({
|
||||
queryKey: ['open-detail', editId],
|
||||
queryFn: () => openApi.get(editId!),
|
||||
enabled: Boolean(editId) })
|
||||
|
||||
const outOptions = useMemo(
|
||||
() => (companies.data?.items ?? []).filter((c) => ['SHOP', 'DEALER', 'OUT'].includes(String(c.type))).map((c) => ({ value: c.id, label: String(c.name) })),
|
||||
[companies.data],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const next = typeFromPath(location.pathname)
|
||||
setOpenType(next)
|
||||
form.setFieldValue('gubun', next)
|
||||
}, [location.pathname, form])
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail.data) return
|
||||
const row = detail.data
|
||||
setOpenType((row.gubun as OpenType) || '할부')
|
||||
const phone = String(row.openphone ?? '').replace(/\D/g, '')
|
||||
form.setFieldsValue({
|
||||
...row,
|
||||
opendate: row.opendate ? dayjs(String(row.opendate)) : dayjs(),
|
||||
phone1: phone.slice(0, 3) || '010',
|
||||
phone2: phone.slice(3, 7),
|
||||
phone3: phone.slice(7, 11),
|
||||
feeDiscount: false })
|
||||
if (row.pserial) setPhoneInfo({ model: row.pmodel, color: row.pcolor, serial: row.pserial, incompanyName: row.incompanyName, outcompanyName: row.outcompanyName, inprice: row.inprice })
|
||||
if (row.userial) setUsimInfo({ model: row.umodel, serial: row.userial })
|
||||
}, [detail.data])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const openphone = `${values.phone1}-${values.phone2}-${values.phone3}`
|
||||
const payload: Record<string, unknown> = {
|
||||
...values,
|
||||
gubun: openType,
|
||||
salehow: openType,
|
||||
openphone,
|
||||
opendate: values.opendate ? dayjs(values.opendate as dayjs.Dayjs).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD'),
|
||||
pserial: openType === '유심' ? null : (phoneInfo?.serial ?? values.pserial),
|
||||
pmodel: openType === '유심' ? null : (phoneInfo?.model ?? values.pmodel),
|
||||
pcolor: openType === '유심' ? null : (phoneInfo?.color ?? values.pcolor),
|
||||
phoneStockId: openType === '유심' ? null : phoneInfo?.id,
|
||||
userial: usimInfo?.serial ?? values.userial,
|
||||
umodel: usimInfo?.model ?? values.umodel,
|
||||
usimStockId: usimInfo?.id,
|
||||
incompanyId: phoneInfo?.incompanyId ?? values.incompanyId,
|
||||
inprice: values.inprice ?? phoneInfo?.inprice,
|
||||
openStatus: values.openStatus ?? '정상',
|
||||
state: 'OPEN' }
|
||||
delete payload.phone1
|
||||
delete payload.phone2
|
||||
delete payload.phone3
|
||||
delete payload.feeDiscount
|
||||
delete payload.dates
|
||||
if (editId) return openApi.save(editId, payload)
|
||||
return openApi.create(payload)
|
||||
},
|
||||
onSuccess: () => { message.success(editId ? '개통 정보를 수정했습니다.' : '개통 등록이 완료되었습니다.'); navigate('/care/open/open') },
|
||||
onError: (e: Error) => message.error(e.message || '저장에 실패했습니다.') })
|
||||
|
||||
const lookupSerial = async (serial: string, kind: 'phone' | 'usim') => {
|
||||
if (!serial || serial.length < 3) return
|
||||
const list = await openApi.serialLookup(serial, kind === 'usim' ? '유심' : '휴대폰')
|
||||
const mapped = list.map((stock) => ({
|
||||
value: String(stock.serial),
|
||||
label: `${stock.serial} / ${stock.model ?? '-'} / ${stock.statusLabel ?? stock.state}`,
|
||||
stock }))
|
||||
if (kind === 'phone') setPhoneOptions(mapped)
|
||||
else setUsimOptions(mapped)
|
||||
}
|
||||
|
||||
const applyPhone = (stock: Record<string, unknown>) => {
|
||||
setPhoneInfo(stock)
|
||||
form.setFieldsValue({
|
||||
pserial: stock.serial,
|
||||
pmodel: stock.model,
|
||||
pcolor: stock.color,
|
||||
phoneStockId: stock.id,
|
||||
telecom: stock.telecom ?? form.getFieldValue('telecom'),
|
||||
incompanyId: stock.incompanyId,
|
||||
inprice: stock.inprice })
|
||||
}
|
||||
const applyUsim = (stock: Record<string, unknown>) => {
|
||||
setUsimInfo(stock)
|
||||
form.setFieldsValue({ userial: stock.serial, umodel: stock.model, usimStockId: stock.id })
|
||||
}
|
||||
|
||||
const changeType = (type: OpenType) => {
|
||||
setOpenType(type)
|
||||
form.setFieldValue('gubun', type)
|
||||
navigate(`${pathForType(type)}${editId ? `?id=${editId}` : ''}`, { replace: true })
|
||||
if (type === '유심') { setPhoneInfo(null); form.setFieldsValue({ pserial: undefined, pmodel: undefined, phoneStockId: undefined }) }
|
||||
}
|
||||
|
||||
const keepResult = keepDays == null ? '' : keepBase.add(keepDays, 'day').format('YYYY-MM-DD')
|
||||
|
||||
const quickIn = async (gubun: string) => {
|
||||
const serial = window.prompt(`${gubun} 일련번호를 입력하세요`)
|
||||
if (!serial) return
|
||||
try {
|
||||
await stockApi.create({ gubun, telecom: form.getFieldValue('telecom') || 'SKT', serial, model: gubun === '유심' ? 'USIM' : '미지정', cond: '정상', state: 'IN', indate: dayjs().format('YYYY-MM-DD'), processDate: dayjs().format('YYYY-MM-DD') })
|
||||
message.success('간편입고 되었습니다.')
|
||||
await lookupSerial(serial, gubun === '유심' ? 'usim' : 'phone')
|
||||
} catch (e) { message.error((e as Error).message) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="open-write-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>개통 등록</h2>
|
||||
<p>아래 양식에 개통 정보를 순서대로 입력해 주세요. ('필수' 표시는 필수항목입니다.)</p>
|
||||
</div>
|
||||
<div className="open-type-toggle">
|
||||
{(['할부', '현금', '유심'] as OpenType[]).map((type) => (
|
||||
<label key={type} className={openType === type ? 'active' : ''}>
|
||||
<input type="checkbox" checked={openType === type} onChange={() => changeType(type)} />
|
||||
{type}개통
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '110px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
initialValues={{
|
||||
gubun: openType,
|
||||
telecom: 'SKT',
|
||||
openhow: '신규',
|
||||
openStatus: '정상',
|
||||
supportType: '선택약정',
|
||||
opendate: dayjs(),
|
||||
openHour: dayjs().hour(),
|
||||
openMinute: 0,
|
||||
phone1: '010',
|
||||
salemon: openType === '할부' ? 24 : 0,
|
||||
agreemon: 24,
|
||||
salehow: openType,
|
||||
joinhow: '일반',
|
||||
usimhow: '신규유심' }}
|
||||
onFinish={(values) => save.mutate(values)}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} xl={18}>
|
||||
<Card className="open-section" size="small" title={null}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<div className="open-block-head">
|
||||
<strong>단말기</strong>
|
||||
{openType !== '유심' && <Button size="small" onClick={() => quickIn('휴대폰')}>간편입고</Button>}
|
||||
</div>
|
||||
{openType === '유심' ? (
|
||||
<div className="open-na">관계없음</div>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item label="일련번호" required>
|
||||
<AutoComplete
|
||||
options={phoneOptions}
|
||||
onSearch={(v) => lookupSerial(v, 'phone')}
|
||||
onSelect={(_, opt) => applyPhone((opt as { stock: Record<string, unknown> }).stock)}
|
||||
placeholder="단말기 일련번호를 입력해주세요"
|
||||
>
|
||||
<Input onBlur={(e) => { const v = e.target.value; if (v.length >= 3) lookupSerial(v, 'phone').then(() => { const hit = phoneOptions.find((o) => o.value === v); if (hit) applyPhone(hit.stock) }) }} />
|
||||
</AutoComplete>
|
||||
</Form.Item>
|
||||
<p className="open-hint">(3자리 이상 입력 시 검색됩니다)</p>
|
||||
<div className="open-info-lines">
|
||||
<div><span>단말기정보</span><b>{phoneInfo ? `${phoneInfo.model ?? '-'} / ${phoneInfo.color ?? '-'}` : '-'}</b></div>
|
||||
<div><span>입고정보</span><b>{phoneInfo ? `${phoneInfo.incompanyName ?? '-'} / ${phoneInfo.inprice != null ? Number(phoneInfo.inprice).toLocaleString() + '원' : '-'}` : '-'}</b></div>
|
||||
<div><span>출고정보</span><b>{phoneInfo ? `${phoneInfo.outcompanyName ?? '-'} / ${phoneInfo.statusLabel ?? phoneInfo.state ?? '-'}` : '-'}</b></div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<div className="open-block-head">
|
||||
<strong>유심</strong>
|
||||
<Button size="small" onClick={() => quickIn('유심')}>간편입고</Button>
|
||||
</div>
|
||||
<Form.Item label="일련번호">
|
||||
<AutoComplete
|
||||
options={usimOptions}
|
||||
onSearch={(v) => lookupSerial(v, 'usim')}
|
||||
onSelect={(_, opt) => applyUsim((opt as { stock: Record<string, unknown> }).stock)}
|
||||
placeholder="유심 일련번호를 입력해주세요"
|
||||
>
|
||||
<Input />
|
||||
</AutoComplete>
|
||||
</Form.Item>
|
||||
<p className="open-hint">(3자리 이상 입력 시 검색됩니다)</p>
|
||||
<div className="open-info-lines">
|
||||
<div><span>유심정보</span><b>{usimInfo ? String(usimInfo.model ?? usimInfo.serial ?? '-') : '-'}</b></div>
|
||||
<div><span>입고정보</span><b>{usimInfo ? `${usimInfo.incompanyName ?? '-'} / ${usimInfo.inprice != null ? Number(usimInfo.inprice).toLocaleString() + '원' : '-'}` : '-'}</b></div>
|
||||
<div><span>출고정보</span><b>{usimInfo ? `${usimInfo.outcompanyName ?? '-'} / ${usimInfo.statusLabel ?? usimInfo.state ?? '-'}` : '-'}</b></div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card className="open-section" size="small" title="정책정보">
|
||||
{openType === '유심' && (
|
||||
<Form.Item name="supportType" label="지원유형">
|
||||
<Radio.Group options={[{ value: '일반', label: '일반' }, { value: '선택약정', label: '선택약정' }]} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} md={12}>
|
||||
{openType !== '유심' && (
|
||||
<>
|
||||
<MoneyField name="inprice" label="입고가" />
|
||||
<Form.Item label="공통지원금">
|
||||
<Space>
|
||||
<Form.Item name="commonSupport" noStyle><InputNumber className="open-money" controls={false} addonAfter="원" /></Form.Item>
|
||||
<Form.Item name="feeDiscount" valuePropName="checked" noStyle><Checkbox>요금할인</Checkbox></Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
{openType === '할부' && <MoneyField name="extraSupport" label="추가지원금" tip="추가 지원금" />}
|
||||
<MoneyField name={openType === '할부' ? 'switchSupport' : 'moveprice'} label="전환지원금" />
|
||||
<Form.Item label="포인트/카드수납">
|
||||
<Space>
|
||||
<Form.Item name="pointPay" noStyle><InputNumber className="open-money" controls={false} addonAfter="원" /></Form.Item>
|
||||
<Button size="small">+ 추가</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<MoneyField name="preprice" label="선납금" tip="선납금" />
|
||||
{openType === '할부' && <MoneyField name="joinprice" label="할부원금" />}
|
||||
{openType === '현금' && <MoneyField name="extraSupport" label="추가지원금" />}
|
||||
{openType === '현금' && <MoneyField name="joinprice" label="할부원금" />}
|
||||
<MoneyField name="netprice" label="NET가" tip="최종 NET가" />
|
||||
</>
|
||||
)}
|
||||
{openType === '유심' && (
|
||||
<>
|
||||
<MoneyField name="basicprice1" label="기본정책" />
|
||||
<MoneyField name="basicprice3" label="추가정책" />
|
||||
<MoneyField name="etcprice1" label="기타정책1" />
|
||||
<MoneyField name="netprice" label="NET가" tip="최종 NET가" />
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="basicprice4" label="정책번호"><InputNumber className="open-money" controls={false} addonAfter="호" /></Form.Item>
|
||||
{openType !== '유심' && (
|
||||
<>
|
||||
<MoneyField name="basicprice1" label="기본정책" />
|
||||
<MoneyField name="basicprice2" label="구두정책" />
|
||||
<MoneyField name="basicprice3" label="추가정책" />
|
||||
<MoneyField name="gradePrice" label="그레이드" />
|
||||
<MoneyField name="etcprice1" label="기타정책1" />
|
||||
<MoneyField name="etcprice2" label="기타정책2" />
|
||||
</>
|
||||
)}
|
||||
{openType === '유심' && (
|
||||
<>
|
||||
<MoneyField name="basicprice2" label="구두정책" />
|
||||
<MoneyField name="gradePrice" label="그레이드" />
|
||||
<MoneyField name="etcprice2" label="기타정책2" />
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card className="open-section" size="small" title="가입정보">
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="outcompanyId" label="출고처" rules={[{ required: true, message: '출고처를 선택하세요' }]}>
|
||||
<Select showSearch optionFilterProp="label" options={outOptions} placeholder="출고처 선택" />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="고객명" rules={[{ required: true, message: '고객명을 입력하세요' }]}>
|
||||
<Input placeholder="고객명" />
|
||||
</Form.Item>
|
||||
<Form.Item label="개통일자" required>
|
||||
<Space wrap>
|
||||
<Form.Item name="opendate" noStyle rules={[{ required: true }]}><DatePicker /></Form.Item>
|
||||
<Form.Item name="openHour" noStyle><Select className="w-100" options={hours} /></Form.Item>
|
||||
<Form.Item name="openMinute" noStyle><Select className="w-100" options={minutes} /></Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item label="개통번호" required>
|
||||
<Space.Compact>
|
||||
<Form.Item name="phone1" noStyle rules={[{ required: true }]}><Select className="w-100" options={options(['010', '011', '016', '017', '018', '019'])} /></Form.Item>
|
||||
<Form.Item name="phone2" noStyle rules={[{ required: true }]}><Input className="w-100" maxLength={4} /></Form.Item>
|
||||
<Form.Item name="phone3" noStyle rules={[{ required: true }]}><Input className="w-100" maxLength={4} /></Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item name="telecom" label="통신사" rules={[{ required: true }]}>
|
||||
<Select options={options(['SKT', 'KT', 'LGU+'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="openhow" label="종류" rules={[{ required: true }]}>
|
||||
<Select options={options(['신규', '번호이동', '보상', '기변', '메이징', '가개통', '선불개통'])} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="plan" label="요금제" rules={[{ required: true, message: '요금제를 입력하세요' }]}>
|
||||
<Input placeholder="요금제명" />
|
||||
</Form.Item>
|
||||
<Form.Item name="planprice" label="요금제금액"><InputNumber className="open-money" controls={false} addonAfter="원" /></Form.Item>
|
||||
<Form.Item name="salemon" label="할부개월"><InputNumber className="open-money" min={0} addonAfter="개월" /></Form.Item>
|
||||
<Form.Item name="agreemon" label="약정개월"><InputNumber className="open-money" min={0} addonAfter="개월" /></Form.Item>
|
||||
<Form.Item name="joinhow" label="가입유형"><Select options={options(['일반', '선택약정', '공시지원'])} /></Form.Item>
|
||||
<Form.Item name="usimhow" label="유심방식"><Select options={options(['신규유심', '기존유심', 'eSIM'])} /></Form.Item>
|
||||
<Form.Item name="salesperson" label="영업사원"><Select allowClear options={options(['김영업', '이영업'])} /></Form.Item>
|
||||
<Form.Item name="employee" label="직원"><Select allowClear options={options(['demo'])} /></Form.Item>
|
||||
<Form.Item name="outCategory" label="출고분류"><Select allowClear options={options(['판매점', '도매'])} /></Form.Item>
|
||||
<Form.Item name="openStatus" label="상태"><Select options={options(['정상', '보류', '취소'])} /></Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card className="open-section" size="small" title="정산/마진">
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} md={12}>
|
||||
<MoneyField name="sellprice" label="정산금" />
|
||||
<MoneyField name="declprice1" label="차감" />
|
||||
<MoneyField name="dealermargin1" label="마진" />
|
||||
<MoneyField name="usimprice" label="유심비" />
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<MoneyField name="memberpoint" label="멤버십포인트" />
|
||||
<Form.Item name="decltype" label="할인유형"><Select allowClear options={options(['단말할인', '요금할인', '기타'])} /></Form.Item>
|
||||
<Form.Item name="dealermemo" label="딜러메모"><Input /></Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card className="open-section" size="small" title="메모">
|
||||
<Form.Item name="memo1" label="메모1"><Input.TextArea rows={2} /></Form.Item>
|
||||
<Form.Item name="memo2" label="메모2"><Input.TextArea rows={2} /></Form.Item>
|
||||
<Form.Item name="memo3" label="메모3"><Input.TextArea rows={2} /></Form.Item>
|
||||
</Card>
|
||||
|
||||
<div className="open-actions">
|
||||
<Button onClick={() => navigate('/care/open/open')}>목록</Button>
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>{editId ? '수정' : '등록'}</Button>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} xl={6}>
|
||||
<Card className="keep-calc" size="small" title="유지일 계산">
|
||||
<DatePicker value={keepBase} onChange={(v) => v && setKeepBase(v)} style={{ width: '100%', marginBottom: 8 }} />
|
||||
<div className="keep-row">
|
||||
<span>+</span>
|
||||
<InputNumber min={0} value={keepDays ?? undefined} onChange={(v) => setKeepDays(v)} style={{ width: 70 }} />
|
||||
<span>일 =</span>
|
||||
<Input value={keepResult} readOnly />
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Popover,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
EditOutlined, FileTextOutlined, InfoCircleOutlined, MailOutlined, PlusOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type OutCompanyRow = {
|
||||
id: number
|
||||
status?: string
|
||||
active?: boolean
|
||||
channel?: string
|
||||
category?: string
|
||||
name?: string
|
||||
pcode?: string
|
||||
salesperson?: string
|
||||
phone?: string
|
||||
managerName?: string
|
||||
mobile?: string
|
||||
businessName?: string
|
||||
businessNumber?: string
|
||||
bankAccount?: string
|
||||
createdAt?: string
|
||||
memo?: string
|
||||
}
|
||||
|
||||
type OutCompanyData = {
|
||||
total: number
|
||||
list: OutCompanyRow[]
|
||||
}
|
||||
|
||||
const channelOptions = ['도매', '소매', 'C채널', '특판', '기타']
|
||||
const categoryOptions = ['판매점', '출고처', '도매', '특판', '본사']
|
||||
|
||||
export default function OutCompanyPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [memoForm] = Form.useForm()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<OutCompanyRow | null>(null)
|
||||
const [memoOpen, setMemoOpen] = useState(false)
|
||||
const [memoTarget, setMemoTarget] = useState<OutCompanyRow | null>(null)
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({
|
||||
searchType: '출고처명',
|
||||
page: 0,
|
||||
size: 15,
|
||||
sort: 'name,asc' })
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['outcompanies', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<OutCompanyData>>(`${apiPrefix()}/outcompanies`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => {
|
||||
setSelected([])
|
||||
qc.invalidateQueries({ queryKey: ['outcompanies'] })
|
||||
}
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
if (editing?.id) {
|
||||
const { data } = await client.put(`${apiPrefix()}/outcompanies/${editing.id}`, values)
|
||||
return unwrap(data)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/outcompanies`, values)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '출고처를 수정했습니다.' : '출고처를 등록했습니다.')
|
||||
setOpen(false)
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const statusMut = useMutation({
|
||||
mutationFn: async (status: '사용' | '미사용') => {
|
||||
const { data } = await client.post(`${apiPrefix()}/outcompanies/status`, { ids: selected, status })
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (_d, status) => {
|
||||
message.success(`${status} 처리했습니다.`)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const memoMut = useMutation({
|
||||
mutationFn: async (memo: string) => {
|
||||
const { data } = await client.put(`${apiPrefix()}/outcompanies/${memoTarget!.id}`, { memo })
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('비고를 저장했습니다.')
|
||||
setMemoOpen(false)
|
||||
setMemoTarget(null)
|
||||
memoForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
status: values.status,
|
||||
channel: values.channel,
|
||||
category: values.category,
|
||||
searchType: values.searchType || '출고처명',
|
||||
keyword: values.keyword,
|
||||
page: 0,
|
||||
size: Number(values.size ?? prev.size ?? 15) }))
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.setFieldsValue({
|
||||
status: undefined,
|
||||
channel: undefined,
|
||||
category: undefined,
|
||||
searchType: '출고처명',
|
||||
keyword: undefined,
|
||||
size: 15 })
|
||||
setFilters({ searchType: '출고처명', page: 0, size: 15, sort: 'name,asc' })
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
editForm.setFieldsValue({
|
||||
name: undefined,
|
||||
channel: '도매',
|
||||
category: '판매점',
|
||||
pcode: undefined,
|
||||
salesperson: undefined,
|
||||
phone: undefined,
|
||||
managerName: undefined,
|
||||
mobile: undefined,
|
||||
businessName: undefined,
|
||||
businessNumber: undefined,
|
||||
bankAccount: undefined,
|
||||
memo: undefined,
|
||||
status: '사용',
|
||||
type: 'OUT' })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (row: OutCompanyRow) => {
|
||||
setEditing(row)
|
||||
editForm.setFieldsValue({
|
||||
name: row.name,
|
||||
channel: row.channel,
|
||||
category: row.category,
|
||||
pcode: row.pcode,
|
||||
salesperson: row.salesperson,
|
||||
phone: row.phone,
|
||||
managerName: row.managerName,
|
||||
mobile: row.mobile,
|
||||
businessName: row.businessName,
|
||||
businessNumber: row.businessNumber,
|
||||
bankAccount: row.bankAccount,
|
||||
memo: row.memo,
|
||||
status: row.status || '사용' })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const openMemo = (row: OutCompanyRow) => {
|
||||
setMemoTarget(row)
|
||||
memoForm.setFieldsValue({ memo: row.memo })
|
||||
setMemoOpen(true)
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
const { data } = await client.get(`${apiPrefix()}/outcompanies/export`, {
|
||||
params: filters,
|
||||
responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '출고처관리.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '상태',
|
||||
dataIndex: 'status',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => <span className={v === '미사용' ? 'farebox-cancelled' : ''}>{v}</span> },
|
||||
{ title: '채널', dataIndex: 'channel', width: 80, align: 'center' as const, sorter: true },
|
||||
{ title: '업체명', dataIndex: 'name', width: 140, ellipsis: true, sorter: true },
|
||||
{ title: 'P코드', dataIndex: 'pcode', width: 90, align: 'center' as const, sorter: true },
|
||||
{ title: '영업담당', dataIndex: 'salesperson', width: 90, align: 'center' as const },
|
||||
{ title: '전화', dataIndex: 'phone', width: 120, align: 'center' as const },
|
||||
{ title: '담당자', dataIndex: 'managerName', width: 90, align: 'center' as const },
|
||||
{ title: '휴대폰', dataIndex: 'mobile', width: 120, align: 'center' as const },
|
||||
{ title: '상호', dataIndex: 'businessName', width: 110, ellipsis: true },
|
||||
{ title: '사업자번호', dataIndex: 'businessNumber', width: 120, align: 'center' as const },
|
||||
{
|
||||
title: '계좌',
|
||||
dataIndex: 'bankAccount',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (v?: string) => (
|
||||
v
|
||||
? (
|
||||
<Popover content={<div style={{ maxWidth: 260 }}>{v}</div>} title="계좌정보">
|
||||
<Button type="text" size="small" icon={<InfoCircleOutlined style={{ color: '#1677ff' }} />} />
|
||||
</Popover>
|
||||
)
|
||||
: null
|
||||
) },
|
||||
{ title: '등록일', dataIndex: 'createdAt', width: 110, align: 'center' as const, sorter: true },
|
||||
{
|
||||
title: '비고',
|
||||
key: 'memo',
|
||||
width: 60,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: OutCompanyRow) => (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<MailOutlined style={{ color: row.memo ? '#1677ff' : '#94a3b8' }} />}
|
||||
onClick={() => openMemo(row)}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '관리',
|
||||
key: 'manage',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, row: OutCompanyRow) => (
|
||||
<Button type="primary" size="small" className="staff-manage-btn" icon={<FileTextOutlined />} onClick={() => openEdit(row)} />
|
||||
) },
|
||||
], [])
|
||||
|
||||
return (
|
||||
<div className="stock-page outcompany-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><SettingOutlined style={{ marginRight: 8 }} />출고처관리</h2>
|
||||
<p>거래하고 있는 출고처(판매점)를 등록 혹은 관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap>
|
||||
<Button
|
||||
className="farebox-gray-btn"
|
||||
disabled={!selected.length}
|
||||
loading={statusMut.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) { message.warning('항목을 선택하세요.'); return }
|
||||
statusMut.mutate('사용')
|
||||
}}
|
||||
>
|
||||
사용
|
||||
</Button>
|
||||
<Button
|
||||
className="farebox-gray-btn"
|
||||
disabled={!selected.length}
|
||||
loading={statusMut.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) { message.warning('항목을 선택하세요.'); return }
|
||||
statusMut.mutate('미사용')
|
||||
}}
|
||||
>
|
||||
미사용
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>출고처 등록</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="farebox-filter"
|
||||
initialValues={{ searchType: '출고처명', size: 15 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="farebox-filter-row">
|
||||
<Form.Item name="status">
|
||||
<Select allowClear placeholder="상태" className="w-110" options={['사용', '미사용'].map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="channel">
|
||||
<Select allowClear placeholder="채널" className="w-110" options={channelOptions.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="category">
|
||||
<Select allowClear placeholder="분류" className="w-110" options={categoryOptions.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="searchType">
|
||||
<Select
|
||||
className="w-120"
|
||||
options={['출고처명', 'P코드', '담당자', '영업담당', '상호', '사업자번호'].map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-150" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button onClick={onReset}>초기화</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="size" className="farebox-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => setFilters((prev) => ({ ...prev, size, page: 0 }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="farebox-table-toolbar">
|
||||
<Typography.Text>목록 {query.data?.total ?? 0}</Typography.Text>
|
||||
<Button type="link" icon={<FileTextOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
rowKey="id"
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
bordered
|
||||
rowSelection={{
|
||||
selectedRowKeys: selected,
|
||||
onChange: (keys) => setSelected(keys.map(Number)) }}
|
||||
pagination={{
|
||||
current: Number(filters.page ?? 0) + 1,
|
||||
pageSize: Number(filters.size ?? 15),
|
||||
total: query.data?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
onChange={(_p, _f, sorter) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter
|
||||
if (!s?.field) return
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort: `${String(s.field)},${s.order === 'ascend' ? 'asc' : 'desc'}` }))
|
||||
}}
|
||||
scroll={{ x: 1500 }}
|
||||
locale={{ emptyText: '출고처가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '출고처 수정' : '출고처 등록'}
|
||||
open={open}
|
||||
onCancel={() => { setOpen(false); setEditing(null) }}
|
||||
footer={null}
|
||||
width={560}
|
||||
destroyOnHidden
|
||||
centered
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
labelCol={{ flex: '100px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
labelAlign="left"
|
||||
colon={false}
|
||||
onFinish={(values) => saveMut.mutate(values)}
|
||||
>
|
||||
<Form.Item name="name" label="출고처명" rules={[{ required: true, message: '출고처명을 입력하세요.' }]}>
|
||||
<Input placeholder="출고처명을 입력하세요" />
|
||||
</Form.Item>
|
||||
<Form.Item name="channel" label="채널">
|
||||
<Select allowClear placeholder="-채널-" options={channelOptions.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="분류">
|
||||
<Select allowClear placeholder="-분류-" options={categoryOptions.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="pcode" label="P코드">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="salesperson" label="영업담당">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="전화">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="managerName" label="담당자">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="mobile" label="휴대폰">
|
||||
<Input placeholder="010-0000-0000" />
|
||||
</Form.Item>
|
||||
<Form.Item name="businessName" label="상호">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="businessNumber" label="사업자번호">
|
||||
<Input placeholder="000-00-00000" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccount" label="계좌">
|
||||
<Input placeholder="은행 계좌번호 예금주" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="상태">
|
||||
<Select options={['사용', '미사용'].map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="비고">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<EditOutlined />}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="비고"
|
||||
open={memoOpen}
|
||||
onCancel={() => { setMemoOpen(false); setMemoTarget(null) }}
|
||||
footer={null}
|
||||
width={420}
|
||||
destroyOnHidden
|
||||
centered
|
||||
>
|
||||
<Form form={memoForm} layout="vertical" onFinish={(values) => memoMut.mutate(String(values.memo ?? ''))}>
|
||||
<Form.Item name="memo" label={memoTarget?.name}>
|
||||
<Input.TextArea rows={4} placeholder="비고를 입력하세요" />
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button type="primary" htmlType="submit" loading={memoMut.isPending}>저장</Button>
|
||||
<Button onClick={() => { setMemoOpen(false); setMemoTarget(null) }}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Tooltip,
|
||||
message } from 'antd'
|
||||
import { ClockCircleOutlined, LeftOutlined, RightOutlined, SettingOutlined } from '@ant-design/icons'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import SubjectSettingsModal from '../../components/SubjectSettingsModal'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import type { Row } from './homesShared'
|
||||
|
||||
type PendingResult = {
|
||||
total: number
|
||||
list: Row[]
|
||||
types?: string[]
|
||||
statuses?: string[]
|
||||
receiveEmployees?: string[]
|
||||
processEmployees?: string[]
|
||||
}
|
||||
|
||||
const WEEK_LABELS = [
|
||||
{ key: 'sun', label: 'SUN', cls: 'sun' },
|
||||
{ key: 'mon', label: 'MON', cls: '' },
|
||||
{ key: 'tue', label: 'TUE', cls: '' },
|
||||
{ key: 'wed', label: 'WED', cls: '' },
|
||||
{ key: 'thu', label: 'THU', cls: '' },
|
||||
{ key: 'fri', label: 'FRI', cls: '' },
|
||||
{ key: 'sat', label: 'SAT', cls: 'sat' },
|
||||
]
|
||||
|
||||
const VIEW_OPTIONS = [
|
||||
{ value: 'simple', label: '간단하게 보기' },
|
||||
{ value: 'detail', label: '자세히 보기' },
|
||||
]
|
||||
|
||||
const DEFAULT_TYPES = ['일반', '약속1', '약속2']
|
||||
const DEFAULT_STATUSES = ['예정', '완료', '보류', '취소']
|
||||
|
||||
function splitPhone(phone?: string) {
|
||||
const digits = String(phone || '').replace(/\D/g, '')
|
||||
if (digits.length >= 10) {
|
||||
return {
|
||||
phone1: digits.slice(0, 3),
|
||||
phone2: digits.slice(3, digits.length - 4),
|
||||
phone3: digits.slice(-4) }
|
||||
}
|
||||
return { phone1: '010', phone2: '', phone3: '' }
|
||||
}
|
||||
|
||||
function startOfWeekSunday(date: Dayjs) {
|
||||
return date.subtract(date.day(), 'day').startOf('day')
|
||||
}
|
||||
|
||||
function endOfWeekSaturday(date: Dayjs) {
|
||||
return date.add(6 - date.day(), 'day').endOf('day')
|
||||
}
|
||||
|
||||
function buildMonthCells(cursor: Dayjs) {
|
||||
const start = startOfWeekSunday(cursor.startOf('month'))
|
||||
const end = endOfWeekSaturday(cursor.endOf('month'))
|
||||
const cells: Dayjs[] = []
|
||||
let d = start
|
||||
while (d.isBefore(end) || d.isSame(end, 'day')) {
|
||||
cells.push(d)
|
||||
d = d.add(1, 'day')
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
function eventLabel(row: Row, view: string) {
|
||||
if (view === 'detail') {
|
||||
const parts = [row.pendingTime, row.customerName, row.contents].filter(Boolean)
|
||||
return parts.join(' ')
|
||||
}
|
||||
return String(row.customerName || row.contents || row.pendingType || '')
|
||||
}
|
||||
|
||||
export default function PendingCalendarPage() {
|
||||
const [cursor, setCursor] = useState(() => dayjs().startOf('month'))
|
||||
const [pendingType, setPendingType] = useState<string | undefined>()
|
||||
const [status, setStatus] = useState<string | undefined>()
|
||||
const [view, setView] = useState('simple')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [editing, setEditing] = useState<Row | null>(null)
|
||||
const [typeOpen, setTypeOpen] = useState(false)
|
||||
const [editForm] = Form.useForm()
|
||||
const qc = useQueryClient()
|
||||
const modalOpen = creating || Boolean(editing)
|
||||
|
||||
const rangeStart = startOfWeekSunday(cursor.startOf('month'))
|
||||
const rangeEnd = endOfWeekSaturday(cursor.endOf('month'))
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-pendings-cal', cursor.format('YYYY-MM'), pendingType, status],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<PendingResult>>(`${apiPrefix()}/homes/pendings`, {
|
||||
params: {
|
||||
page: 0,
|
||||
size: 500,
|
||||
dateFrom: rangeStart.format('YYYY-MM-DD'),
|
||||
dateTo: rangeEnd.format('YYYY-MM-DD'),
|
||||
pendingType: pendingType || undefined,
|
||||
status: status || undefined } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const members = useQuery({
|
||||
queryKey: ['homes/members-options'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ list: { id: number; name?: string }[] }>>(`${apiPrefix()}/homes/members`, {
|
||||
params: { page: 0, size: 200 } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const byDate = useMemo(() => {
|
||||
const map: Record<string, Row[]> = {}
|
||||
for (const row of query.data?.list || []) {
|
||||
const d = String(row.pendingDate || '')
|
||||
if (!d) continue
|
||||
;(map[d] ||= []).push(row)
|
||||
}
|
||||
return map
|
||||
}, [query.data])
|
||||
|
||||
const typeOptions = useMemo(() => {
|
||||
const fromApi = query.data?.types || []
|
||||
return Array.from(new Set([...DEFAULT_TYPES, ...fromApi])).map((v) => ({ value: v, label: v }))
|
||||
}, [query.data?.types])
|
||||
|
||||
const statusOptions = useMemo(() => {
|
||||
const fromApi = query.data?.statuses || []
|
||||
return Array.from(new Set([...DEFAULT_STATUSES, ...fromApi])).map((v) => ({ value: v, label: v }))
|
||||
}, [query.data?.statuses])
|
||||
|
||||
const staffOptions = useMemo(() => {
|
||||
const fromApi = [
|
||||
...(query.data?.receiveEmployees || []),
|
||||
...(query.data?.processEmployees || []),
|
||||
]
|
||||
const fromMembers = (members.data?.list || []).map((m) => String(m.name || '')).filter(Boolean)
|
||||
return Array.from(new Set([...fromApi, ...fromMembers])).map((v) => ({ value: v, label: v }))
|
||||
}, [query.data, members.data])
|
||||
|
||||
const cells = useMemo(() => buildMonthCells(cursor), [cursor])
|
||||
const todayKey = dayjs().format('YYYY-MM-DD')
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ['homes-pendings-cal'] })
|
||||
qc.invalidateQueries({ queryKey: ['homes-pendings'] })
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const phone = [values.phone1, values.phone2, values.phone3]
|
||||
.map((v) => String(v || '').replace(/\D/g, ''))
|
||||
.filter(Boolean)
|
||||
.join('-')
|
||||
const payload: Record<string, unknown> = {
|
||||
receiveEmployee: values.receiveEmployee,
|
||||
employee: values.receiveEmployee,
|
||||
pendingDate: values.pendingDate ? dayjs(values.pendingDate as Dayjs).format('YYYY-MM-DD') : undefined,
|
||||
pendingType: values.pendingType,
|
||||
contents: values.contents,
|
||||
customerName: values.customerName,
|
||||
phone: phone || undefined,
|
||||
memo: values.memo,
|
||||
status: values.status || (editing?.status ?? '접수') }
|
||||
if (editing) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/pendings/${editing.id}`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/pendings`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '수정했습니다.' : '일정을 등록했습니다.')
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const saveTypes = useMutation({
|
||||
mutationFn: async (types: string[]) => {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/pendings/types`, { types })
|
||||
return unwrap(data as ApiResponse<{ types: string[] }>)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('과목을 저장했습니다.')
|
||||
setTypeOpen(false)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const closeModal = () => {
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
}
|
||||
|
||||
const openCreate = (date: Dayjs) => {
|
||||
setEditing(null)
|
||||
const defaultStaff = staffOptions[0]?.value || localStorage.getItem('userName') || '김대표'
|
||||
editForm.setFieldsValue({
|
||||
receiveEmployee: defaultStaff,
|
||||
pendingDate: date,
|
||||
pendingType: typeOptions[0]?.value || '일반',
|
||||
contents: undefined,
|
||||
customerName: undefined,
|
||||
phone1: '010',
|
||||
phone2: '',
|
||||
phone3: '',
|
||||
memo: undefined,
|
||||
status: '접수' })
|
||||
setCreating(true)
|
||||
}
|
||||
|
||||
const openEdit = (row: Row) => {
|
||||
setCreating(false)
|
||||
setEditing(row)
|
||||
const phones = splitPhone(String(row.phone || ''))
|
||||
editForm.setFieldsValue({
|
||||
receiveEmployee: row.receiveEmployee,
|
||||
pendingDate: row.pendingDate ? dayjs(String(row.pendingDate)) : undefined,
|
||||
pendingType: row.pendingType,
|
||||
contents: row.contents,
|
||||
customerName: row.customerName,
|
||||
...phones,
|
||||
memo: row.memo,
|
||||
status: row.status || '접수' })
|
||||
}
|
||||
|
||||
const openTypeSettings = () => setTypeOpen(true)
|
||||
|
||||
return (
|
||||
<div className="stock-page pending-calendar-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<ClockCircleOutlined className="product-title-icon" />
|
||||
캘린더
|
||||
</h2>
|
||||
<p>미결 등록된 업무를 월별 캘린더로 관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="pending-calendar-card" size="small">
|
||||
<div className="pending-calendar-toolbar">
|
||||
<div className="pending-calendar-nav">
|
||||
<button type="button" aria-label="이전 달" onClick={() => setCursor((c) => c.subtract(1, 'month'))}>
|
||||
<LeftOutlined />
|
||||
</button>
|
||||
<span>{cursor.format('YYYY년 M월')}</span>
|
||||
<button type="button" aria-label="다음 달" onClick={() => setCursor((c) => c.add(1, 'month'))}>
|
||||
<RightOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="pending-calendar-filters">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="과목"
|
||||
className="w-120"
|
||||
value={pendingType}
|
||||
options={typeOptions}
|
||||
onChange={(v) => setPendingType(v)}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="상태"
|
||||
className="w-120"
|
||||
value={status}
|
||||
options={statusOptions}
|
||||
onChange={(v) => setStatus(v)}
|
||||
/>
|
||||
<Select
|
||||
className="w-140"
|
||||
value={view}
|
||||
options={VIEW_OPTIONS}
|
||||
onChange={(v) => setView(v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pending-calendar-grid">
|
||||
<div className="pending-calendar-weekhead">
|
||||
{WEEK_LABELS.map((w) => (
|
||||
<div key={w.key} className={`pending-calendar-weekday ${w.cls}`}>{w.label}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pending-calendar-body">
|
||||
{cells.map((date) => {
|
||||
const key = date.format('YYYY-MM-DD')
|
||||
const inMonth = date.month() === cursor.month()
|
||||
const isToday = key === todayKey
|
||||
const isMonthStart = date.date() === 1
|
||||
const items = byDate[key] || []
|
||||
const dateLabel = isMonthStart ? `${date.month() + 1}월 ${date.date()}일` : String(date.date())
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={[
|
||||
'pending-calendar-cell',
|
||||
'pending-calendar-cell-clickable',
|
||||
inMonth ? 'in-month' : 'out-month',
|
||||
isToday ? 'is-today' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => openCreate(date)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
openCreate(date)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={`pending-calendar-date ${date.day() === 0 ? 'sun' : date.day() === 6 ? 'sat' : ''}`}>
|
||||
{dateLabel}
|
||||
</div>
|
||||
<div className="pending-calendar-events">
|
||||
{items.slice(0, view === 'simple' ? 3 : 6).map((row) => {
|
||||
const text = eventLabel(row, view)
|
||||
return (
|
||||
<Tooltip
|
||||
key={row.id}
|
||||
title={`${row.pendingTime || ''} ${row.customerName || ''} / ${row.pendingType || ''} / ${row.status || ''}\n${row.contents || ''}`}
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`pending-calendar-event status-${row.status || '예정'}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
openEdit(row)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
openEdit(row)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{items.length > (view === 'simple' ? 3 : 6) && (
|
||||
<div className="pending-calendar-more">+{items.length - (view === 'simple' ? 3 : 6)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '약속 수정' : '일정 등록'}
|
||||
open={modalOpen}
|
||||
onCancel={closeModal}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={560}
|
||||
className="pending-modal"
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '100px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
onFinish={(values) => save.mutate(values)}
|
||||
className="balance-edit-form"
|
||||
>
|
||||
<Form.Item name="receiveEmployee" label="접수직원" rules={[{ required: true, message: '접수직원을 선택하세요' }]}>
|
||||
<Select options={staffOptions} showSearch optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="pendingDate" label="일자" rules={[{ required: true, message: '예정일을 선택해주세요.' }]}>
|
||||
<DatePicker className="full-width" placeholder="예정일을 선택해주세요." />
|
||||
</Form.Item>
|
||||
<Form.Item label="과목" required>
|
||||
<Space.Compact className="full-width">
|
||||
<Form.Item name="pendingType" noStyle rules={[{ required: true, message: '과목을 선택하세요' }]}>
|
||||
<Select options={typeOptions} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Button icon={<SettingOutlined />} onClick={openTypeSettings}>설정</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item name="contents" label="내용" rules={[{ required: true, message: '내용을 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="customerName" label="고객명">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="연락처">
|
||||
<Space.Compact>
|
||||
<Form.Item name="phone1" noStyle><Input style={{ width: 70 }} maxLength={3} /></Form.Item>
|
||||
<Form.Item name="phone2" noStyle><Input style={{ width: 80 }} maxLength={4} /></Form.Item>
|
||||
<Form.Item name="phone3" noStyle><Input style={{ width: 80 }} maxLength={4} /></Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="비고" extra="최대 1,000자">
|
||||
<Input.TextArea rows={4} maxLength={1000} showCount />
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="pending-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={closeModal}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<SubjectSettingsModal
|
||||
open={typeOpen}
|
||||
items={typeOptions.map((o) => o.value)}
|
||||
saving={saveTypes.isPending}
|
||||
submitClassName="pending-submit-btn"
|
||||
onCancel={() => setTypeOpen(false)}
|
||||
onSave={(types) => saveTypes.mutate(types)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ClockCircleOutlined } from '@ant-design/icons'
|
||||
import { HomesLogListPage } from './HomesLogListPage'
|
||||
|
||||
const ACTIONS = [
|
||||
'약속-등록',
|
||||
'약속-수정',
|
||||
'약속-삭제',
|
||||
'약속-해결',
|
||||
]
|
||||
|
||||
export default function PendingLogPage() {
|
||||
return (
|
||||
<HomesLogListPage
|
||||
domain="PENDING"
|
||||
title="일정이력"
|
||||
description="일정관리와 관련된 이력을 확인하실 수 있습니다."
|
||||
defaultActions={ACTIONS}
|
||||
titleIcon={<ClockCircleOutlined className="product-title-icon" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Tabs,
|
||||
Tooltip,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import SubjectSettingsModal from '../../components/SubjectSettingsModal'
|
||||
import {
|
||||
ClockCircleOutlined,
|
||||
CloseOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
MailOutlined,
|
||||
PlusSquareOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { options } from './homesShared'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
type PendingRow = {
|
||||
id: number
|
||||
status?: string
|
||||
pendingDate?: string
|
||||
pendingType?: string
|
||||
contents?: string
|
||||
customerName?: string
|
||||
phone?: string
|
||||
receiveEmployee?: string
|
||||
processDate?: string
|
||||
processEmployee?: string
|
||||
memo?: string
|
||||
pendingTime?: string
|
||||
}
|
||||
|
||||
type PendingResult = {
|
||||
total: number
|
||||
list: PendingRow[]
|
||||
counts?: Record<string, number>
|
||||
types?: string[]
|
||||
statuses?: string[]
|
||||
receiveEmployees?: string[]
|
||||
processEmployees?: string[]
|
||||
}
|
||||
|
||||
const initial = { page: 0, size: 10, tab: 'ALL' } as Record<string, unknown>
|
||||
|
||||
const maskName = (name?: string) => {
|
||||
if (!name) return '-'
|
||||
if (name.length <= 1) return name
|
||||
if (name.length === 2) return `${name[0]}*`
|
||||
return `${name[0]}*${name.slice(2)}`
|
||||
}
|
||||
|
||||
const maskPhone = (phone?: string) => {
|
||||
if (!phone) return '-'
|
||||
const digits = phone.replace(/\D/g, '')
|
||||
if (digits.length < 7) return phone
|
||||
if (digits.length === 10) return `${digits.slice(0, 3)}-****-${digits.slice(6)}`
|
||||
return `${digits.slice(0, 3)}-****-${digits.slice(-4)}`
|
||||
}
|
||||
|
||||
const splitPhone = (phone?: string) => {
|
||||
const digits = (phone || '').replace(/\D/g, '')
|
||||
if (digits.length >= 10) {
|
||||
return {
|
||||
phone1: digits.slice(0, 3),
|
||||
phone2: digits.slice(3, digits.length - 4),
|
||||
phone3: digits.slice(-4) }
|
||||
}
|
||||
return { phone1: '010', phone2: '', phone3: '' }
|
||||
}
|
||||
|
||||
export default function PendingPage() {
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const qc = useQueryClient()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [editing, setEditing] = useState<PendingRow | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [typeOpen, setTypeOpen] = useState(false)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-pendings', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<PendingResult>>(`${apiPrefix()}/homes/pendings`, {
|
||||
params: {
|
||||
...filters,
|
||||
pendingType: filters.tab === 'ALL' ? undefined : filters.tab } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const members = useQuery({
|
||||
queryKey: ['homes/members-options'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ list: { id: number; name?: string }[] }>>(`${apiPrefix()}/homes/members`, {
|
||||
params: { page: 0, size: 200 } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ['homes-pendings'] })
|
||||
setSelected([])
|
||||
}
|
||||
|
||||
const staffOptions = useMemo(() => {
|
||||
const fromApi = [
|
||||
...(query.data?.receiveEmployees || []),
|
||||
...(query.data?.processEmployees || []),
|
||||
]
|
||||
const fromMembers = (members.data?.list || []).map((m) => String(m.name || '')).filter(Boolean)
|
||||
return Array.from(new Set([...fromApi, ...fromMembers])).map((v) => ({ value: v, label: v }))
|
||||
}, [query.data, members.data])
|
||||
|
||||
const typeOptions = useMemo(() => {
|
||||
const fromApi = query.data?.types || ['일반', '약속1', '약속2']
|
||||
return fromApi.map((v) => ({ value: v, label: v }))
|
||||
}, [query.data?.types])
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
const types = query.data?.types || ['일반', '약속1', '약속2']
|
||||
return [
|
||||
{ key: 'ALL', label: `전체 ${query.data?.counts?.ALL ?? query.data?.total ?? 0}` },
|
||||
...types.map((t) => ({ key: t, label: `${t} ${query.data?.counts?.[t] ?? 0}` })),
|
||||
]
|
||||
}, [query.data])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const phone = [values.phone1, values.phone2, values.phone3]
|
||||
.map((v) => String(v || '').replace(/\D/g, ''))
|
||||
.filter(Boolean)
|
||||
.join('-')
|
||||
const payload: Record<string, unknown> = {
|
||||
receiveEmployee: values.receiveEmployee,
|
||||
employee: values.receiveEmployee,
|
||||
pendingDate: values.pendingDate ? dayjs(values.pendingDate as Dayjs).format('YYYY-MM-DD') : undefined,
|
||||
pendingType: values.pendingType,
|
||||
contents: values.contents,
|
||||
customerName: values.customerName,
|
||||
phone: phone || undefined,
|
||||
memo: values.memo,
|
||||
status: values.status || (editing?.status ?? '접수') }
|
||||
if (editing) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/pendings/${editing.id}`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/pendings`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '수정했습니다.' : '일정을 등록했습니다.')
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const processMut = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/pendings/process`, { ids })
|
||||
return unwrap(data as ApiResponse<{ updated: number }>)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
message.success(`${data.updated}건을 처리했습니다.`)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const saveTypes = useMutation({
|
||||
mutationFn: async (types: string[]) => {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/pendings/types`, { types })
|
||||
return unwrap(data as ApiResponse<{ types: string[] }>)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('과목을 저장했습니다.')
|
||||
setTypeOpen(false)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setFilters({
|
||||
...initial,
|
||||
status: values.status || undefined,
|
||||
receiveEmployee: values.receiveEmployee || undefined,
|
||||
processEmployee: values.processEmployee || undefined,
|
||||
phoneTail: values.phoneTail || undefined,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 10,
|
||||
tab: filters.tab })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ size: 10 })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setCreating(true)
|
||||
const defaultStaff = staffOptions[0]?.value || '김대표'
|
||||
editForm.setFieldsValue({
|
||||
receiveEmployee: defaultStaff,
|
||||
pendingDate: undefined,
|
||||
pendingType: typeOptions[0]?.value || '일반',
|
||||
contents: undefined,
|
||||
customerName: undefined,
|
||||
phone1: '010',
|
||||
phone2: '',
|
||||
phone3: '',
|
||||
memo: undefined,
|
||||
status: '접수' })
|
||||
}
|
||||
|
||||
const openEdit = (row: PendingRow) => {
|
||||
setCreating(false)
|
||||
setEditing(row)
|
||||
const phones = splitPhone(row.phone)
|
||||
editForm.setFieldsValue({
|
||||
receiveEmployee: row.receiveEmployee,
|
||||
pendingDate: row.pendingDate ? dayjs(row.pendingDate) : undefined,
|
||||
pendingType: row.pendingType,
|
||||
contents: row.contents,
|
||||
customerName: row.customerName,
|
||||
...phones,
|
||||
memo: row.memo,
|
||||
status: row.status || '접수' })
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/homes/pendings/export`, {
|
||||
params: { ...filters, pendingType: filters.tab === 'ALL' ? undefined : filters.tab },
|
||||
responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '일정관리.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const openTypeSettings = () => setTypeOpen(true)
|
||||
|
||||
const typeItems = query.data?.types || ['일반', '약속1', '약속2']
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: (
|
||||
<Checkbox
|
||||
checked={!!query.data?.list?.length && selected.length === query.data.list.length}
|
||||
indeterminate={selected.length > 0 && selected.length < (query.data?.list?.length || 0)}
|
||||
onChange={(e) => setSelected(e.target.checked ? (query.data?.list || []).map((r) => r.id) : [])}
|
||||
/>
|
||||
),
|
||||
width: 42,
|
||||
render: (_: unknown, row: PendingRow) => (
|
||||
<Checkbox
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={(e) => setSelected((prev) => (e.target.checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)))}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '상태',
|
||||
dataIndex: 'status',
|
||||
width: 70,
|
||||
render: (v: string) => <span className={v === '접수' ? 'pending-status-open' : ''}>{v || '-'}</span> },
|
||||
{
|
||||
title: '일자',
|
||||
dataIndex: 'pendingDate',
|
||||
width: 100,
|
||||
sorter: (a: PendingRow, b: PendingRow) => String(a.pendingDate || '').localeCompare(String(b.pendingDate || '')) },
|
||||
{ title: '과목', dataIndex: 'pendingType', width: 80 },
|
||||
{ title: '내용', dataIndex: 'contents', width: 160, ellipsis: true },
|
||||
{
|
||||
title: '고객명',
|
||||
dataIndex: 'customerName',
|
||||
width: 90,
|
||||
render: (v: string) => maskName(v) },
|
||||
{
|
||||
title: '연락처',
|
||||
dataIndex: 'phone',
|
||||
width: 120,
|
||||
render: (v: string) => maskPhone(v) },
|
||||
{ title: '접수직원', dataIndex: 'receiveEmployee', width: 90 },
|
||||
{
|
||||
title: '처리일',
|
||||
dataIndex: 'processDate',
|
||||
width: 100,
|
||||
sorter: (a: PendingRow, b: PendingRow) => String(a.processDate || '').localeCompare(String(b.processDate || '')),
|
||||
render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '처리직원',
|
||||
dataIndex: 'processEmployee',
|
||||
width: 90,
|
||||
render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '비고',
|
||||
dataIndex: 'memo',
|
||||
width: 54,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => (v ? <Tooltip title={v}><MailOutlined className="memo-icon" /></Tooltip> : '-') },
|
||||
{
|
||||
title: '관리',
|
||||
width: 72,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: PendingRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="pending-manage-btn"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(row)}
|
||||
/>
|
||||
) },
|
||||
]
|
||||
|
||||
const modalOpen = creating || Boolean(editing)
|
||||
|
||||
return (
|
||||
<div className="stock-page pending-manage-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<ClockCircleOutlined className="product-title-icon" />
|
||||
일정관리
|
||||
</h2>
|
||||
<p>일정관리와 관련된 모든 업무를 관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
loading={processMut.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('처리할 약속을 선택하세요.')
|
||||
return
|
||||
}
|
||||
processMut.mutate(selected)
|
||||
}}
|
||||
>
|
||||
일정처리
|
||||
</Button>
|
||||
<Button className="pending-register-btn" type="primary" icon={<PlusSquareOutlined />} onClick={openCreate}>
|
||||
일정 등록
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form form={form} layout="inline" className="balance-filter" initialValues={{ size: 10 }} onFinish={onSearch}>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().add(1, 'month').startOf('month'), dayjs().add(1, 'month').endOf('month'))}>익월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().add(2, 'month').startOf('month'), dayjs().add(2, 'month').endOf('month'))}>익익월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={() => form.setFieldValue('dates', undefined)} />
|
||||
</div>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="status">
|
||||
<Select allowClear placeholder="상태" className="w-110" options={options(['접수', '해결', '보류', '취소'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="receiveEmployee">
|
||||
<Select allowClear showSearch placeholder="접수직원" className="w-120" options={staffOptions} optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="processEmployee">
|
||||
<Select allowClear showSearch placeholder="처리직원" className="w-120" options={staffOptions} optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="phoneTail">
|
||||
<Input placeholder="연락처(뒷4자리)" className="w-140" maxLength={4} allowClear />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((v) => ({ ...v, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="stock-table-actions">
|
||||
<Tabs
|
||||
activeKey={String(filters.tab)}
|
||||
onChange={(tab) => setFilters((v) => ({ ...v, tab, page: 0 }))}
|
||||
items={tabs}
|
||||
/>
|
||||
<Space>
|
||||
<Button type="link" icon={<SettingOutlined />} onClick={openTypeSettings}>과목설정</Button>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((v) => ({ ...v, page: page - 1 })) }}
|
||||
locale={{ emptyText: '등록된 자료가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '약속 수정' : '일정 등록'}
|
||||
open={modalOpen}
|
||||
onCancel={() => { setCreating(false); setEditing(null) }}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={560}
|
||||
className="pending-modal"
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '100px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
onFinish={(values) => save.mutate(values)}
|
||||
className="balance-edit-form"
|
||||
>
|
||||
<Form.Item name="receiveEmployee" label="접수직원" rules={[{ required: true, message: '접수직원을 선택하세요' }]}>
|
||||
<Select options={staffOptions} showSearch optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="pendingDate" label="일자" rules={[{ required: true, message: '예정일을 선택해주세요.' }]}>
|
||||
<DatePicker className="full-width" placeholder="예정일을 선택해주세요." />
|
||||
</Form.Item>
|
||||
<Form.Item label="과목" required>
|
||||
<Space.Compact className="full-width">
|
||||
<Form.Item name="pendingType" noStyle rules={[{ required: true, message: '과목을 선택하세요' }]}>
|
||||
<Select options={typeOptions} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Button icon={<SettingOutlined />} onClick={openTypeSettings}>설정</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item name="contents" label="내용" rules={[{ required: true, message: '내용을 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="customerName" label="고객명">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="연락처">
|
||||
<Space.Compact>
|
||||
<Form.Item name="phone1" noStyle><Input style={{ width: 70 }} maxLength={3} /></Form.Item>
|
||||
<Form.Item name="phone2" noStyle><Input style={{ width: 80 }} maxLength={4} /></Form.Item>
|
||||
<Form.Item name="phone3" noStyle><Input style={{ width: 80 }} maxLength={4} /></Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="비고" extra="최대 1,000자">
|
||||
<Input.TextArea rows={4} maxLength={1000} showCount />
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="pending-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<SubjectSettingsModal
|
||||
open={typeOpen}
|
||||
items={typeItems}
|
||||
saving={saveTypes.isPending}
|
||||
submitClassName="pending-submit-btn"
|
||||
onCancel={() => setTypeOpen(false)}
|
||||
onSave={(types) => saveTypes.mutate(types)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button, Card, Checkbox, Form, Input, InputNumber, Radio, Select, Space, Tabs, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { PlusOutlined, SettingOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type CompanyInfo = {
|
||||
name?: string
|
||||
managerName?: string
|
||||
mobile?: string
|
||||
businessName?: string
|
||||
businessNumber?: string
|
||||
ceo?: string
|
||||
bizType?: string
|
||||
bizItem?: string
|
||||
address?: string
|
||||
}
|
||||
|
||||
type AccessIps = {
|
||||
enabled?: boolean
|
||||
ips?: string[]
|
||||
}
|
||||
|
||||
type Masking = {
|
||||
enabled?: boolean
|
||||
name?: boolean
|
||||
phone?: boolean
|
||||
rrn?: boolean
|
||||
address?: boolean
|
||||
}
|
||||
|
||||
type StockSetting = {
|
||||
duplicateSerialCheck?: boolean
|
||||
allowNegative?: boolean
|
||||
autoRecallDays?: number
|
||||
}
|
||||
|
||||
type OpenSetting = {
|
||||
requireUsim?: boolean
|
||||
requirePhone?: boolean
|
||||
defaultOpenHow?: string
|
||||
autoSettle?: boolean
|
||||
}
|
||||
|
||||
type Preference = {
|
||||
company?: CompanyInfo
|
||||
accessIps?: AccessIps
|
||||
masking?: Masking
|
||||
outCategories?: string[]
|
||||
stock?: StockSetting
|
||||
open?: OpenSetting
|
||||
}
|
||||
|
||||
const TAB_KEYS = [
|
||||
{ key: 'company', label: '사업자정보' },
|
||||
{ key: 'accessIp', label: '접속아이피' },
|
||||
{ key: 'masking', label: '개인정보마스킹' },
|
||||
{ key: 'outCategory', label: '출고처분류' },
|
||||
{ key: 'stock', label: '재고설정' },
|
||||
{ key: 'open', label: '개통설정' },
|
||||
] as const
|
||||
|
||||
type TabKey = (typeof TAB_KEYS)[number]['key']
|
||||
|
||||
const splitPhone = (mobile?: string) => {
|
||||
const digits = String(mobile || '').replace(/\D/g, '')
|
||||
if (digits.length >= 10) {
|
||||
return {
|
||||
p1: digits.slice(0, 3),
|
||||
p2: digits.slice(3, 7),
|
||||
p3: digits.slice(7, 11) }
|
||||
}
|
||||
const parts = String(mobile || '').split('-')
|
||||
return { p1: parts[0] || '010', p2: parts[1] || '', p3: parts[2] || '' }
|
||||
}
|
||||
|
||||
const splitBizNo = (no?: string) => {
|
||||
const parts = String(no || '').split('-')
|
||||
if (parts.length >= 3) return { b1: parts[0], b2: parts[1], b3: parts[2] }
|
||||
const digits = String(no || '').replace(/\D/g, '')
|
||||
return {
|
||||
b1: digits.slice(0, 3),
|
||||
b2: digits.slice(3, 5),
|
||||
b3: digits.slice(5, 10) }
|
||||
}
|
||||
|
||||
function SectionHead({ title, desc }: { title: string; desc: string }) {
|
||||
return (
|
||||
<div className="pref-section-head">
|
||||
<h3>{title}</h3>
|
||||
<p>{desc}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PreferencePage() {
|
||||
const qc = useQueryClient()
|
||||
const [tab, setTab] = useState<TabKey>('company')
|
||||
const [companyForm] = Form.useForm()
|
||||
const [maskForm] = Form.useForm()
|
||||
const [stockForm] = Form.useForm()
|
||||
const [openForm] = Form.useForm()
|
||||
const [ipInput, setIpInput] = useState('')
|
||||
const [categoryInput, setCategoryInput] = useState('')
|
||||
const [localIps, setLocalIps] = useState<string[]>([])
|
||||
const [ipEnabled, setIpEnabled] = useState(false)
|
||||
const [categories, setCategories] = useState<string[]>([])
|
||||
const sectionRefs = useRef<Record<TabKey, HTMLDivElement | null>>({
|
||||
company: null,
|
||||
accessIp: null,
|
||||
masking: null,
|
||||
outCategory: null,
|
||||
stock: null,
|
||||
open: null })
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['preference'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Preference>>(`${apiPrefix()}/settings/preference`)
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.data) return
|
||||
const c = query.data.company || {}
|
||||
const phone = splitPhone(c.mobile)
|
||||
const biz = splitBizNo(c.businessNumber)
|
||||
companyForm.setFieldsValue({
|
||||
name: c.name,
|
||||
managerName: c.managerName,
|
||||
phone1: phone.p1 || '010',
|
||||
phone2: phone.p2,
|
||||
phone3: phone.p3,
|
||||
businessName: c.businessName,
|
||||
biz1: biz.b1,
|
||||
biz2: biz.b2,
|
||||
biz3: biz.b3,
|
||||
ceo: c.ceo,
|
||||
bizType: c.bizType,
|
||||
bizItem: c.bizItem,
|
||||
address: c.address })
|
||||
setLocalIps(query.data.accessIps?.ips || [])
|
||||
setIpEnabled(!!query.data.accessIps?.enabled)
|
||||
maskForm.setFieldsValue(query.data.masking || {})
|
||||
setCategories(query.data.outCategories || [])
|
||||
stockForm.setFieldsValue(query.data.stock || {})
|
||||
openForm.setFieldsValue(query.data.open || {})
|
||||
}, [query.data, companyForm, maskForm, stockForm, openForm])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (patch: Preference) => {
|
||||
const { data } = await client.put<ApiResponse<Preference>>(`${apiPrefix()}/settings/preference`, patch)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
qc.setQueryData(['preference'], data)
|
||||
message.success('적용했습니다.')
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const onTabChange = (key: string) => {
|
||||
const k = key as TabKey
|
||||
setTab(k)
|
||||
sectionRefs.current[k]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
const saveCompany = async () => {
|
||||
const v = await companyForm.validateFields()
|
||||
const mobile = [v.phone1, v.phone2, v.phone3].filter(Boolean).join('-')
|
||||
const businessNumber = [v.biz1, v.biz2, v.biz3].filter(Boolean).join('-')
|
||||
save.mutate({
|
||||
company: {
|
||||
name: v.name,
|
||||
managerName: v.managerName,
|
||||
mobile,
|
||||
businessName: v.businessName,
|
||||
businessNumber,
|
||||
ceo: v.ceo,
|
||||
bizType: v.bizType,
|
||||
bizItem: v.bizItem,
|
||||
address: v.address } })
|
||||
}
|
||||
|
||||
const saveAccessIps = () => {
|
||||
save.mutate({ accessIps: { enabled: ipEnabled, ips: localIps } })
|
||||
}
|
||||
|
||||
const saveMasking = async () => {
|
||||
const v = await maskForm.validateFields()
|
||||
save.mutate({ masking: v })
|
||||
}
|
||||
|
||||
const saveCategories = () => {
|
||||
save.mutate({ outCategories: categories })
|
||||
}
|
||||
|
||||
const saveStock = async () => {
|
||||
const v = await stockForm.validateFields()
|
||||
save.mutate({ stock: v })
|
||||
}
|
||||
|
||||
const saveOpen = async () => {
|
||||
const v = await openForm.validateFields()
|
||||
save.mutate({ open: v })
|
||||
}
|
||||
|
||||
const addIp = () => {
|
||||
const ip = ipInput.trim()
|
||||
if (!ip) {
|
||||
message.warning('아이피를 입력하세요.')
|
||||
return
|
||||
}
|
||||
if (localIps.includes(ip)) {
|
||||
message.warning('이미 등록된 아이피입니다.')
|
||||
return
|
||||
}
|
||||
setLocalIps((prev) => [...prev, ip])
|
||||
setIpInput('')
|
||||
}
|
||||
|
||||
const addCategory = () => {
|
||||
const name = categoryInput.trim()
|
||||
if (!name) {
|
||||
message.warning('분류명을 입력하세요.')
|
||||
return
|
||||
}
|
||||
if (categories.includes(name)) {
|
||||
message.warning('이미 등록된 분류입니다.')
|
||||
return
|
||||
}
|
||||
setCategories((prev) => [...prev, name])
|
||||
setCategoryInput('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stock-page pref-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><SettingOutlined style={{ marginRight: 8 }} />기본설정</h2>
|
||||
<p>에이전트 이용에 필요한 기본적인 설정을 하실 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="stock-table-card pref-card" size="small" loading={query.isLoading}>
|
||||
<Tabs
|
||||
className="pref-tabs"
|
||||
activeKey={tab}
|
||||
onChange={onTabChange}
|
||||
items={TAB_KEYS.map((t) => ({ key: t.key, label: t.label }))}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={(el) => { sectionRefs.current.company = el }}
|
||||
className="pref-section"
|
||||
id="pref-company"
|
||||
>
|
||||
<SectionHead title="사업자정보" desc="등록된 사업자의 정보를 변경하실 수 있습니다." />
|
||||
<Form form={companyForm} className="pref-form" requiredMark={false}>
|
||||
<table className="pref-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>업체명</th>
|
||||
<td colSpan={3}>
|
||||
<Form.Item name="name" rules={[{ required: true, message: '업체명을 입력하세요.' }]} noStyle>
|
||||
<Input className="pref-input-wide" />
|
||||
</Form.Item>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>빌링에이전트 담당자</th>
|
||||
<td>
|
||||
<Form.Item name="managerName" noStyle>
|
||||
<Input className="pref-input-md" />
|
||||
</Form.Item>
|
||||
</td>
|
||||
<th>휴대폰</th>
|
||||
<td>
|
||||
<Space size={6}>
|
||||
<Form.Item name="phone1" noStyle>
|
||||
<Select
|
||||
className="pref-phone-select"
|
||||
options={['010', '011', '016', '017', '018', '019'].map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="phone2" noStyle>
|
||||
<Input className="pref-phone-input" maxLength={4} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone3" noStyle>
|
||||
<Input className="pref-phone-input" maxLength={4} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>상호</th>
|
||||
<td colSpan={3}>
|
||||
<Form.Item name="businessName" noStyle>
|
||||
<Input className="pref-input-wide" />
|
||||
</Form.Item>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사업자등록번호</th>
|
||||
<td>
|
||||
<Space size={6}>
|
||||
<Form.Item name="biz1" noStyle>
|
||||
<Input className="pref-biz-sm" maxLength={3} />
|
||||
</Form.Item>
|
||||
<span>-</span>
|
||||
<Form.Item name="biz2" noStyle>
|
||||
<Input className="pref-biz-xs" maxLength={2} />
|
||||
</Form.Item>
|
||||
<span>-</span>
|
||||
<Form.Item name="biz3" noStyle>
|
||||
<Input className="pref-biz-md" maxLength={5} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</td>
|
||||
<th>대표</th>
|
||||
<td>
|
||||
<Form.Item name="ceo" noStyle>
|
||||
<Input className="pref-input-md" />
|
||||
</Form.Item>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>업태</th>
|
||||
<td>
|
||||
<Form.Item name="bizType" noStyle>
|
||||
<Input className="pref-input-md" />
|
||||
</Form.Item>
|
||||
</td>
|
||||
<th>종목</th>
|
||||
<td>
|
||||
<Form.Item name="bizItem" noStyle>
|
||||
<Input className="pref-input-md" />
|
||||
</Form.Item>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>주소</th>
|
||||
<td colSpan={3}>
|
||||
<Form.Item name="address" noStyle>
|
||||
<Input className="pref-input-wide" />
|
||||
</Form.Item>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="pref-actions">
|
||||
<Button type="primary" loading={save.isPending} onClick={saveCompany}>적용</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={(el) => { sectionRefs.current.accessIp = el }}
|
||||
className="pref-section"
|
||||
id="pref-accessIp"
|
||||
>
|
||||
<SectionHead
|
||||
title="접속아이피"
|
||||
desc="허용된 아이피에서만 접속하도록 제한할 수 있습니다. 미사용 시 모든 아이피에서 접속 가능합니다."
|
||||
/>
|
||||
<div className="pref-block">
|
||||
<div className="pref-row">
|
||||
<span className="pref-label">접속제한</span>
|
||||
<Radio.Group
|
||||
value={ipEnabled}
|
||||
onChange={(e) => setIpEnabled(e.target.value)}
|
||||
options={[
|
||||
{ value: true, label: '사용' },
|
||||
{ value: false, label: '미사용' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="pref-row">
|
||||
<span className="pref-label">아이피 등록</span>
|
||||
<Space>
|
||||
<Input
|
||||
className="pref-input-md"
|
||||
placeholder="예: 123.456.789.012"
|
||||
value={ipInput}
|
||||
onChange={(e) => setIpInput(e.target.value)}
|
||||
onPressEnter={addIp}
|
||||
/>
|
||||
<Button icon={<PlusOutlined />} onClick={addIp}>추가</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<CareTable
|
||||
className="pref-list-table"
|
||||
size="small"
|
||||
rowKey="ip"
|
||||
pagination={false}
|
||||
dataSource={localIps.map((ip) => ({ ip }))}
|
||||
columns={[
|
||||
{ title: 'No', width: 60, align: 'center' as const, render: (_: unknown, __: { ip: string }, i: number) => i + 1 },
|
||||
{ title: '아이피', dataIndex: 'ip' },
|
||||
{
|
||||
title: '관리',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: { ip: string }) => (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => setLocalIps((prev) => prev.filter((x) => x !== row.ip))}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
) },
|
||||
]}
|
||||
locale={{ emptyText: '등록된 아이피가 없습니다.' }}
|
||||
/>
|
||||
<div className="pref-actions">
|
||||
<Button type="primary" loading={save.isPending} onClick={saveAccessIps}>적용</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={(el) => { sectionRefs.current.masking = el }}
|
||||
className="pref-section"
|
||||
id="pref-masking"
|
||||
>
|
||||
<SectionHead
|
||||
title="개인정보마스킹"
|
||||
desc="목록/상세 화면에서 개인정보를 마스킹 처리할지 설정합니다."
|
||||
/>
|
||||
<Form form={maskForm} className="pref-block">
|
||||
<div className="pref-row">
|
||||
<span className="pref-label">마스킹 사용</span>
|
||||
<Form.Item name="enabled" noStyle>
|
||||
<Radio.Group
|
||||
options={[
|
||||
{ value: true, label: '사용' },
|
||||
{ value: false, label: '미사용' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="pref-row">
|
||||
<span className="pref-label">마스킹 항목</span>
|
||||
<Space wrap>
|
||||
<Form.Item name="name" valuePropName="checked" noStyle>
|
||||
<Checkbox>고객명</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" valuePropName="checked" noStyle>
|
||||
<Checkbox>연락처</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="rrn" valuePropName="checked" noStyle>
|
||||
<Checkbox>주민번호</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="address" valuePropName="checked" noStyle>
|
||||
<Checkbox>주소</Checkbox>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="pref-actions">
|
||||
<Button type="primary" loading={save.isPending} onClick={saveMasking}>적용</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={(el) => { sectionRefs.current.outCategory = el }}
|
||||
className="pref-section"
|
||||
id="pref-outCategory"
|
||||
>
|
||||
<SectionHead
|
||||
title="출고처분류"
|
||||
desc="출고처 분류를 등록·관리합니다. 재고/개통/정산 화면의 출고분류 선택에 사용됩니다."
|
||||
/>
|
||||
<div className="pref-block">
|
||||
<div className="pref-row">
|
||||
<span className="pref-label">분류 등록</span>
|
||||
<Space>
|
||||
<Input
|
||||
className="pref-input-md"
|
||||
placeholder="예: 판매점"
|
||||
value={categoryInput}
|
||||
onChange={(e) => setCategoryInput(e.target.value)}
|
||||
onPressEnter={addCategory}
|
||||
/>
|
||||
<Button icon={<PlusOutlined />} onClick={addCategory}>추가</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<CareTable
|
||||
className="pref-list-table"
|
||||
size="small"
|
||||
rowKey="name"
|
||||
pagination={false}
|
||||
dataSource={categories.map((name) => ({ name }))}
|
||||
columns={[
|
||||
{ title: 'No', width: 60, align: 'center' as const, render: (_: unknown, __: { name: string }, i: number) => i + 1 },
|
||||
{ title: '분류명', dataIndex: 'name' },
|
||||
{
|
||||
title: '관리',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: { name: string }) => (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => setCategories((prev) => prev.filter((x) => x !== row.name))}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
) },
|
||||
]}
|
||||
locale={{ emptyText: '등록된 분류가 없습니다.' }}
|
||||
/>
|
||||
<div className="pref-actions">
|
||||
<Button type="primary" loading={save.isPending} onClick={saveCategories}>적용</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={(el) => { sectionRefs.current.stock = el }}
|
||||
className="pref-section"
|
||||
id="pref-stock"
|
||||
>
|
||||
<SectionHead title="재고설정" desc="재고 업무에 적용되는 기본 옵션을 설정합니다." />
|
||||
<Form form={stockForm} className="pref-block">
|
||||
<div className="pref-row">
|
||||
<span className="pref-label">일련번호 중복검사</span>
|
||||
<Form.Item name="duplicateSerialCheck" noStyle>
|
||||
<Radio.Group
|
||||
options={[
|
||||
{ value: true, label: '사용' },
|
||||
{ value: false, label: '미사용' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="pref-row">
|
||||
<span className="pref-label">마이너스 재고 허용</span>
|
||||
<Form.Item name="allowNegative" noStyle>
|
||||
<Radio.Group
|
||||
options={[
|
||||
{ value: true, label: '허용' },
|
||||
{ value: false, label: '미허용' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="pref-row">
|
||||
<span className="pref-label">자동회수 기준일</span>
|
||||
<Space>
|
||||
<Form.Item name="autoRecallDays" noStyle>
|
||||
<InputNumber min={0} max={365} className="pref-number" />
|
||||
</Form.Item>
|
||||
<Typography.Text type="secondary">일 (0이면 미사용)</Typography.Text>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="pref-actions">
|
||||
<Button type="primary" loading={save.isPending} onClick={saveStock}>적용</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={(el) => { sectionRefs.current.open = el }}
|
||||
className="pref-section"
|
||||
id="pref-open"
|
||||
>
|
||||
<SectionHead title="개통설정" desc="개통 업무에 적용되는 기본 옵션을 설정합니다." />
|
||||
<Form form={openForm} className="pref-block">
|
||||
<div className="pref-row">
|
||||
<span className="pref-label">유심 필수</span>
|
||||
<Form.Item name="requireUsim" noStyle>
|
||||
<Radio.Group
|
||||
options={[
|
||||
{ value: true, label: '필수' },
|
||||
{ value: false, label: '선택' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="pref-row">
|
||||
<span className="pref-label">단말기 필수</span>
|
||||
<Form.Item name="requirePhone" noStyle>
|
||||
<Radio.Group
|
||||
options={[
|
||||
{ value: true, label: '필수' },
|
||||
{ value: false, label: '선택' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="pref-row">
|
||||
<span className="pref-label">기본 개통유형</span>
|
||||
<Form.Item name="defaultOpenHow" noStyle>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="선택"
|
||||
className="pref-input-md"
|
||||
options={['신규', '기변', 'MNP', '보상'].map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="pref-row">
|
||||
<span className="pref-label">개통 시 자동정산반영</span>
|
||||
<Form.Item name="autoSettle" noStyle>
|
||||
<Radio.Group
|
||||
options={[
|
||||
{ value: true, label: '사용' },
|
||||
{ value: false, label: '미사용' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="pref-actions">
|
||||
<Button type="primary" loading={save.isPending} onClick={saveOpen}>적용</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button, Card, Checkbox, Form, Input, InputNumber, Popconfirm, Select, Space, Tabs, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
AppstoreOutlined, FormOutlined, PlusSquareOutlined, SettingOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { money, options, type PageResult, type Row } from './homesShared'
|
||||
|
||||
const CATEGORIES = ['정수기', '공기청정기', '비데', '매트리스', '가전', '기타'] as const
|
||||
const COMPANIES = ['청호나이스', '코웨이', 'SK매직', 'LG전자', '삼성', '시몬스', '위닉스', '기타']
|
||||
const MONTH_OPTIONS = [
|
||||
{ value: 0, label: '무약정' },
|
||||
{ value: 12, label: '1년(12개월)' },
|
||||
{ value: 24, label: '2년(24개월)' },
|
||||
{ value: 36, label: '3년(36개월)' },
|
||||
{ value: 48, label: '4년(48개월)' },
|
||||
{ value: 60, label: '5년(60개월)' },
|
||||
]
|
||||
|
||||
function formatMonths(v: unknown) {
|
||||
if (v == null || v === '') return '-'
|
||||
const months = Number(v)
|
||||
if (!months) return '무약정'
|
||||
if (months % 12 === 0) return `${months / 12}년(${months}개월)`
|
||||
return `${months}개월`
|
||||
}
|
||||
|
||||
export default function ProductHomePage() {
|
||||
const qc = useQueryClient()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [form] = Form.useForm()
|
||||
const [policyForm] = Form.useForm()
|
||||
const initialCategory = CATEGORIES.includes(searchParams.get('category') as (typeof CATEGORIES)[number])
|
||||
? (searchParams.get('category') as string)
|
||||
: '정수기'
|
||||
const [category, setCategory] = useState<string>(initialCategory)
|
||||
const [page, setPage] = useState(0)
|
||||
const [size, setSize] = useState(15)
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Row | null>(null)
|
||||
const [policyTarget, setPolicyTarget] = useState<Row | null>(null)
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({ kind: 'HOME', category, page, size }),
|
||||
[category, page, size],
|
||||
)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes/products', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<PageResult>>(`${apiPrefix()}/homes/products`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const policies = useQuery({
|
||||
queryKey: ['homes/product-policies', policyTarget?.id],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ list: Row[] }>>(
|
||||
`${apiPrefix()}/homes/products/${policyTarget!.id}/policies`,
|
||||
)
|
||||
return unwrap(data)
|
||||
},
|
||||
enabled: Boolean(policyTarget) })
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const payload = { kind: 'HOME', ...values }
|
||||
if (editing) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/products/${editing.id}`, payload)
|
||||
return unwrap(data)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/products`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '수정했습니다.' : '등록했습니다.')
|
||||
setOpen(false)
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
qc.invalidateQueries({ queryKey: ['homes/products'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/products/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes/products'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const sync = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.post<ApiResponse<{ added: number }>>(`${apiPrefix()}/homes/products/sync`, { kind: 'HOME' })
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
message.success(`기본상품을 동기화했습니다. (+${res?.added ?? 0})`)
|
||||
qc.invalidateQueries({ queryKey: ['homes/products'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const addPolicy = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/products/${policyTarget!.id}/policies`, values)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('정책을 등록했습니다.')
|
||||
policyForm.resetFields()
|
||||
policyForm.setFieldsValue({ amount: 100000 })
|
||||
qc.invalidateQueries({ queryKey: ['homes/product-policies', policyTarget?.id] })
|
||||
qc.invalidateQueries({ queryKey: ['homes/products'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const removePolicy = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/products/policies/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('정책을 삭제했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes/product-policies', policyTarget?.id] })
|
||||
qc.invalidateQueries({ queryKey: ['homes/products'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ category, months: 60 })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (row: Row) => {
|
||||
setEditing(row)
|
||||
form.setFieldsValue({
|
||||
category: row.category,
|
||||
company: row.company,
|
||||
name: row.name,
|
||||
model: row.model,
|
||||
months: row.months ?? 0,
|
||||
memo: row.memo })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const counts = query.data?.counts
|
||||
const list = query.data?.list || []
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: (
|
||||
<Checkbox
|
||||
checked={list.length > 0 && selected.length === list.length}
|
||||
indeterminate={selected.length > 0 && selected.length < list.length}
|
||||
onChange={(e) => setSelected(e.target.checked ? list.map((r) => r.id) : [])}
|
||||
/>
|
||||
),
|
||||
width: 48,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<Checkbox
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={(e) => {
|
||||
setSelected((prev) => (e.target.checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)))
|
||||
}}
|
||||
/>
|
||||
) },
|
||||
{ title: '회사', dataIndex: 'company', width: 100 },
|
||||
{ title: '상품명', dataIndex: 'name', width: 160, ellipsis: true },
|
||||
{ title: '모델명', dataIndex: 'model', width: 140, ellipsis: true, render: (v: unknown) => v || '' },
|
||||
{
|
||||
title: '기본약정',
|
||||
dataIndex: 'months',
|
||||
width: 130,
|
||||
render: formatMonths },
|
||||
{ title: '비고', dataIndex: 'memo', width: 140, ellipsis: true, render: (v: unknown) => v || '' },
|
||||
{
|
||||
title: '정책설정',
|
||||
width: 130,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: Row) => {
|
||||
const count = Number(row.policyCount || 0)
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
className="product-policy-btn"
|
||||
onClick={() => {
|
||||
setPolicyTarget(row)
|
||||
policyForm.setFieldsValue({ amount: row.policyAmount || 100000 })
|
||||
}}
|
||||
>
|
||||
{count > 0 ? `정책설정(${count})` : '정책설정'}
|
||||
</Button>
|
||||
)
|
||||
} },
|
||||
{
|
||||
title: '관리',
|
||||
width: 64,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
className="product-manage-btn"
|
||||
icon={<FormOutlined />}
|
||||
onClick={() => openEdit(row)}
|
||||
/>
|
||||
) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stock-page product-internet-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<AppstoreOutlined className="product-title-icon" />
|
||||
홈렌탈상품
|
||||
</h2>
|
||||
<p>홈렌탈상품을 등록 및 관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<SyncOutlined />} loading={sync.isPending} onClick={() => sync.mutate()}>
|
||||
기본상품 동기화
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusSquareOutlined />} onClick={openCreate}>
|
||||
홈렌탈상품 등록
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-table-card product-internet-card" size="small">
|
||||
<div className="product-internet-tabs">
|
||||
<Tabs
|
||||
activeKey={category}
|
||||
onChange={(key) => {
|
||||
setCategory(key)
|
||||
setPage(0)
|
||||
setSelected([])
|
||||
}}
|
||||
items={CATEGORIES.map((c) => ({
|
||||
key: c,
|
||||
label: counts?.[c] != null ? `${c} ${counts[c]}` : c }))}
|
||||
/>
|
||||
<div className="product-internet-settings">
|
||||
<button type="button" className="product-setting-link" onClick={() => message.info('추가구성품설정(데모)')}>
|
||||
<SettingOutlined /> 추가구성품설정
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1000 }}
|
||||
pagination={{
|
||||
current: page + 1,
|
||||
pageSize: size,
|
||||
total: query.data?.total || 0,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['15', '30', '50', '100'],
|
||||
onChange: (p, s) => {
|
||||
setPage(p - 1)
|
||||
setSize(s)
|
||||
setSelected([])
|
||||
},
|
||||
showTotal: (t) => `총 ${t}건` }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '홈렌탈상품 수정' : '홈렌탈상품 등록'}
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
footer={null}
|
||||
width={520}
|
||||
destroyOnHidden
|
||||
className="product-internet-modal"
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '100px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
colon={false}
|
||||
onFinish={(values) => save.mutate(values)}
|
||||
className="product-internet-form"
|
||||
>
|
||||
<Form.Item name="category" label="구분" rules={[{ required: true, message: '구분을 선택하세요' }]}>
|
||||
<Select options={options([...CATEGORIES])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="company" label="회사" rules={[{ required: true, message: '회사를 선택하세요' }]}>
|
||||
<Select placeholder="- 선택 -" options={options(COMPANIES)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="상품명" rules={[{ required: true, message: '상품명을 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="model" label="모델명">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="months" label="기본약정">
|
||||
<Select placeholder="- 선택 -" allowClear options={MONTH_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="비고">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="product-modal-footer">
|
||||
{editing && (
|
||||
<Popconfirm title="이 상품을 삭제할까요?" onConfirm={() => { remove.mutate(editing.id); setOpen(false) }}>
|
||||
<Button danger>삭제</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<div className="product-modal-footer-right">
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)}>취소</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`정책설정 - ${policyTarget?.name ?? ''}`}
|
||||
open={Boolean(policyTarget)}
|
||||
onCancel={() => setPolicyTarget(null)}
|
||||
footer={null}
|
||||
width={640}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={policyForm}
|
||||
layout="inline"
|
||||
className="product-policy-form"
|
||||
onFinish={(v) => addPolicy.mutate(v)}
|
||||
initialValues={{ amount: 100000 }}
|
||||
>
|
||||
<Form.Item name="name" label="정책명" rules={[{ required: true, message: '정책명 필수' }]}>
|
||||
<Input placeholder="정책명" className="w-150" />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="금액" rules={[{ required: true, message: '금액 필수' }]}>
|
||||
<InputNumber className="w-130" controls={false} addonAfter="원" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={addPolicy.isPending}>추가</Button>
|
||||
</Form>
|
||||
<CareTable
|
||||
className="product-policy-table"
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={policies.isLoading}
|
||||
dataSource={policies.data?.list || []}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '정책명', dataIndex: 'name' },
|
||||
{
|
||||
title: '금액',
|
||||
dataIndex: 'amount',
|
||||
align: 'right' as const,
|
||||
width: 120,
|
||||
render: money },
|
||||
{
|
||||
title: '관리',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<Popconfirm title="삭제할까요?" onConfirm={() => removePolicy.mutate(row.id)}>
|
||||
<Button size="small" type="link" danger>삭제</Button>
|
||||
</Popconfirm>
|
||||
) },
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button, Card, Checkbox, Form, Input, InputNumber, Popconfirm, Select, Space, Tabs, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
AppstoreOutlined, FormOutlined, PlusSquareOutlined, SettingOutlined, SyncOutlined } from '@ant-design/icons'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { money, options, type PageResult, type Row } from './homesShared'
|
||||
|
||||
const CATEGORIES = ['인터넷', 'TV', '전화', '와이파이', '스마트홈', '기타'] as const
|
||||
const COMPANIES = ['KT', 'SKB', 'LGU+', '기타']
|
||||
const MONTH_OPTIONS = [
|
||||
{ value: 12, label: '1년(12개월)' },
|
||||
{ value: 24, label: '2년(24개월)' },
|
||||
{ value: 36, label: '3년(36개월)' },
|
||||
{ value: 48, label: '4년(48개월)' },
|
||||
{ value: 60, label: '5년(60개월)' },
|
||||
]
|
||||
|
||||
function formatMonths(v: unknown) {
|
||||
const months = Number(v)
|
||||
if (!months) return '-'
|
||||
if (months % 12 === 0) return `${months / 12}년(${months}개월)`
|
||||
return `${months}개월`
|
||||
}
|
||||
|
||||
export default function ProductInternetPage() {
|
||||
const qc = useQueryClient()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [form] = Form.useForm()
|
||||
const [policyForm] = Form.useForm()
|
||||
const initialCategory = CATEGORIES.includes(searchParams.get('category') as (typeof CATEGORIES)[number])
|
||||
? (searchParams.get('category') as string)
|
||||
: '인터넷'
|
||||
const [category, setCategory] = useState<string>(initialCategory)
|
||||
const [page, setPage] = useState(0)
|
||||
const [size, setSize] = useState(15)
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Row | null>(null)
|
||||
const [policyTarget, setPolicyTarget] = useState<Row | null>(null)
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({ kind: 'INTERNET', category, page, size }),
|
||||
[category, page, size],
|
||||
)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes/products', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<PageResult>>(`${apiPrefix()}/homes/products`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const policies = useQuery({
|
||||
queryKey: ['homes/product-policies', policyTarget?.id],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ list: Row[] }>>(
|
||||
`${apiPrefix()}/homes/products/${policyTarget!.id}/policies`,
|
||||
)
|
||||
return unwrap(data)
|
||||
},
|
||||
enabled: Boolean(policyTarget) })
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const payload = { kind: 'INTERNET', ...values }
|
||||
if (editing) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/products/${editing.id}`, payload)
|
||||
return unwrap(data)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/products`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '수정했습니다.' : '등록했습니다.')
|
||||
setOpen(false)
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
qc.invalidateQueries({ queryKey: ['homes/products'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/products/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes/products'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const sync = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.post<ApiResponse<{ added: number }>>(`${apiPrefix()}/homes/products/sync`, { kind: 'INTERNET' })
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
message.success(`기본상품을 동기화했습니다. (+${res?.added ?? 0})`)
|
||||
qc.invalidateQueries({ queryKey: ['homes/products'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const addPolicy = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/products/${policyTarget!.id}/policies`, values)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('정책을 등록했습니다.')
|
||||
policyForm.resetFields()
|
||||
policyForm.setFieldsValue({ amount: 50000 })
|
||||
qc.invalidateQueries({ queryKey: ['homes/product-policies', policyTarget?.id] })
|
||||
qc.invalidateQueries({ queryKey: ['homes/products'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const removePolicy = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/products/policies/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('정책을 삭제했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes/product-policies', policyTarget?.id] })
|
||||
qc.invalidateQueries({ queryKey: ['homes/products'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ category: category === 'ALL' ? '인터넷' : category, months: 36 })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (row: Row) => {
|
||||
setEditing(row)
|
||||
form.setFieldsValue({
|
||||
category: row.category,
|
||||
company: row.company,
|
||||
name: row.name,
|
||||
months: row.months,
|
||||
memo: row.memo })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const counts = query.data?.counts
|
||||
const list = query.data?.list || []
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: (
|
||||
<Checkbox
|
||||
checked={list.length > 0 && selected.length === list.length}
|
||||
indeterminate={selected.length > 0 && selected.length < list.length}
|
||||
onChange={(e) => setSelected(e.target.checked ? list.map((r) => r.id) : [])}
|
||||
/>
|
||||
),
|
||||
width: 48,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<Checkbox
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={(e) => {
|
||||
setSelected((prev) => (e.target.checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)))
|
||||
}}
|
||||
/>
|
||||
) },
|
||||
{ title: '회사', dataIndex: 'company', width: 90 },
|
||||
{ title: '상품명', dataIndex: 'name', ellipsis: true },
|
||||
{
|
||||
title: '기본약정',
|
||||
dataIndex: 'months',
|
||||
width: 130,
|
||||
render: formatMonths },
|
||||
{ title: '비고', dataIndex: 'memo', width: 160, ellipsis: true, render: (v: unknown) => v || '' },
|
||||
{
|
||||
title: '정책설정',
|
||||
width: 130,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: Row) => {
|
||||
const count = Number(row.policyCount || 0)
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
className="product-policy-btn"
|
||||
onClick={() => {
|
||||
setPolicyTarget(row)
|
||||
policyForm.setFieldsValue({ amount: row.policyAmount || 50000 })
|
||||
}}
|
||||
>
|
||||
{count > 0 ? `정책설정(${count})` : '정책설정'}
|
||||
</Button>
|
||||
)
|
||||
} },
|
||||
{
|
||||
title: '관리',
|
||||
width: 64,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
className="product-manage-btn"
|
||||
icon={<FormOutlined />}
|
||||
onClick={() => openEdit(row)}
|
||||
/>
|
||||
) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stock-page product-internet-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<AppstoreOutlined className="product-title-icon" />
|
||||
인터넷상품
|
||||
</h2>
|
||||
<p>인터넷상품을 등록 및 관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
loading={sync.isPending}
|
||||
onClick={() => sync.mutate()}
|
||||
>
|
||||
기본상품 동기화
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusSquareOutlined />} onClick={openCreate}>
|
||||
인터넷상품 등록
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-table-card product-internet-card" size="small">
|
||||
<div className="product-internet-tabs">
|
||||
<Tabs
|
||||
activeKey={category}
|
||||
onChange={(key) => {
|
||||
setCategory(key)
|
||||
setPage(0)
|
||||
setSelected([])
|
||||
}}
|
||||
items={CATEGORIES.map((c) => ({
|
||||
key: c,
|
||||
label: counts?.[c] != null ? `${c} ${counts[c]}` : c }))}
|
||||
/>
|
||||
<div className="product-internet-settings">
|
||||
<button type="button" className="product-setting-link" onClick={() => message.info('부가서비스설정(데모)')}>
|
||||
<SettingOutlined /> 부가서비스설정
|
||||
</button>
|
||||
<button type="button" className="product-setting-link" onClick={() => message.info('셋톱박스설정(데모)')}>
|
||||
<SettingOutlined /> 셋톱박스설정
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={list}
|
||||
columns={columns}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: page + 1,
|
||||
pageSize: size,
|
||||
total: query.data?.total || 0,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['15', '30', '50', '100'],
|
||||
onChange: (p, s) => {
|
||||
setPage(p - 1)
|
||||
setSize(s)
|
||||
setSelected([])
|
||||
},
|
||||
showTotal: (t) => `총 ${t}건` }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '인터넷상품 수정' : '인터넷상품 등록'}
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
footer={null}
|
||||
width={520}
|
||||
destroyOnHidden
|
||||
className="product-internet-modal"
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '100px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
colon={false}
|
||||
onFinish={(values) => save.mutate(values)}
|
||||
className="product-internet-form"
|
||||
>
|
||||
<Form.Item name="category" label="구분" rules={[{ required: true, message: '구분을 선택하세요' }]}>
|
||||
<Select options={options([...CATEGORIES])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="company" label="회사" rules={[{ required: true, message: '회사를 선택하세요' }]}>
|
||||
<Select placeholder="- 선택 -" options={options(COMPANIES)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="상품명" rules={[{ required: true, message: '상품명을 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="months" label="기본약정">
|
||||
<Select placeholder="- 선택 -" allowClear options={MONTH_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="비고">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="product-modal-footer">
|
||||
{editing && (
|
||||
<Popconfirm title="이 상품을 삭제할까요?" onConfirm={() => { remove.mutate(editing.id); setOpen(false) }}>
|
||||
<Button danger>삭제</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<div className="product-modal-footer-right">
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)}>취소</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`정책설정 - ${policyTarget?.name ?? ''}`}
|
||||
open={Boolean(policyTarget)}
|
||||
onCancel={() => setPolicyTarget(null)}
|
||||
footer={null}
|
||||
width={640}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={policyForm}
|
||||
layout="inline"
|
||||
className="product-policy-form"
|
||||
onFinish={(v) => addPolicy.mutate(v)}
|
||||
initialValues={{ amount: 50000 }}
|
||||
>
|
||||
<Form.Item name="name" label="정책명" rules={[{ required: true, message: '정책명 필수' }]}>
|
||||
<Input placeholder="정책명" className="w-150" />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="금액" rules={[{ required: true, message: '금액 필수' }]}>
|
||||
<InputNumber className="w-130" controls={false} addonAfter="원" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={addPolicy.isPending}>추가</Button>
|
||||
</Form>
|
||||
<CareTable
|
||||
className="product-policy-table"
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={policies.isLoading}
|
||||
dataSource={policies.data?.list || []}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '정책명', dataIndex: 'name' },
|
||||
{
|
||||
title: '금액',
|
||||
dataIndex: 'amount',
|
||||
align: 'right' as const,
|
||||
width: 120,
|
||||
render: money },
|
||||
{
|
||||
title: '관리',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<Popconfirm title="삭제할까요?" onConfirm={() => removePolicy.mutate(row.id)}>
|
||||
<Button size="small" type="link" danger>삭제</Button>
|
||||
</Popconfirm>
|
||||
) },
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||