기능수정

This commit is contained in:
Macbook
2026-07-12 19:05:29 +09:00
parent 07a8114927
commit dca36c7b19
19 changed files with 714 additions and 253 deletions
Vendored
+3 -3
View File
@@ -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'
}
}
}
+4 -4
View File
@@ -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명을 분리했습니다.
@@ -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<String> progressSet = csvSet(progress);
Set<String> settleSet = csvSet(settle);
Set<String> giftStatusSet = csvSet(giftStatus);
Set<String> centerStatusSet = csvSet(centerStatus);
Set<String> carrierSet = csvSet(carrier);
Set<String> branchSet = csvSet(branch);
Set<String> processorSet = csvSet(processor);
Set<String> regionSet = csvSet(region);
Set<String> giftCatSet = csvSet(giftCategory);
Set<String> productCatSet = csvSet(productCategory);
Set<String> ispIdSet = csvSet(ispIdCsv);
Set<String> productExactSet = csvSet(productNameCsv);
Set<String> branchGroupSet = csvSet(branchGroup);
String progressF = progress;
List<HomesReception> 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<String> 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<String, Object> d) {
if (d.containsKey("orderDate")) r.setOrderDate(parseDateTime(str(d.get("orderDate"))));
if (d.containsKey("counselor")) r.setCounselor(str(d.get("counselor")));
@@ -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 {
@@ -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<String, String> CENTER_COST_TYPE = map(
"k01", "정산", "k02", "위면해지", "k03", "직권해지", "k04", "유보중단환수", "k05", "해지", "k06", "voc차감");
/** 구 BillCare 한글 라벨 → 코드 */
/** 구 BizCare 한글 라벨 → 코드 */
public static final Map<String, String> LEGACY_PROGRESS = Map.ofEntries(
Map.entry("1.접수완료", "f04"),
Map.entry("2.고객통화", "f03"),
+5 -5
View File
@@ -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}
+12 -12
View File
@@ -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가 마운트되어 있어야 갱신이 됩니다.
+14 -14
View File
@@ -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:
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>비즈케어</title>
<title>BizCare</title>
</head>
<body>
<div id="root"></div>
+2 -2
View File
@@ -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) {
<div className={`topbar-brand${collapsed ? ' is-collapsed' : ''}`} style={collapsed ? undefined : { width: panelWidth }}>
{!collapsed && (
<div className="brand-inline">
<BillCareLogo title="BillCare" size="header" />
<BizCareLogo title="BizCare" size="header" />
</div>
)}
</div>
@@ -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 (
<div className={root} title={title}>
<img src={logoMark} alt="" className="billcare-logo-mark" draggable={false} />
<span className="billcare-logo-title">{title}</span>
<img src={logoMark} alt="" className="bizcare-logo-mark" draggable={false} />
<span className="bizcare-logo-title">{title}</span>
</div>
)
}
@@ -54,11 +54,11 @@ export default function BillCareLogo({
)
}
// 다크/블루 헤더: 마크 + BillCare
// 다크/블루 헤더: 마크 + BizCare
return (
<div className={root} title={title}>
<img src={logoMark} alt="" className="billcare-logo-mark" draggable={false} />
<span className="billcare-logo-title">{title}</span>
<img src={logoMark} alt="" className="bizcare-logo-mark" draggable={false} />
<span className="bizcare-logo-title">{title}</span>
</div>
)
}
+103
View File
@@ -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<HTMLDivElement>(null)
const toggleRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(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 (
<div className={`ms${open ? ' is-open' : ''}${className ? ` ${className}` : ''}`} ref={rootRef}>
<button
ref={toggleRef}
type="button"
className="ms-toggle"
onClick={() => setOpen((o) => !o)}
title={text}
>
<span className="ms-text" style={value.length ? undefined : { color: 'var(--muted)' }}>{text}</span>
<span className="ms-caret">▾</span>
</button>
{open && panelPos && createPortal(
<div
ref={panelRef}
className="ms-panel ms-panel-portal"
style={{ top: panelPos.top, left: panelPos.left, width: panelPos.width, maxHeight: panelPos.maxHeight }}
>
<div className="ms-actions">
<button type="button" className="ms-action" onClick={selectAll}>전체 선택</button>
<button type="button" className="ms-action ms-action-muted" onClick={selectNone}>전체 해제</button>
</div>
{options.map(([k, v]) => (
<label key={k} className="ms-item">
<input type="checkbox" checked={value.includes(k)} onChange={() => toggle(k)} /> {v}
</label>
))}
</div>,
document.body,
)}
</div>
)
}
export function mapOptions(items: { id: number; name: string }[]): MultiSelectOption[] {
return items.map((x) => [String(x.id), x.name])
}
export function entriesOptions(map: Record<string, string> | undefined): MultiSelectOption[] {
return map ? Object.entries(map) : []
}
+2 -2
View File
@@ -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() {
<div className="login-wrap">
<form className="login-box" onSubmit={submit}>
<div className="login-brand">
<BillCareLogo title="BillCare" size="login" />
<BizCareLogo title="BizCare" size="login" />
<p>통합 영업·정산 관리 시스템</p>
</div>
<div className="field">
+222 -111
View File
@@ -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<typeof initialFilters>
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<ApiResponse<{ id: number; name: string }[]>>(
`${apiPrefix()}/homes/branch-groups`,
)
return unwrap(data) || []
},
})
const inputCentersQuery = useQuery({
queryKey: ['homes-input-centers'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<{ id: number; name?: string }[]>>(
`${apiPrefix()}/homes/input-centers`,
)
return unwrap(data) || []
},
})
const branchesQuery = useQuery({
queryKey: ['homes-branches-office'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<{ id: number; name?: string; officeName?: string }[]>>(
`${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<ApiResponse<IspProduct[]>>(
`${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<ApiResponse<ReceptionResult>>(`${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<string, string>)
const settleSelectOpts = statusOptions(settleLabels as Record<string, string>)
const setD = (patch: Partial<ReturnType<typeof initialFilters>>) => setDraft((p) => ({ ...p, ...patch }))
const applyF = (patch: Partial<ReturnType<typeof initialFilters>>) => {
const giftTypeOpts: MultiSelectOption[] = (
query.data?.giftCategories || ['상품권', '가전', '캐시백', '없음']
).map((x) => [x, x])
const progressMsOpts = entriesOptions(progressLabels as Record<string, string>)
const giftMsOpts = entriesOptions(giftLabels as Record<string, string>)
const centerStatusMsOpts = entriesOptions(settleLabels as Record<string, string>)
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<string>()
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<string>()
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<string>()
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<Filters>) => setDraft((p) => ({ ...p, ...patch }))
const applyF = (patch: Partial<Filters>) => {
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() {
</div>
</div>
<div className="toolbar order-top-buttons">
<Button type="primary" onClick={search}>검색</Button>
<Button onClick={reset}>검색 초기화</Button>
</div>
<div className="order-filter">
<div className="order-date-row">
<DatePicker
@@ -447,28 +565,27 @@ export default function ReceptionListPage() {
/>
</label>
<div className="order-attach-buttons">
<Button type="primary" className="order-search-btn" onClick={search}>검색</Button>
<Button onClick={reset}>검색 초기화</Button>
<Button onClick={downloadExcel}>Excel 다운로드</Button>
<Button onClick={downloadExcel}>사은품 Excel 다운로드</Button>
</div>
</div>
<div className="order-result-row">
<span className="order-result muted">검색 결과: 총 {(query.data?.total ?? 0).toLocaleString()} 건</span>
</div>
<div className="order-search-fields">
<Select
allowClear
<MultiSelect
className="ff"
placeholder="사은품"
options={giftTypeOpts}
value={draft.giftType}
onChange={(v) => setD({ giftType: v })}
options={options(query.data?.giftCategories || ['상품권', '가전', '캐시백', '없음'])}
/>
<input className="ff-i" placeholder="포스트잇" value={draft.postIt} onChange={(e) => setD({ postIt: e.target.value })} />
<input className="ff-i" placeholder="약정" value={draft.agreementName} onChange={(e) => setD({ agreementName: e.target.value })} />
<input className="ff-i" placeholder="주민번호" value={draft.customerSsid} onChange={(e) => setD({ customerSsid: e.target.value })} />
<input className="ff-i" placeholder="이메일" value={draft.customerEmail} onChange={(e) => setD({ customerEmail: e.target.value })} />
<input className="ff-i" placeholder="납입자주민" value={draft.paymentTransferSsid} onChange={(e) => setD({ paymentTransferSsid: e.target.value })} />
<input className="ff-i" placeholder="납입계좌번호" value={draft.paymentTransferNumber} onChange={(e) => setD({ paymentTransferNumber: e.target.value })} />
<label className="ff-d">
개통일자
<DatePicker
@@ -484,9 +601,18 @@ export default function ReceptionListPage() {
onChange={(d) => setD({ giftSended: d ? d.format('YYYY-MM-DD') : undefined })}
/>
</label>
<input className="ff-i" placeholder="입력처명" value={draft.centerName} onChange={(e) => setD({ centerName: e.target.value })} />
<input className="ff-i" placeholder="영업점명" value={draft.branchName} onChange={(e) => setD({ branchName: e.target.value })} />
<MultiSelect
className="ff"
placeholder="영업점 그룹"
options={branchGroupMsOpts}
value={draft.branchGroupIds}
onChange={(v) => setD({ branchGroupIds: v })}
/>
<input className="ff-i" placeholder="통신사명" value={draft.ispName} onChange={(e) => setD({ ispName: e.target.value })} />
<input className="ff-i" placeholder="상품명" value={draft.productName} onChange={(e) => setD({ productName: e.target.value })} />
<span className="order-result muted">검색 결과: 총 {(query.data?.total ?? 0).toLocaleString()} 건</span>
</div>
</div>
@@ -523,50 +649,45 @@ export default function ReceptionListPage() {
</select>
</th>
<th className="col-center">
<Select
allowClear
size="small"
<MultiSelect
placeholder="입력처"
value={draft.settleStatus}
onChange={(v) => applyF({ settleStatus: v })}
options={settleSelectOpts}
style={{ width: '100%' }}
options={processorMsOpts}
value={draft.processors}
onChange={(v) => applyF({ processors: v, centerNames: v })}
/>
</th>
<th className="col-branch">
<Select
allowClear
size="small"
<MultiSelect
placeholder="영업점"
value={draft.branch}
onChange={(v) => applyF({ branch: v })}
options={options(query.data?.branches || [])}
style={{ width: '100%' }}
options={branchMsOpts}
value={draft.branches}
onChange={(v) => applyF({ branches: v, branchIds: v })}
/>
</th>
<th className="col-isp">
<Select
allowClear
size="small"
<MultiSelect
placeholder="통신사"
value={draft.carrier}
onChange={(v) => applyF({ carrier: v })}
options={options(query.data?.carriers || [])}
style={{ width: '100%' }}
options={ispMsOpts}
value={draft.ispId}
onChange={(v) => applyF({ ispId: v, productNames: [], productIds: [] })}
/>
</th>
<th className="col-ptype">
<Select
allowClear
size="small"
<MultiSelect
placeholder="상품종류"
options={PRODUCT_TYPE_OPTS}
value={draft.productType}
onChange={(v) => applyF({ productType: v })}
options={options(query.data?.productCategories || ['인터넷', '전화', 'TV', '렌탈'])}
style={{ width: '100%' }}
/>
</th>
<th className="col-product">&nbsp;</th>
<th className="col-product">
<MultiSelect
placeholder="상품"
options={productMsOpts}
value={draft.productNames}
onChange={(v) => applyF({ productNames: v, productIds: v })}
/>
</th>
<th className="col-customer">
<input
placeholder="고객명"
@@ -576,14 +697,11 @@ export default function ReceptionListPage() {
/>
</th>
<th className="col-region">
<Select
allowClear
size="small"
<MultiSelect
placeholder="지역"
options={sidoMsOpts}
value={draft.customerSido}
onChange={(v) => applyF({ customerSido: v })}
options={options(SIDO_LIST)}
style={{ width: '100%' }}
/>
</th>
<th className="col-idnum">
@@ -595,14 +713,11 @@ export default function ReceptionListPage() {
/>
</th>
<th className="col-status">
<Select
allowClear
size="small"
<MultiSelect
placeholder="진행현황"
options={progressMsOpts}
value={draft.progressStatus}
onChange={(v) => applyF({ progressStatus: v })}
options={progressSelectOpts}
style={{ width: '100%' }}
/>
</th>
<th className="col-contract">
@@ -614,33 +729,29 @@ export default function ReceptionListPage() {
/>
</th>
<th className="col-giftplace">
<Select
allowClear
size="small"
placeholder="지급처"
value={draft.giftPlace}
onChange={(v) => applyF({ giftPlace: v })}
options={[
{ value: 'b01', label: '본사요청' },
{ value: 'b02', label: '자체발송' },
{ value: '본사', label: '본사' },
{ value: '영업점', label: '영업점' },
]}
style={{ width: '100%' }}
/>
<select value={draft.giftPlace} onChange={(e) => applyF({ giftPlace: e.target.value })}>
<option value="">모두</option>
{Object.entries(GIFT_PLACE).map(([code, label]) => (
<option key={code} value={code}>{label}</option>
))}
</select>
</th>
<th className="col-gift">
<Select
allowClear
size="small"
placeholder="사은품진행"
<MultiSelect
placeholder="사은품진행현황"
options={giftMsOpts}
value={draft.giftStatus}
onChange={(v) => applyF({ giftStatus: v })}
options={giftSelectOpts}
style={{ width: '100%' }}
/>
</th>
<th className="col-cstatus">&nbsp;</th>
<th className="col-cstatus">
<MultiSelect
placeholder="입력처진행현황"
options={centerStatusMsOpts}
value={draft.centerStatus}
onChange={(v) => applyF({ centerStatus: v })}
/>
</th>
</tr>
</thead>
<tbody>
+182 -31
View File
@@ -43,14 +43,14 @@ body { margin: 0; background: var(--bg); color: var(--text); }
text-align: center;
margin-bottom: 22px;
}
.login-box .login-brand .billcare-logo {
.login-box .login-brand .bizcare-logo {
margin: 0 auto;
}
.login-box .login-brand .billcare-logo-mark {
.login-box .login-brand .bizcare-logo-mark {
height: 64px;
width: auto;
}
.login-box .login-brand .billcare-logo-title {
.login-box .login-brand .bizcare-logo-title {
font-size: 28px;
color: var(--login-text) !important;
}
@@ -108,7 +108,7 @@ body { margin: 0; background: var(--bg); color: var(--text); }
font-size: 12px;
}
.billcare-logo {
.bizcare-logo {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -116,39 +116,39 @@ body { margin: 0; background: var(--bg); color: var(--text); }
max-width: 100%;
background: transparent !important;
}
.billcare-logo--login {
.bizcare-logo--login {
flex-direction: column;
gap: 10px;
}
.billcare-logo--header.billcare-logo--dark,
.billcare-logo--header.billcare-logo--blue {
.bizcare-logo--header.bizcare-logo--dark,
.bizcare-logo--header.bizcare-logo--blue {
flex-direction: row;
align-items: center;
justify-content: center;
gap: 10px;
}
img.billcare-logo,
.billcare-logo img,
.billcare-logo-mark {
img.bizcare-logo,
.bizcare-logo img,
.bizcare-logo-mark {
display: block;
background: transparent !important;
object-fit: contain;
}
img.billcare-logo--header {
img.bizcare-logo--header {
height: 28px;
width: auto;
max-width: min(150px, 100%);
object-position: center;
}
.billcare-logo--header .billcare-logo-mark {
.bizcare-logo--header .bizcare-logo-mark {
height: 30px;
width: auto;
}
.billcare-logo--login .billcare-logo-mark {
.bizcare-logo--login .bizcare-logo-mark {
height: 64px;
width: auto;
}
.billcare-logo-title {
.bizcare-logo-title {
font-family: 'Pretendard', -apple-system, 'Malgun Gothic', sans-serif;
font-weight: 800;
font-size: 15px;
@@ -157,14 +157,14 @@ img.billcare-logo--header {
color: var(--text);
white-space: nowrap;
}
.billcare-logo--header .billcare-logo-title {
.bizcare-logo--header .bizcare-logo-title {
font-size: 19px;
transform: translateY(2px);
}
.billcare-logo--login .billcare-logo-title {
.bizcare-logo--login .bizcare-logo-title {
font-size: 28px;
}
.billcare-logo--blue .billcare-logo-title {
.bizcare-logo--blue .bizcare-logo-title {
color: #e8f4ff;
}
.brand-inline {
@@ -177,7 +177,7 @@ img.billcare-logo--header {
overflow: hidden;
padding: 0 8px;
}
.brand-inline .billcare-logo {
.brand-inline .bizcare-logo {
max-width: 100%;
justify-content: center;
align-items: center;
@@ -4251,7 +4251,7 @@ html.theme-light .reception-order-page {
outline: none;
border-color: var(--primary);
}
.order-result { margin-left: auto; font-size: 12px; }
.order-result { margin-left: auto; font-size: 12px; white-space: nowrap; }
.order-attach-buttons {
margin-left: auto;
display: inline-flex;
@@ -4259,15 +4259,10 @@ html.theme-light .reception-order-page {
gap: 6px;
flex-wrap: wrap;
}
.order-result-row {
display: flex;
align-items: center;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.order-result-row .order-result {
margin-left: 0;
.order-attach-buttons .order-search-btn.ant-btn-primary,
.order-attach-buttons .order-search-btn.ant-btn-primary:hover,
.order-attach-buttons .order-search-btn.ant-btn-primary:focus {
color: #000 !important;
}
.order-search-fields {
display: flex;
@@ -4352,12 +4347,168 @@ table.order-list tr.order-col-filter select {
width: 100%;
font-size: 10px;
padding: 2px 4px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
background: var(--surface) !important;
color: var(--text) !important;
border: 1px solid var(--border) !important;
border-radius: 3px;
box-sizing: border-box;
}
table.order-list tr.order-col-filter select option {
background: var(--surface);
color: var(--text);
}
table.order-list tr.order-col-filter .ms-toggle {
padding: 2px 4px;
font-size: 10px;
min-width: 0;
width: 100%;
}
table.order-list tr.order-col-filter .ms-panel,
table.order-list tr.order-col-filter .ms-panel-portal {
min-width: 140px;
}
.order-search-fields .ff .ms-toggle {
width: 100%;
font-size: 11px;
padding: 6px 8px;
}
/* MultiSelect — 테마 토큰 기반 */
.ms { position: relative; display: inline-block; width: 100%; min-width: 0; }
.ms-toggle {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
text-align: left;
padding: 8px 10px;
cursor: pointer;
background: var(--surface) !important;
color: var(--text) !important;
border: 1px solid var(--border) !important;
border-radius: 4px;
box-shadow: none !important;
}
.ms-toggle:hover {
border-color: color-mix(in srgb, var(--primary) 45%, var(--border)) !important;
color: var(--text) !important;
}
.ms.is-open .ms-toggle,
.ms-toggle:focus-visible {
border-color: var(--primary) !important;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 25%, transparent) !important;
outline: none;
}
.ms-toggle .ms-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
color: inherit;
}
.ms-toggle .ms-caret { color: var(--muted); font-size: 10px; flex-shrink: 0; }
.ms-panel,
.ms-panel-portal {
background: var(--elevated, var(--surface)) !important;
color: var(--text) !important;
border: 1px solid var(--border) !important;
border-radius: 6px;
box-shadow: var(--shadow-lg, var(--shadow)) !important;
padding: 4px;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--primary) 45%, var(--muted)) var(--surface-2);
}
.ms-panel {
position: absolute;
z-index: 60;
top: calc(100% + 2px);
left: 0;
right: 0;
min-width: 160px;
max-height: 240px;
overflow-y: auto;
}
.ms-panel-portal {
position: fixed;
z-index: 5000;
right: auto;
min-width: 160px;
overflow-y: auto;
}
.ms-panel::-webkit-scrollbar,
.ms-panel-portal::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.ms-panel::-webkit-scrollbar-track,
.ms-panel-portal::-webkit-scrollbar-track {
background: var(--surface-2);
border-radius: 4px;
}
.ms-panel::-webkit-scrollbar-thumb,
.ms-panel-portal::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--primary) 40%, var(--muted));
border-radius: 4px;
border: 2px solid var(--surface-2);
}
.ms-panel::-webkit-scrollbar-thumb:hover,
.ms-panel-portal::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--primary) 60%, var(--muted));
}
.ms-actions {
display: flex;
gap: 4px;
padding: 2px 2px 6px;
border-bottom: 1px solid var(--border);
margin-bottom: 4px;
position: sticky;
top: 0;
background: var(--elevated, var(--surface));
z-index: 1;
}
.ms-action {
padding: 2px 6px;
font-size: 11px;
background: transparent !important;
border: 1px solid var(--border) !important;
border-radius: 3px;
color: var(--text) !important;
cursor: pointer;
}
.ms-action:hover {
background: color-mix(in srgb, var(--primary) 14%, transparent) !important;
border-color: color-mix(in srgb, var(--primary) 40%, var(--border)) !important;
color: var(--text) !important;
}
.ms-action-muted { color: var(--muted) !important; }
.ms-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
border-radius: 4px;
color: var(--text) !important;
}
.ms-item:hover {
background: color-mix(in srgb, var(--primary) 14%, transparent) !important;
}
.ms-item:has(input:checked) {
background: color-mix(in srgb, var(--primary) 18%, transparent);
}
.ms-item input[type='checkbox'] {
width: auto;
margin: 0;
accent-color: var(--primary);
flex-shrink: 0;
}
table.order-list tbody tr { cursor: pointer; }
table.order-list tbody tr:hover td { background: color-mix(in srgb, var(--primary) 8%, var(--surface)); }
table.order-list .c-name { color: var(--c-name); }
-24
View File
@@ -1,24 +0,0 @@
# BillCare — exdev Caddy 서브도메인 스니펫
# host 포트 83 → billcare-frontend
# 인증서: acme.sh 로 발급한 billcare 전용 LE cert
# /Users/aidev/dev/exAIChatbot2.0/docker/nginx/certs/billcare/{fullchain,privkey}.pem
# (Caddy 컨테이너 마운트: /etc/caddy/certs/billcare/)
# BillCare
https://{$BILLCARE_DOMAIN:billcare.exdev.co.kr} {
tls /etc/caddy/certs/billcare/fullchain.pem /etc/caddy/certs/billcare/privkey.pem
handle {
reverse_proxy host.docker.internal:{$BILLCARE_HOST_PORT:83} {
flush_interval -1
transport http {
read_timeout 120s
write_timeout 120s
versions 1.1 2
}
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
# BizCare — exdev Caddy 서브도메인 스니펫
# host 포트 83 → bizcare-frontend
# 인증서: acme.sh 로 발급한 bizcare 전용 LE cert
# /Users/aidev/dev/exAIChatbot2.0/docker/nginx/certs/bizcare/{fullchain,privkey}.pem
# (Caddy 컨테이너 마운트: /etc/caddy/certs/bizcare/)
# BizCare
https://{$BILLCARE_DOMAIN:bizcare.exdev.co.kr} {
tls /etc/caddy/certs/bizcare/fullchain.pem /etc/caddy/certs/bizcare/privkey.pem
handle {
reverse_proxy host.docker.internal:{$BILLCARE_HOST_PORT:83} {
flush_interval -1
transport http {
read_timeout 120s
write_timeout 120s
versions 1.1 2
}
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}
}
+10 -10
View File
@@ -1,17 +1,17 @@
#!/usr/bin/env bash
# ==============================================================================
# BillCare 배포 스크립트 — Docker Compose
# BizCare 배포 스크립트 — Docker Compose
# 호출: Git post-receive (Track A) | Jenkins (Track B)
# 서버 설치: /Volumes/ADATA/git/deploy-billcare.sh
# 서버 설치: /Volumes/ADATA/git/deploy-bizcare.sh
# ==============================================================================
set -euo pipefail
GIT_DIR="${GIT_DIR:-/Volumes/ADATA/git/BillCare.git}"
WORK_TREE="${WORK_TREE:-/Users/aidev/apps/BillCare}"
GIT_DIR="${GIT_DIR:-/Volumes/ADATA/git/BizCare.git}"
WORK_TREE="${WORK_TREE:-/Users/aidev/apps/BizCare}"
BRANCH="${BRANCH:-main}"
DOCKER_COMPOSE_FILE="${DOCKER_COMPOSE_FILE:-${WORK_TREE}/docker-compose.yml}"
DEPLOY_LOG="${DEPLOY_LOG:-/Volumes/ADATA/logs/deploy/billcare-deploy.log}"
LOG_TAG="[billcare-deploy]"
DEPLOY_LOG="${DEPLOY_LOG:-/Volumes/ADATA/logs/deploy/bizcare-deploy.log}"
LOG_TAG="[bizcare-deploy]"
SERVICE_NAME="${SERVICE_NAME:-all}"
DOCKER_NO_CACHE="${DOCKER_NO_CACHE:-0}"
BACKEND_HEALTH_URL="${BACKEND_HEALTH_URL:-http://127.0.0.1:18082/api/health}"
@@ -22,7 +22,7 @@ export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:${PATH:-/usr/bi
export FRONTEND_PORT="${FRONTEND_PORT:-83}"
export BACKEND_PORT="${BACKEND_PORT:-18082}"
export MYSQL_PORT="${MYSQL_PORT:-3308}"
export APP_CORS_ORIGINS="${APP_CORS_ORIGINS:-https://billcare.exdev.co.kr,http://billcare.exdev.co.kr,http://localhost:83,http://127.0.0.1:83}"
export APP_CORS_ORIGINS="${APP_CORS_ORIGINS:-https://bizcare.exdev.co.kr,http://bizcare.exdev.co.kr,http://localhost:83,http://127.0.0.1:83}"
mkdir -p "$(dirname "$DEPLOY_LOG")"
exec >> "$DEPLOY_LOG" 2>&1
@@ -33,7 +33,7 @@ warn() { log "[WARN] $*"; }
err() { log "[ERROR] $*"; exit 1; }
log "======================================================"
log " BillCare 배포 시작"
log " BizCare 배포 시작"
log " 서비스 : $SERVICE_NAME"
log " 브랜치 : $BRANCH"
log " 작업트리: $WORK_TREE"
@@ -41,7 +41,7 @@ log " compose : $DOCKER_COMPOSE_FILE"
log " 실행자 : $(whoami)"
log "======================================================"
LOCK_FILE="/tmp/billcare_deploy.lock"
LOCK_FILE="/tmp/bizcare_deploy.lock"
if [ -f "$LOCK_FILE" ]; then
LOCK_PID=$(cat "$LOCK_FILE" 2>/dev/null || echo "")
if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then
@@ -141,7 +141,7 @@ esac
log "======================================================"
log " 배포 완료: $(date '+%Y-%m-%d %H:%M:%S')"
log " 커밋: $(git --git-dir="$GIT_DIR" log --oneline -1 2>/dev/null || echo N/A)"
log " URL: https://billcare.exdev.co.kr"
log " URL: https://bizcare.exdev.co.kr"
log "======================================================"
docker compose -f "$DOCKER_COMPOSE_FILE" ps 2>/dev/null || docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
+9 -9
View File
@@ -1,15 +1,15 @@
#!/usr/bin/env bash
# BillCare post-receive — Track A (git push main → deploy-billcare.sh)
# 설치: cp scripts/post-receive /Volumes/ADATA/git/BillCare.git/hooks/post-receive && chmod +x ...
# BizCare post-receive — Track A (git push main → deploy-bizcare.sh)
# 설치: cp scripts/post-receive /Volumes/ADATA/git/BizCare.git/hooks/post-receive && chmod +x ...
set -euo pipefail
GIT_DIR="/Volumes/ADATA/git/BillCare.git"
WORK_TREE="${WORK_TREE:-/Users/aidev/apps/BillCare}"
DEPLOY_SCRIPT="/Volumes/ADATA/git/deploy-billcare.sh"
GIT_DIR="/Volumes/ADATA/git/BizCare.git"
WORK_TREE="${WORK_TREE:-/Users/aidev/apps/BizCare}"
DEPLOY_SCRIPT="/Volumes/ADATA/git/deploy-bizcare.sh"
SUMMARY_SCRIPT="/Volumes/ADATA/git/git-push-summary.sh"
DEPLOY_LOG="/Volumes/ADATA/logs/deploy/billcare-deploy.log"
DEPLOY_LOG="/Volumes/ADATA/logs/deploy/bizcare-deploy.log"
SUMMARY_DIR="/Volumes/ADATA/logs/deploy/push-summaries"
HOOK_TAG="[billcare-post-receive]"
HOOK_TAG="[bizcare-post-receive]"
mkdir -p "$(dirname "$DEPLOY_LOG")" "$SUMMARY_DIR"
@@ -31,7 +31,7 @@ while read -r OLDREV NEWREV REFNAME; do
fi
if [ "$BRANCH_NAME" = "main" ]; then
echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG main → deploy-billcare.sh 실행"
echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG main → deploy-bizcare.sh 실행"
export SERVICE_NAME="${SERVICE_NAME:-all}"
if [ ! -x "$DEPLOY_SCRIPT" ]; then
echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG [ERROR] 배포 스크립트 없음: $DEPLOY_SCRIPT"
@@ -43,7 +43,7 @@ while read -r OLDREV NEWREV REFNAME; do
echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG [ERROR] 배포 실패 (exit: $EXIT_CODE)"
exit "$EXIT_CODE"
fi
echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG 배포 완료 → https://billcare.exdev.co.kr"
echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG 배포 완료 → https://bizcare.exdev.co.kr"
else
echo "$(date '+%Y-%m-%d %H:%M:%S') $HOOK_TAG $BRANCH_NAME — 배포 생략 (main만 자동 배포)"
fi