diff --git a/Jenkinsfile b/Jenkinsfile index 274d8ad..df6b25e 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -19,16 +19,16 @@ pipeline { sh ''' set -euo pipefail export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:$PATH" - echo "=== BillCare Jenkins Build #${BUILD_NUMBER} ===" + echo "=== BizCare Jenkins Build #${BUILD_NUMBER} ===" echo "시각: $(date)" - SERVICE_NAME=${SERVICE_NAME} /Volumes/ADATA/git/deploy-billcare.sh + SERVICE_NAME=${SERVICE_NAME} /Volumes/ADATA/git/deploy-bizcare.sh ''' } } } post { always { - sh 'tail -40 /Volumes/ADATA/logs/deploy/billcare-deploy.log || true' + sh 'tail -40 /Volumes/ADATA/logs/deploy/bizcare-deploy.log || true' } } } diff --git a/README.md b/README.md index 4ec34f5..5ca2db8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 빌링 케어 (비즈케어 홈즈) +# BizCare (비즈케어 홈즈) [비즈케어 홈즈](https://h.bizcare.co.kr/) 홈상품(인터넷/홈렌탈) 운용관리 프로그램을 billAgent와 동일한 레이아웃·테마 구조로 재구현한 **독립 프로젝트**입니다. @@ -13,7 +13,7 @@ docker compose up --build -d - UI: http://localhost:82 - API: http://localhost:8082 -- MySQL: localhost:3308 (`billcare`) +- MySQL: localhost:3308 (`bizcare`) 데모 계정: `demo` / `demo123` @@ -21,10 +21,10 @@ docker compose up --build -d | 구분 | 내용 | |------|------| -| 브랜드 | 빌링 케어 / Bizcare Homes | +| 브랜드 | BizCare / Bizcare Homes | | 레이아웃/테마 | `AgentShell` + light/dark/blue | | 메뉴 | 상품·계약·정산·약속·예약·고객·장부·통계·설정·CS·게시판 | | API | Spring Boot JWT (`/api/agent/homes/**`) | -| 컨테이너 | `billcare-frontend` / `billcare-backend` / `billcare-mysql` | +| 컨테이너 | `bizcare-frontend` / `bizcare-backend` / `bizcare-mysql` | billAgent와 동시에 실행할 수 있도록 포트·컨테이너·DB명을 분리했습니다. diff --git a/backend/src/main/java/com/bizcare/agent/CareReceptionController.java b/backend/src/main/java/com/bizcare/agent/CareReceptionController.java index 55195de..d14cd7d 100644 --- a/backend/src/main/java/com/bizcare/agent/CareReceptionController.java +++ b/backend/src/main/java/com/bizcare/agent/CareReceptionController.java @@ -814,7 +814,7 @@ class CareReceptionController { String carrier = blankToNull(f.get("carrier")); String branch = blankToNull(f.get("branch")); String counselor = blankToNull(f.get("counselor")); - String processor = blankToNull(f.get("processor")); + String processor = blankToNull(first(f, "processor", "processors", "centerNames")); String region = blankToNull(first(f, "region", "customerSido")); String giftCategory = blankToNull(first(f, "giftCategory", "giftType")); String productCategory = blankToNull(first(f, "productCategory", "productType")); @@ -832,8 +832,29 @@ class CareReceptionController { String giftPlace = blankToNull(f.get("giftPlace")); String branchEmployee = blankToNull(first(f, "branchEmployee")); String branchName = blankToNull(f.get("branchName")); + String centerName = blankToNull(first(f, "centerName", "processorName")); + String branchGroup = blankToNull(first(f, "branchGroup", "branchGroupName", "branchGroupId")); + String paymentTransferSsid = blankToNull(first(f, "paymentTransferSsid", "paymentSsid")); + String paymentTransferNumber = blankToNull(first(f, "paymentTransferNumber", "paymentAccountNumber")); + String centerStatus = blankToNull(first(f, "centerStatus")); + String ispIdCsv = blankToNull(first(f, "ispId", "ispIds")); + String productNameCsv = blankToNull(first(f, "ispProductId", "productNames", "productNameExact")); String sorting = blankToNull(f.get("sorting")); + Set progressSet = csvSet(progress); + Set settleSet = csvSet(settle); + Set giftStatusSet = csvSet(giftStatus); + Set centerStatusSet = csvSet(centerStatus); + Set carrierSet = csvSet(carrier); + Set branchSet = csvSet(branch); + Set processorSet = csvSet(processor); + Set regionSet = csvSet(region); + Set giftCatSet = csvSet(giftCategory); + Set productCatSet = csvSet(productCategory); + Set ispIdSet = csvSet(ispIdCsv); + Set productExactSet = csvSet(productNameCsv); + Set branchGroupSet = csvSet(branchGroup); + String progressF = progress; List result = all.stream().filter(r -> { LocalDate od = r.getOrderDate() == null ? null : r.getOrderDate().toLocalDate(); @@ -846,34 +867,95 @@ class CareReceptionController { LocalDate gd = r.getGiftSended() != null ? r.getGiftSended() : r.getGiftDate(); if (gd == null || !gd.equals(giftSended)) return false; } - if (progressF != null) { + if (!progressSet.isEmpty()) { + String rp = ReceptionStatuses.normalizeProgress(r.getProgressStatus()); + boolean hit = progressSet.stream().anyMatch(fp -> { + String n = ReceptionStatuses.normalizeProgress(fp); + return n.equals(rp) || fp.equals(r.getProgressStatus()); + }); + if (!hit) return false; + } else if (progressF != null) { String rp = ReceptionStatuses.normalizeProgress(r.getProgressStatus()); String fp = ReceptionStatuses.normalizeProgress(progressF); if (!fp.equals(rp) && !progressF.equals(r.getProgressStatus())) return false; } - if (settle != null) { + if (!settleSet.isEmpty()) { + String rs = ReceptionStatuses.normalizeCenter(r.getSettleStatus()); + boolean hit = settleSet.stream().anyMatch(fs -> { + String n = ReceptionStatuses.normalizeCenter(fs); + return n.equals(rs) || fs.equals(r.getSettleStatus()); + }); + if (!hit) return false; + } else if (settle != null) { String rs = ReceptionStatuses.normalizeCenter(r.getSettleStatus()); String fs = ReceptionStatuses.normalizeCenter(settle); if (!fs.equals(rs) && !settle.equals(r.getSettleStatus())) return false; } - if (giftStatus != null) { + if (!centerStatusSet.isEmpty()) { + String rs = ReceptionStatuses.normalizeCenter(r.getSettleStatus()); + boolean hit = centerStatusSet.stream().anyMatch(fs -> { + String n = ReceptionStatuses.normalizeCenter(fs); + return n.equals(rs) || fs.equals(r.getSettleStatus()); + }); + if (!hit) return false; + } + if (!giftStatusSet.isEmpty()) { + String rg = ReceptionStatuses.normalizeGift(r.getGiftStatus()); + boolean hit = giftStatusSet.stream().anyMatch(fg -> { + String n = ReceptionStatuses.normalizeGift(fg); + return n.equals(rg) || fg.equals(r.getGiftStatus()); + }); + if (!hit) return false; + } else if (giftStatus != null) { String rg = ReceptionStatuses.normalizeGift(r.getGiftStatus()); String fg = ReceptionStatuses.normalizeGift(giftStatus); if (!fg.equals(rg) && !giftStatus.equals(r.getGiftStatus())) return false; } - if (carrier != null && !carrier.equals(r.getCarrier()) && !carrier.equals(r.getIspName())) return false; + if (!carrierSet.isEmpty()) { + if (!carrierSet.contains(nullToEmpty(r.getCarrier())) && !carrierSet.contains(nullToEmpty(r.getIspName()))) { + if (r.getIspId() == null || !carrierSet.contains(String.valueOf(r.getIspId()))) return false; + } + } else if (carrier != null && !carrier.equals(r.getCarrier()) && !carrier.equals(r.getIspName())) return false; + if (!ispIdSet.isEmpty()) { + if (r.getIspId() == null || !ispIdSet.contains(String.valueOf(r.getIspId()))) { + if (!ispIdSet.contains(nullToEmpty(r.getIspName())) && !ispIdSet.contains(nullToEmpty(r.getCarrier()))) return false; + } + } if (ispName != null) { String hay = (nullToEmpty(r.getIspName()) + " " + nullToEmpty(r.getCarrier())).toLowerCase(); if (!hay.contains(ispName.toLowerCase())) return false; } - if (branch != null && !branch.equals(r.getBranch())) return false; + if (!branchSet.isEmpty()) { + if (!branchSet.contains(nullToEmpty(r.getBranch()))) return false; + } else if (branch != null && !branch.equals(r.getBranch())) return false; if (branchName != null && !nullToEmpty(r.getBranch()).contains(branchName)) return false; + if (!branchGroupSet.isEmpty()) { + String bg = nullToEmpty(r.getBranchGroup()); + boolean hit = branchGroupSet.stream().anyMatch(g -> bg.contains(g) || bg.equals(g) || String.valueOf(r.getBranchGroup()).equals(g)); + if (!hit) return false; + } else if (branchGroup != null && !nullToEmpty(r.getBranchGroup()).contains(branchGroup) + && !String.valueOf(r.getBranchGroup()).equals(branchGroup)) return false; if (counselor != null && !counselor.equals(r.getCounselor())) return false; if (branchEmployee != null && !nullToEmpty(r.getCounselor()).contains(branchEmployee)) return false; - if (processor != null && !processor.equals(r.getProcessor())) return false; - if (region != null && !region.equals(r.getRegion())) return false; - if (giftCategory != null && !nullToEmpty(r.getGiftCategory()).contains(giftCategory)) return false; - if (productCategory != null + if (centerName != null) { + String cn = blank(r.getProcessor()) ? "본사" : r.getProcessor(); + if (!cn.contains(centerName)) return false; + } + if (!processorSet.isEmpty()) { + String cn = blank(r.getProcessor()) ? "본사" : nullToEmpty(r.getProcessor()); + if (!processorSet.contains(cn) && !processorSet.contains(nullToEmpty(r.getProcessor()))) return false; + } else if (processor != null && !processor.equals(r.getProcessor())) return false; + if (!regionSet.isEmpty()) { + if (!regionSet.contains(nullToEmpty(r.getRegion()))) return false; + } else if (region != null && !region.equals(r.getRegion())) return false; + if (!giftCatSet.isEmpty()) { + boolean hit = giftCatSet.stream().anyMatch(g -> nullToEmpty(r.getGiftCategory()).contains(g)); + if (!hit) return false; + } else if (giftCategory != null && !nullToEmpty(r.getGiftCategory()).contains(giftCategory)) return false; + if (!productCatSet.isEmpty()) { + if (!productCatSet.contains(nullToEmpty(r.getProductCategory())) + && !productCatSet.contains(nullToEmpty(r.getProductType()))) return false; + } else if (productCategory != null && !productCategory.equals(r.getProductCategory()) && !productCategory.equals(r.getProductType())) return false; if (giftPlace != null && !giftPlace.equals(r.getGiftPlace())) return false; @@ -888,7 +970,13 @@ class CareReceptionController { if (agreementName != null && !nullToEmpty(r.getAgreementName()).contains(agreementName)) return false; if (customerSsid != null && !nullToEmpty(r.getIdentifyNumber()).contains(customerSsid)) return false; if (customerEmail != null && !nullToEmpty(r.getEmail()).contains(customerEmail)) return false; - if (productName != null && !nullToEmpty(r.getProductName()).contains(productName)) return false; + if (paymentTransferSsid != null && !nullToEmpty(r.getPaymentSsid()).contains(paymentTransferSsid)) return false; + if (paymentTransferNumber != null + && !nullToEmpty(r.getPaymentAccountNumber()).contains(paymentTransferNumber) + && !nullToEmpty(r.getPaymentCardNumber()).contains(paymentTransferNumber)) return false; + if (!productExactSet.isEmpty()) { + if (!productExactSet.contains(nullToEmpty(r.getProductName()))) return false; + } else if (productName != null && !nullToEmpty(r.getProductName()).contains(productName)) return false; if (contractNumber != null && !nullToEmpty(r.getContractNumber()).contains(contractNumber)) return false; if (q != null) { String hay = (nullToEmpty(r.getCustomerName()) + " " + nullToEmpty(r.getPhone()) + " " @@ -909,6 +997,14 @@ class CareReceptionController { return result; } + private Set csvSet(String csv) { + if (csv == null || csv.isBlank()) return Set.of(); + return Arrays.stream(csv.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + private void apply(HomesReception r, Map d) { if (d.containsKey("orderDate")) r.setOrderDate(parseDateTime(str(d.get("orderDate")))); if (d.containsKey("counselor")) r.setCounselor(str(d.get("counselor"))); diff --git a/backend/src/main/java/com/bizcare/agent/DomainEntities.java b/backend/src/main/java/com/bizcare/agent/DomainEntities.java index 03438a0..573a2a9 100644 --- a/backend/src/main/java/com/bizcare/agent/DomainEntities.java +++ b/backend/src/main/java/com/bizcare/agent/DomainEntities.java @@ -732,7 +732,7 @@ class HomesBranch extends BaseEntity { /** * 영업점 수수료 시트 — billing branch_fees / branch_group_fees 축소. - * BillCare는 영업점명을 문자열로 쓰므로 branchName 키. + * BizCare는 영업점명을 문자열로 쓰므로 branchName 키. */ @Entity @Table(name = "homes_branch_fees") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor class HomesBranchFee extends BaseEntity { diff --git a/backend/src/main/java/com/bizcare/agent/ReceptionStatuses.java b/backend/src/main/java/com/bizcare/agent/ReceptionStatuses.java index 4883607..c408fff 100644 --- a/backend/src/main/java/com/bizcare/agent/ReceptionStatuses.java +++ b/backend/src/main/java/com/bizcare/agent/ReceptionStatuses.java @@ -3,7 +3,7 @@ package com.bizcare.agent; import java.util.*; /** - * billing_react AppConstant 의 접수/사은품/입력처/정산 상태·전이를 BillCare 에 이식. + * billing_react AppConstant 의 접수/사은품/입력처/정산 상태·전이를 BizCare 에 이식. */ public final class ReceptionStatuses { private ReceptionStatuses() {} @@ -43,7 +43,7 @@ public final class ReceptionStatuses { public static final Map CENTER_COST_TYPE = map( "k01", "정산", "k02", "위면해지", "k03", "직권해지", "k04", "유보중단환수", "k05", "해지", "k06", "voc차감"); - /** 구 BillCare 한글 라벨 → 코드 */ + /** 구 BizCare 한글 라벨 → 코드 */ public static final Map LEGACY_PROGRESS = Map.ofEntries( Map.entry("1.접수완료", "f04"), Map.entry("2.고객통화", "f03"), diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 7ea3c41..44319f4 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,8 +1,8 @@ spring: datasource: - url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3308/billcare?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul&characterEncoding=UTF-8} - username: ${SPRING_DATASOURCE_USERNAME:billcare} - password: ${SPRING_DATASOURCE_PASSWORD:billcare} + url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3308/bizcare?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul&characterEncoding=UTF-8} + username: ${SPRING_DATASOURCE_USERNAME:bizcare} + password: ${SPRING_DATASOURCE_PASSWORD:bizcare} jpa: hibernate: ddl-auto: update @@ -23,8 +23,8 @@ server: port: 8080 app: jwt: - secret: ${APP_JWT_SECRET:billcare-bizcare-homes-secret-key-must-be-long-enough-256bits} + secret: ${APP_JWT_SECRET:bizcare-homes-secret-key-must-be-long-enough-256bits} expiration-ms: ${APP_JWT_EXPIRATION_MS:86400000} cors: - origins: ${APP_CORS_ORIGINS:http://localhost:5174,http://localhost:82,http://127.0.0.1:82,http://localhost:1420,https://billcare.exdev.co.kr,tauri://localhost,https://tauri.localhost,http://tauri.localhost} + origins: ${APP_CORS_ORIGINS:http://localhost:5174,http://localhost:82,http://127.0.0.1:82,http://localhost:1420,https://bizcare.exdev.co.kr,tauri://localhost,https://tauri.localhost,http://tauri.localhost} upload-dir: ${APP_UPLOAD_DIR:./storage/uploads} diff --git a/deploy_server_guide.md b/deploy_server_guide.md index ac7050f..7cd654b 100644 --- a/deploy_server_guide.md +++ b/deploy_server_guide.md @@ -1,21 +1,21 @@ -# BillCare 서버 배포 가이드 (exdev.co.kr + Jenkins) +# BizCare 서버 배포 가이드 (exdev.co.kr + Jenkins) ## 개요 | 트랙 | 트리거 | 동작 | |------|--------|------| -| Track A | `git push origin main` | `post-receive` → `deploy-billcare.sh` | -| Track B | Jenkins `BillCare-Pipeline` | `deploy-billcare.sh` | +| Track A | `git push origin main` | `post-receive` → `deploy-bizcare.sh` | +| Track B | Jenkins `BizCare-Pipeline` | `deploy-bizcare.sh` | ## 경로 | 항목 | 경로 | |------|------| -| bare repo | `/Volumes/ADATA/git/BillCare.git` | -| work-tree | `/Users/aidev/apps/BillCare` | -| 배포 스크립트 | `/Volumes/ADATA/git/deploy-billcare.sh` | -| 로그 | `/Volumes/ADATA/logs/deploy/billcare-deploy.log` | -| HTTPS | https://billcare.exdev.co.kr | +| bare repo | `/Volumes/ADATA/git/BizCare.git` | +| work-tree | `/Users/aidev/apps/BizCare` | +| 배포 스크립트 | `/Volumes/ADATA/git/deploy-bizcare.sh` | +| 로그 | `/Volumes/ADATA/logs/deploy/bizcare-deploy.log` | +| HTTPS | https://bizcare.exdev.co.kr | ## 호스트 포트 @@ -36,12 +36,12 @@ git push origin main | 항목 | 값 | |------|-----| -| URL | https://billcare.exdev.co.kr | +| URL | https://bizcare.exdev.co.kr | | Caddy | `ex-chatbot-caddy` → host `:83` | -| 인증서 | LE 전용 (`billcare.exdev.co.kr`) | -| cert 경로 | `.../nginx/certs/billcare/{fullchain,privkey}.pem` | +| 인증서 | LE 전용 (`bizcare.exdev.co.kr`) | +| cert 경로 | `.../nginx/certs/bizcare/{fullchain,privkey}.pem` | | 발급 | `acme.sh` + HTTP-01 (`gc-frontend` `/var/www/certbot`) | | 갱신 reload | `docker exec ex-chatbot-caddy caddy reload --config /etc/caddy/Caddyfile` | -공유 인증서(`stock`/`chatbot`) SAN에 billcare가 없어 별도 발급했습니다. +공유 인증서(`stock`/`chatbot`) SAN에 bizcare가 없어 별도 발급했습니다. `gc-frontend`에 acme-webroot가 마운트되어 있어야 갱신이 됩니다. diff --git a/docker-compose.yml b/docker-compose.yml index 9009ede..47aa12d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,17 @@ services: mysql: image: mysql:8.0 - container_name: billcare-mysql + container_name: bizcare-mysql restart: unless-stopped environment: MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: billcare - MYSQL_USER: billcare - MYSQL_PASSWORD: billcare + MYSQL_DATABASE: bizcare + MYSQL_USER: bizcare + MYSQL_PASSWORD: bizcare ports: - "${MYSQL_PORT:-3308}:3306" volumes: - - billcare_mysql:/var/lib/mysql + - bizcare_mysql:/var/lib/mysql command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot"] @@ -24,19 +24,19 @@ services: build: context: ./backend dockerfile: Dockerfile - container_name: billcare-backend + container_name: bizcare-backend restart: unless-stopped depends_on: mysql: condition: service_healthy environment: - SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/billcare?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul&characterEncoding=UTF-8 - SPRING_DATASOURCE_USERNAME: billcare - SPRING_DATASOURCE_PASSWORD: billcare - APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:82,http://127.0.0.1:82,http://localhost:5174,http://localhost:1420,https://billcare.exdev.co.kr,tauri://localhost,https://tauri.localhost,http://tauri.localhost} + SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/bizcare?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + SPRING_DATASOURCE_USERNAME: bizcare + SPRING_DATASOURCE_PASSWORD: bizcare + APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:82,http://127.0.0.1:82,http://localhost:5174,http://localhost:1420,https://bizcare.exdev.co.kr,tauri://localhost,https://tauri.localhost,http://tauri.localhost} APP_UPLOAD_DIR: /app/storage/uploads volumes: - - billcare_uploads:/app/storage/uploads + - bizcare_uploads:/app/storage/uploads ports: - "${BACKEND_PORT:-8082}:8080" healthcheck: @@ -50,7 +50,7 @@ services: build: context: ./frontend dockerfile: Dockerfile - container_name: billcare-frontend + container_name: bizcare-frontend restart: unless-stopped depends_on: backend: @@ -59,5 +59,5 @@ services: - "${FRONTEND_PORT:-82}:80" volumes: - billcare_mysql: - billcare_uploads: + bizcare_mysql: + bizcare_uploads: diff --git a/frontend/index.html b/frontend/index.html index bfc7d85..c3b74cd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - 비즈케어 + BizCare
diff --git a/frontend/src/components/AgentShell.tsx b/frontend/src/components/AgentShell.tsx index a471edd..d59a9d9 100644 --- a/frontend/src/components/AgentShell.tsx +++ b/frontend/src/components/AgentShell.tsx @@ -4,7 +4,7 @@ import { AppstoreOutlined, CalendarOutlined, ShoppingCartOutlined, } from '@ant-design/icons' import SchedulePanel from './SchedulePanel' -import BillCareLogo from './BillCareLogo' +import BizCareLogo from './BizCareLogo' import AccountPopover from './AccountPopover' import { careMenuSections, filterMenuSections, resolveSelectedKey, sectionContainsPath, shopMenuSections } from '../menu' import { fetchMyMenus } from '../api/auth' @@ -143,7 +143,7 @@ export default function AgentShell({ shop = false }: Props) {
{!collapsed && (
- +
)}
diff --git a/frontend/src/components/BillCareLogo.tsx b/frontend/src/components/BizCareLogo.tsx similarity index 63% rename from frontend/src/components/BillCareLogo.tsx rename to frontend/src/components/BizCareLogo.tsx index 12f8ffb..ee919aa 100644 --- a/frontend/src/components/BillCareLogo.tsx +++ b/frontend/src/components/BizCareLogo.tsx @@ -3,7 +3,7 @@ import logoMark from '../assets/logo_mark_light.png' import { useTheme } from '../themeContext' import type { Theme } from '../theme' -interface BillCareLogoProps { +interface BizCareLogoProps { className?: string title?: string size?: 'header' | 'login' @@ -18,25 +18,25 @@ function isDarkTheme(theme: Theme) { * logo_image.png — 라이트 헤더 가로 로고 * logo_mark_light.png / logo_image_theme.png — 마크 (로그인·다크 헤더) */ -export default function BillCareLogo({ +export default function BizCareLogo({ className = '', - title = 'BillCare', + title = 'BizCare', size = 'header', forceTheme, -}: BillCareLogoProps) { +}: BizCareLogoProps) { const { theme } = useTheme() const active = forceTheme ?? theme const dark = isDarkTheme(active) - const root = ['billcare-logo', `billcare-logo--${size}`, `billcare-logo--${active}`, className] + const root = ['bizcare-logo', `bizcare-logo--${size}`, `bizcare-logo--${active}`, className] .filter(Boolean) .join(' ') - // 로그인: 항상 마크 + BillCare (색상은 CSS로 고정) + // 로그인: 항상 마크 + BizCare (색상은 CSS로 고정) if (size === 'login') { return (
- - {title} + + {title}
) } @@ -54,11 +54,11 @@ export default function BillCareLogo({ ) } - // 다크/블루 헤더: 마크 + BillCare + // 다크/블루 헤더: 마크 + BizCare return (
- - {title} + + {title}
) } diff --git a/frontend/src/components/MultiSelect.tsx b/frontend/src/components/MultiSelect.tsx new file mode 100644 index 0000000..c70a657 --- /dev/null +++ b/frontend/src/components/MultiSelect.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' + +export type MultiSelectOption = [string, string] + +type Props = { + options: MultiSelectOption[] + value: string[] + onChange: (v: string[]) => void + placeholder?: string + className?: string +} + +/** 클릭 시 펼쳐지는 체크박스 다중선택 (billing_react MultiSelect 이식) */ +export default function MultiSelect({ options, value, onChange, placeholder, className }: Props) { + const [open, setOpen] = useState(false) + const [panelPos, setPanelPos] = useState<{ top: number; left: number; width: number; maxHeight: number } | null>(null) + const rootRef = useRef(null) + const toggleRef = useRef(null) + const panelRef = useRef(null) + + const updatePanelPos = () => { + const el = toggleRef.current + if (!el) return + const r = el.getBoundingClientRect() + setPanelPos({ + top: r.bottom + 2, + left: r.left, + width: Math.max(r.width, 160), + maxHeight: Math.min(300, Math.max(120, window.innerHeight - r.bottom - 12)), + }) + } + + useEffect(() => { + if (!open) return + updatePanelPos() + const onScrollOrResize = () => updatePanelPos() + window.addEventListener('resize', onScrollOrResize) + window.addEventListener('scroll', onScrollOrResize, true) + return () => { + window.removeEventListener('resize', onScrollOrResize) + window.removeEventListener('scroll', onScrollOrResize, true) + } + }, [open]) + + useEffect(() => { + const h = (e: MouseEvent) => { + const t = e.target as Node + if (rootRef.current?.contains(t) || panelRef.current?.contains(t)) return + setOpen(false) + } + document.addEventListener('mousedown', h) + return () => document.removeEventListener('mousedown', h) + }, []) + + const toggle = (k: string) => onChange(value.includes(k) ? value.filter((x) => x !== k) : [...value, k]) + const selectAll = () => onChange(options.map(([k]) => k)) + const selectNone = () => onChange([]) + const text = value.length === 0 + ? (placeholder || '전체') + : options.filter(([k]) => value.includes(k)).map(([, v]) => v).join(', ') + + return ( +
+ + {open && panelPos && createPortal( +
+
+ + +
+ {options.map(([k, v]) => ( + + ))} +
, + document.body, + )} +
+ ) +} + +export function mapOptions(items: { id: number; name: string }[]): MultiSelectOption[] { + return items.map((x) => [String(x.id), x.name]) +} + +export function entriesOptions(map: Record | undefined): MultiSelectOption[] { + return map ? Object.entries(map) : [] +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 8e37b16..d643de7 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,7 +1,7 @@ import { type FormEvent, useState } from 'react' import { useNavigate } from 'react-router-dom' import { login } from '../api/auth' -import BillCareLogo from '../components/BillCareLogo' +import BizCareLogo from '../components/BizCareLogo' export default function LoginPage() { const navigate = useNavigate() @@ -34,7 +34,7 @@ export default function LoginPage() {
- +

통합 영업·정산 관리 시스템

diff --git a/frontend/src/pages/care/ReceptionListPage.tsx b/frontend/src/pages/care/ReceptionListPage.tsx index 98764c7..3495b43 100644 --- a/frontend/src/pages/care/ReceptionListPage.tsx +++ b/frontend/src/pages/care/ReceptionListPage.tsx @@ -15,6 +15,7 @@ import { import ReceptionApplyModal from '../../components/ReceptionApplyModal' import ReceptionBulkModal from '../../components/ReceptionBulkModal' import ReceptionProgressModal from '../../components/ReceptionProgressModal' +import MultiSelect, { entriesOptions, mapOptions, type MultiSelectOption } from '../../components/MultiSelect' import { DownOutlined } from '@ant-design/icons' import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client' import { options } from './homesShared' @@ -29,6 +30,7 @@ import { PROGRESS_STATUS, GIFT_STATUS, CENTER_STATUS, + GIFT_PLACE, } from './receptionStatuses' type ReceptionRow = { @@ -96,11 +98,20 @@ type ReceptionResult = { productCategories?: string[] } +type IspProduct = { id: number; name: string; type?: string } + const SIDO_LIST = [ '서울', '경기', '인천', '강원', '충북', '충남', '대전', '전북', '전남', '광주', '경북', '대구', '경남', '울산', '부산', '제주', ] +const PRODUCT_TYPE_OPTS: MultiSelectOption[] = [ + ['e01', '인터넷'], + ['e02', '전화'], + ['e03', 'TV'], + ['e04', '렌탈'], +] + const initialFilters = () => ({ page: 0, size: 50, @@ -118,19 +129,69 @@ const initialFilters = () => ({ ispName: '', branchEmployee: '', branchName: '', - giftType: undefined as string | undefined, - productType: undefined as string | undefined, - progressStatus: undefined as string | undefined, - giftStatus: undefined as string | undefined, - giftPlace: undefined as string | undefined, - customerSido: undefined as string | undefined, - carrier: undefined as string | undefined, - branch: undefined as string | undefined, - settleStatus: undefined as string | undefined, + centerName: '', + paymentTransferSsid: '', + paymentTransferNumber: '', + giftType: [] as string[], + productType: [] as string[], + progressStatus: [] as string[], + giftStatus: [] as string[], + centerStatus: [] as string[], + customerSido: [] as string[], + ispId: [] as string[], + productNames: [] as string[], + productIds: [] as string[], + branches: [] as string[], + branchIds: [] as string[], + processors: [] as string[], + centerNames: [] as string[], + branchGroupIds: [] as string[], + giftPlace: '', opended: undefined as string | undefined, giftSended: undefined as string | undefined, }) +type Filters = ReturnType + +const csv = (arr: string[]) => (arr.length ? arr.join(',') : undefined) + +/** API 쿼리 파라미터 — 배열은 comma join */ +const toSearchParams = (f: Filters, opts?: { omitPaging?: boolean }) => ({ + ...(opts?.omitPaging ? {} : { page: f.page, size: f.size }), + orderDateStart: f.orderDateStart || undefined, + orderDateEnd: f.orderDateEnd || undefined, + sorting: f.sorting, + postIt: f.postIt || undefined, + agreementName: f.agreementName || undefined, + customerSsid: f.customerSsid || undefined, + customerEmail: f.customerEmail || undefined, + customerName: f.customerName || undefined, + customerPhone: f.customerPhone || undefined, + contractNumber: f.contractNumber || undefined, + productName: f.productName || undefined, + ispName: f.ispName || undefined, + branchEmployee: f.branchEmployee || undefined, + branchName: f.branchName || undefined, + centerName: f.centerName || undefined, + paymentTransferSsid: f.paymentTransferSsid || undefined, + paymentTransferNumber: f.paymentTransferNumber || undefined, + giftType: csv(f.giftType), + productType: csv(f.productType), + progressStatus: csv(f.progressStatus), + giftStatus: csv(f.giftStatus), + centerStatus: csv(f.centerStatus), + customerSido: csv(f.customerSido), + ispId: csv(f.ispId), + productNames: csv(f.productNames), + ispProductId: csv(f.productNames.length ? f.productNames : f.productIds), + branch: csv(f.branches.length ? f.branches : f.branchIds), + processor: csv(f.processors.length ? f.processors : f.centerNames), + branchGroupId: csv(f.branchGroupIds), + giftPlace: f.giftPlace || undefined, + opended: f.opended || undefined, + giftSended: f.giftSended || undefined, +}) + const num = (n?: number | null) => (n == null ? '' : Number(n).toLocaleString()) const giftPlaceLabel = (v?: string) => placeLabel(v) @@ -164,39 +225,57 @@ export default function ReceptionListPage() { }, }) + const branchGroupsQuery = useQuery({ + queryKey: ['homes-branch-groups'], + queryFn: async () => { + const { data } = await client.get>( + `${apiPrefix()}/homes/branch-groups`, + ) + return unwrap(data) || [] + }, + }) + + const inputCentersQuery = useQuery({ + queryKey: ['homes-input-centers'], + queryFn: async () => { + const { data } = await client.get>( + `${apiPrefix()}/homes/input-centers`, + ) + return unwrap(data) || [] + }, + }) + + const branchesQuery = useQuery({ + queryKey: ['homes-branches-office'], + queryFn: async () => { + const { data } = await client.get>( + `${apiPrefix()}/homes/branches`, + ) + return unwrap(data) || [] + }, + }) + + const productsQuery = useQuery({ + queryKey: ['homes-reception-products-multi', filters.ispId], + queryFn: async () => { + const results = await Promise.all( + filters.ispId.map(async (id) => { + const { data } = await client.get>( + `${apiPrefix()}/homes/reception-isps/${id}/products`, + ) + return unwrap(data) || [] + }), + ) + return results.flat() + }, + enabled: filters.ispId.length > 0, + }) + const query = useQuery({ queryKey: ['homes-receptions', filters], queryFn: async () => { const { data } = await client.get>(`${apiPrefix()}/homes/receptions`, { - params: { - page: filters.page, - size: filters.size, - orderDateStart: filters.orderDateStart || undefined, - orderDateEnd: filters.orderDateEnd || undefined, - sorting: filters.sorting, - postIt: filters.postIt || undefined, - agreementName: filters.agreementName || undefined, - customerSsid: filters.customerSsid || undefined, - customerEmail: filters.customerEmail || undefined, - customerName: filters.customerName || undefined, - customerPhone: filters.customerPhone || undefined, - contractNumber: filters.contractNumber || undefined, - productName: filters.productName || undefined, - ispName: filters.ispName || undefined, - branchEmployee: filters.branchEmployee || undefined, - branchName: filters.branchName || undefined, - giftType: filters.giftType || undefined, - productType: filters.productType || undefined, - progressStatus: filters.progressStatus || undefined, - giftStatus: filters.giftStatus || undefined, - giftPlace: filters.giftPlace || undefined, - customerSido: filters.customerSido || undefined, - carrier: filters.carrier || undefined, - branch: filters.branch || undefined, - settleStatus: filters.settleStatus || undefined, - opended: filters.opended || undefined, - giftSended: filters.giftSended || undefined, - }, + params: toSearchParams(filters), }) return unwrap(data) }, @@ -218,8 +297,52 @@ export default function ReceptionListPage() { const giftSelectOpts = statusOptions(giftLabels as Record) const settleSelectOpts = statusOptions(settleLabels as Record) - const setD = (patch: Partial>) => setDraft((p) => ({ ...p, ...patch })) - const applyF = (patch: Partial>) => { + const giftTypeOpts: MultiSelectOption[] = ( + query.data?.giftCategories || ['상품권', '가전', '캐시백', '없음'] + ).map((x) => [x, x]) + + const progressMsOpts = entriesOptions(progressLabels as Record) + const giftMsOpts = entriesOptions(giftLabels as Record) + const centerStatusMsOpts = entriesOptions(settleLabels as Record) + const sidoMsOpts: MultiSelectOption[] = SIDO_LIST.map((s) => [s, s]) + const ispMsOpts = mapOptions(ispQuery.data || []) + const branchGroupMsOpts = mapOptions(branchGroupsQuery.data || []) + + const processorMsOpts: MultiSelectOption[] = useMemo(() => { + const names = new Set() + for (const p of query.data?.processors || []) if (p) names.add(p) + for (const c of inputCentersQuery.data || []) if (c.name) names.add(c.name) + names.add('본사') + return Array.from(names).sort().map((n) => [n, n] as MultiSelectOption) + }, [query.data?.processors, inputCentersQuery.data]) + + const branchMsOpts: MultiSelectOption[] = useMemo(() => { + const names = new Set() + for (const b of query.data?.branches || []) if (b) names.add(b) + for (const b of branchesQuery.data || []) { + const n = b.officeName || b.name + if (n) names.add(n) + } + return Array.from(names).sort().map((n) => [n, n] as MultiSelectOption) + }, [query.data?.branches, branchesQuery.data]) + + const productMsOpts: MultiSelectOption[] = useMemo(() => { + let items = productsQuery.data || [] + if (filters.productType.length) { + items = items.filter((p) => filters.productType.includes(p.type || '')) + } + const seen = new Set() + const opts: MultiSelectOption[] = [] + for (const p of items) { + if (!p.name || seen.has(p.name)) continue + seen.add(p.name) + opts.push([p.name, p.name]) + } + return opts + }, [productsQuery.data, filters.productType]) + + const setD = (patch: Partial) => setDraft((p) => ({ ...p, ...patch })) + const applyF = (patch: Partial) => { const next = { ...draft, ...patch, page: 0 } setDraft(next) setFilters(next) @@ -325,7 +448,7 @@ export default function ReceptionListPage() { const downloadExcel = async () => { try { const { data } = await client.get(`${apiPrefix()}/homes/receptions/excel`, { - params: { ...filters, page: undefined, size: undefined }, + params: toSearchParams(filters, { omitPaging: true }), responseType: 'blob', }) const url = URL.createObjectURL(data) @@ -405,11 +528,6 @@ export default function ReceptionListPage() {
-
- - -
-
+ +
-
- 검색 결과: 총 {(query.data?.total ?? 0).toLocaleString()} 건 -
-
- setD({ postIt: e.target.value })} /> setD({ agreementName: e.target.value })} /> setD({ customerSsid: e.target.value })} /> setD({ customerEmail: e.target.value })} /> + setD({ paymentTransferSsid: e.target.value })} /> + setD({ paymentTransferNumber: e.target.value })} /> + setD({ centerName: e.target.value })} /> setD({ branchName: e.target.value })} /> + setD({ branchGroupIds: v })} + /> setD({ ispName: e.target.value })} /> setD({ productName: e.target.value })} /> + 검색 결과: 총 {(query.data?.total ?? 0).toLocaleString()} 건
@@ -523,50 +649,45 @@ export default function ReceptionListPage() { - applyF({ branch: v })} - options={options(query.data?.branches || [])} - style={{ width: '100%' }} + options={branchMsOpts} + value={draft.branches} + onChange={(v) => applyF({ branches: v, branchIds: v })} /> - applyF({ productType: v })} - options={options(query.data?.productCategories || ['인터넷', '전화', 'TV', '렌탈'])} - style={{ width: '100%' }} /> -   + + applyF({ productNames: v, productIds: v })} + /> + - applyF({ progressStatus: v })} - options={progressSelectOpts} - style={{ width: '100%' }} /> @@ -614,33 +729,29 @@ export default function ReceptionListPage() { /> - applyF({ giftPlace: e.target.value })}> + + {Object.entries(GIFT_PLACE).map(([code, label]) => ( + + ))} + -