BillCare 초기 소스 등록

비즈케어 홈즈 홈상품 운용관리(BillCare) 프론트/백엔드/Docker 구성을 exdev git에 등록합니다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Macbook
2026-07-12 05:05:17 +09:00
commit 03cd93e1f2
160 changed files with 47915 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
# Dependencies & build
**/node_modules/
**/dist/
**/dist-ssr/
**/target/
**/.gradle/
# Environment (secrets)
.env
.env.local
.env.*.local
# IDE / OS
.idea/
.vscode/
.cursor/
**/.DS_Store
**/__MACOSX/
# Logs & cache
*.log
.npm/
.cache/
# Uploads / local storage
**/storage/uploads/
backend/storage/
# Docker local volumes / dumps
*.tar.gz
*.sql.gz
+30
View File
@@ -0,0 +1,30 @@
# 빌링 케어 (비즈케어 홈즈)
[비즈케어 홈즈](https://h.bizcare.co.kr/) 홈상품(인터넷/홈렌탈) 운용관리 프로그램을
billAgent와 동일한 레이아웃·테마 구조로 재구현한 **독립 프로젝트**입니다.
## 실행
```bash
cd /Users/macbook/dev/billCare
docker compose down
docker compose up --build -d
```
- UI: http://localhost:82
- API: http://localhost:8082
- MySQL: localhost:3308 (`billcare`)
데모 계정: `demo` / `demo123`
## 구성
| 구분 | 내용 |
|------|------|
| 브랜드 | 빌링 케어 / Bizcare Homes |
| 레이아웃/테마 | `AgentShell` + light/dark/blue |
| 메뉴 | 상품·계약·정산·약속·예약·고객·장부·통계·설정·CS·게시판 |
| API | Spring Boot JWT (`/api/agent/homes/**`) |
| 컨테이너 | `billcare-frontend` / `billcare-backend` / `billcare-mysql` |
billAgent와 동시에 실행할 수 있도록 포트·컨테이너·DB명을 분리했습니다.
+6
View File
@@ -0,0 +1,6 @@
target/
.mvn/wrapper/maven-wrapper.jar
.git
.idea
*.iml
.DS_Store
+2
View File
@@ -0,0 +1,2 @@
/mvnw text eol=lf
*.cmd text eol=crlf
+33
View File
@@ -0,0 +1,33 @@
HELP.md
target/
.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
+3
View File
@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
+17
View File
@@ -0,0 +1,17 @@
# syntax=docker/dockerfile:1
FROM maven:3.9.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn -q -DskipTests package
FROM eclipse-temurin:21-jre
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
HEALTHCHECK --interval=5s --timeout=3s --start-period=25s --retries=20 \
CMD curl -fsS http://127.0.0.1:8080/api/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
Vendored Executable
+295
View File
@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"
+189
View File
@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+142
View File
@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.bizcare</groupId>
<artifactId>agent</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>agent</name>
<description/>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<executions>
<execution>
<id>default-compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</execution>
<execution>
<id>default-testCompile</id>
<phase>test-compile</phase>
<goals>
<goal>testCompile</goal>
</goals>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,13 @@
package com.bizcare.agent;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class AgentApplication {
public static void main(String[] args) {
SpringApplication.run(AgentApplication.class, args);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,587 @@
package com.bizcare.agent;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.*;
@MappedSuperclass @Getter @Setter @NoArgsConstructor @AllArgsConstructor
abstract class BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) Long id;
String groupId;
Instant createdAt; Instant updatedAt;
@PrePersist void created() { createdAt = updatedAt = Instant.now(); }
@PreUpdate void updated() { updatedAt = Instant.now(); }
}
@Entity @Table(name="members") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class Member extends BaseEntity {
@Column(unique=true, nullable=false) String userid; String password; String name; String phone;
String role, staffPermission; Long companyId; boolean active = true;
Integer loginCount;
LocalDateTime lastLoginAt;
String plainPassword;
/** 직원 주소 (홈즈 직원관리) */
String address;
/** 접속제한 메모/IP 등 */
String accessLimit;
@Column(columnDefinition="TEXT") String menuFlags;
@Column(columnDefinition="TEXT") String assignedOutNames;
@Column(columnDefinition="TEXT") String memo;
}
@Entity @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class Company extends BaseEntity {
String type, name, managerName, phone, fax, mobile, telecom, bankAccount, businessName, businessNumber, ceo, address, email;
String channel, pcode, memo, salesperson, category;
@Column(columnDefinition="TEXT") String smsPhones; boolean active = true;
}
@Entity @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class Stock extends BaseEntity {
String gubun, telecom, telcompany, serial, serials, barcode, model, color, cond, pay, memo, state, outcode, userid, procid;
String petname, outCategory, salesperson, source, processor;
@Column(unique=true) String uid;
BigDecimal inprice; Long incompanyId, outcompanyId, openId; LocalDate indate, outdate, recalldate, returndate, lossdate, selldate, processDate;
}
@Entity @Table(name="open_records") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class OpenRecord extends BaseEntity {
Long outcompanyId, phoneStockId, usimStockId, incompanyId;
String gubun, telecom, telcompany, pserial, pmodel, pcolor, userial, umodel, name, openphone, openhow, plan, salehow, joinhow, usimhow, decltype, userid, dealermemo, memo1, memo2, memo3, state;
String salesperson, employee, outCategory, openStatus, supportType;
LocalDate opendate; Integer salemon, agreemon, openHour, openMinute;
BigDecimal joinprice, usimprice, inprice, netprice, sellprice, basicprice1, basicprice2, basicprice3, basicprice4, etcprice1, etcprice2, declprice1, declprice2, moveprice, planprice, preprice, memberpoint, dealermargin1, dealermargin2;
BigDecimal commonSupport, extraSupport, switchSupport, pointPay, gradePrice;
@Column(unique=true) String uid;
}
@Entity @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class SettlementMonth extends BaseEntity {
Long outcompanyId;
@Column(name = "ym") String yearMonth;
String status, excelUrl, memo, outCategory, inquiry, agreement, invoiceStatus, remittanceStatus, outcompanyName;
Integer openCount, afterSettleCount, homeProductCount;
BigDecimal openAmount, afterSettleAmount, homeProductAmount, realSettleAmount;
boolean issued;
}
@Entity @Table(name="after_balances") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class AfterBalance extends BaseEntity {
Long outcompanyId, incompanyId, openId;
LocalDate opendate, basedate;
String outcompanyName, customerName, openphone, gubun, reason, memo, telecom, outCategory;
BigDecimal amount;
}
@Entity @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class Farebox extends BaseEntity {
Long outcompanyId;
LocalDate faredate;
String telcompany, name, phone, how, paystate, receiptNo;
String outcompanyName, outCategory, detail, registrant;
BigDecimal cashprice, cardprice, paidAmount;
}
@Entity @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class FareboxPayment extends BaseEntity {
Long outcompanyId;
String outcompanyName, mode, memo, registrant, status, outCategory;
LocalDate paydate;
Integer payCount;
BigDecimal amount;
@Column(columnDefinition="TEXT") String fareboxIds;
}
@Entity @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class BbsPost extends BaseEntity {
String code, title, authorName, userid, state, targetGroup, fileName;
@Column(columnDefinition="TEXT") String contents, comments;
boolean notice;
Integer views, confirmCount, commentCount;
}
@Entity @Table(name="board_groups") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class BoardGroup extends BaseEntity {
String category, name;
@Column(columnDefinition="TEXT") String companyIds;
}
@Entity @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class ScanDoc extends BaseEntity {
Long outcompanyId, incompanyId;
String telcompany, gubun, name, memo, sendmsg, state, src, outcompanyName;
Integer filesCount;
LocalDateTime registeredAt;
}
@Entity @Table(name="indoc") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class IncompleteDoc extends BaseEntity {
Long outcompanyId, openId, incompanyId;
LocalDate opendate, deadline;
String customerName, openphone, missingDocs, deductReason, telecom, status;
BigDecimal deductAmount;
}
@Entity @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class UnreturnedPhone extends BaseEntity {
Long outcompanyId, openId, incompanyId;
LocalDate opendate, returndate;
String customerName, openphone, model, serial, telecom, returnStatus;
BigDecimal deductAmount;
}
@Entity @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class ScheduleItem extends BaseEntity { String userid, stime, contents; LocalDate sdate; }
@Entity @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class SmsMessage extends BaseEntity {
String userid, phone, status, msgType, senderNumber, senderName;
@Column(columnDefinition="TEXT") String message;
Integer sendCount, deductCount;
Instant sentAt;
}
@Entity @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class SmsTemplate extends BaseEntity { String title; @Column(columnDefinition="TEXT") String body; }
@Entity @Table(name="sms_address_groups") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class SmsAddressGroup extends BaseEntity {
String name;
}
@Entity @Table(name="sms_address_contacts") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class SmsAddressContact extends BaseEntity {
Long addressGroupId;
String name, phone;
}
@Entity @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class PlanMaster extends BaseEntity { String telecom, name; BigDecimal price; boolean active = true; }
@Entity @Table(name="unit_cost_tables") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class UnitCostTable extends BaseEntity {
Long incompanyId;
String telecom, incompanyName, memo, category;
Integer planGroupA, planGroupB, planGroupC, planGroupD, planGroupE, planGroupF, planGroupG, policyCount;
}
@Entity @Table(name="unit_cost_policies") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class UnitCostPolicy extends BaseEntity {
Long unitCostId;
String planGroup, model, planName;
BigDecimal amount;
}
@Entity @Table(name="home_sales") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomeSale extends BaseEntity {
Long outcompanyId, incompanyId;
LocalDate installDate, receiptDate;
String status, telecom, outCategory, outcompanyName, employee, customerName, phone, address, products, missingDocs, memo;
BigDecimal extraPolicy, settleAmount, margin;
}
@Entity @Table(name="home_indocs") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomeIncompleteDoc extends BaseEntity {
Long outcompanyId, incompanyId, homeSaleId;
LocalDate installDate, deadline;
String customerName, phone, missingDocs, deductReason, telecom, status, products, outcompanyName;
BigDecimal deductAmount;
}
@Entity @Table(name="open_exchanges") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class OpenExchange extends BaseEntity {
Long outcompanyId, openId;
LocalDate exchangeDate, openDate;
LocalDateTime processedAt;
String outcompanyName, customerName, openphone, kind, newModel, newColor, newSerial, oldModel, oldColor, oldSerial, processor, memo;
}
@Entity @Table(name="open_name_changes") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class OpenNameChange extends BaseEntity {
Long outcompanyId, openId;
LocalDate changeDate, openDate;
LocalDateTime processedAt;
String outcompanyName, customerName, openphone, oldOwner, oldPhone, oldUsim, processor, memo;
}
@Entity @Table(name="open_withdrawals") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class OpenWithdrawal extends BaseEntity {
Long outcompanyId, openId;
LocalDate withdrawDate, openDate;
LocalDateTime processedAt;
String outcompanyName, customerName, openphone, processor, reason;
}
@Entity @Table(name="open_settle_histories") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class OpenSettleHistory extends BaseEntity {
Long outcompanyId, openId;
LocalDate changeDate, openDate;
LocalDateTime processedAt;
String outcompanyName, customerName, openphone, processor, memo;
BigDecimal oldAmount, newAmount;
}
@Entity @Table(name="open_delete_histories") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class OpenDeleteHistory extends BaseEntity {
Long outcompanyId, openId;
LocalDate deleteDate, openDate;
LocalDateTime processedAt;
String outcompanyName, customerName, openphone, processor, memo;
}
@Entity @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class PhoneModel extends BaseEntity { String telecom, modelCode, modelName; @Column(columnDefinition="TEXT") String colorOptions; boolean active = true; }
@Entity @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class CsInquiry extends BaseEntity {
String userid, authorName, title, status;
@Column(columnDefinition="TEXT") String content, reply;
Integer replyCount;
}
@Entity @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class Alim extends BaseEntity { String userid, title; @Column(columnDefinition="TEXT") String content; boolean confirmed; }
@Entity @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class AccountEntry extends BaseEntity {
Long outcompanyId, dealerId;
LocalDate entryDate;
String type, category, memo;
@Column(name = "ym") String yearMonth;
BigDecimal amount;
}
@Entity @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class Setting extends BaseEntity {
@Column(name = "setting_key") String settingKey;
@Lob @Column(columnDefinition = "LONGTEXT") String value;
}
@Entity @Table(name="usim_ledger") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class UsimLedger extends BaseEntity {
Long outcompanyId;
LocalDate tradeDate;
String telecom, detail, registrant, status;
Integer qty;
BigDecimal amount;
}
@Entity @Table(name="stock_history") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class StockHistory extends BaseEntity {
Long stockId, incompanyId, outcompanyId;
LocalDate processDate;
String gubun, state, statusLabel, serial, model, color, cond, memo, processor, salesperson;
String companyLabel;
}
@Entity @Table(name="stock_notes") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class StockNote extends BaseEntity {
Long stockId;
LocalDate noteDate;
String contents, authorName;
}
@Entity @Table(name="care_contracts") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class CareContract extends BaseEntity {
LocalDate contractDate, startDate, endDate;
String status, customerName, phone, productName, telecom, employee, address, memo;
BigDecimal amount, monthlyFee;
}
@Entity @Table(name="care_customers") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class CareCustomer extends BaseEntity {
String name, phone, telecom, address, status, employee, memo;
}
@Entity @Table(name="care_products") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class CareProduct extends BaseEntity {
String category, name, telecom, plan, status, memo;
BigDecimal monthlyFee, price;
}
@Entity @Table(name="homes_products") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesProduct extends BaseEntity {
String kind, category, company, name, model, memo;
Integer months, policyCount;
BigDecimal policyAmount;
}
@Entity @Table(name="homes_product_policies") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesProductPolicy extends BaseEntity {
Long productId;
String name;
BigDecimal amount;
}
@Entity @Table(name="homes_contracts") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesContract extends BaseEntity {
String kind, status, customerType, customerName, phone, address, products, agency, employee, payMethod, memo;
LocalDate contractDate, installDate;
Integer months;
BigDecimal policySum, settleAmount, margin;
String email, gender, nationality, customerGrade, birthDate;
String zipcode, addressDetail;
String installPhone, installZip, installAddress, installAddressDetail;
String feeReduction, invoiceType;
Boolean sameContact;
}
@Entity @Table(name="homes_balances") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesBalance extends BaseEntity {
/** 정산차감 / 정산추가 (구: 차감/추가) */
String balanceType;
/** 직원 / 하부점 */
String targetType;
/** 대상명 (직원명 또는 하부점명) */
String targetName;
String customerName, phone, products, reason, agency, memo;
LocalDate baseDate;
BigDecimal amount;
Long contractId;
}
@Entity @Table(name="homes_invoices") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesInvoice extends BaseEntity {
String scope, employee, agency, status, memo;
@Column(name = "ym") String yearMonth;
Integer contractCount;
BigDecimal amount;
/** 발행 스냅샷 */
Integer internetCount, homeCount, balanceCount;
BigDecimal internetAmount, homeAmount, balanceAmount, settleAmount;
Boolean issued;
String consent, taxInvoice, withdrawStatus;
}
@Entity @Table(name="homes_pendings") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesPending extends BaseEntity {
/** 과목: 일반/약속1/약속2 ... */
String pendingType, pendingTime, customerName, phone, contents, employee, status;
LocalDate pendingDate;
/** 접수직원 (없으면 employee 사용) */
String receiveEmployee;
/** 처리직원 */
String processEmployee;
LocalDate processDate;
String memo;
}
@Entity @Table(name="homes_bookings") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesBooking extends BaseEntity {
String status, customerName, phone, products, address, employee, memo;
LocalDate bookingDate;
/** 인터넷상품 (콤마 구분) */
String internetProducts;
/** 홈렌탈상품 (콤마 구분) */
String rentalProducts;
String internetNote;
String rentalNote;
String zipcode;
String addressDetail;
String fileName;
}
@Entity @Table(name="homes_customers") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesCustomer extends BaseEntity {
/** 분류 (단골, 단골1 … / 분류없음) */
String grade;
/** 구분: 개인 / 사업자 */
String customerType;
String name, phone, phoneAlt, address, agency, employee, memo;
String zipcode, addressDetail, email, gender, nationality;
String birthYear, birthMonth, birthDay;
LocalDate registeredDate;
}
@Entity @Table(name="homes_abooks") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesAbook extends BaseEntity {
/** 입금 / 출금 / 카드입금 / 카드출금 (구: 수입/지출) */
String entryType;
/** 과목 */
String category;
/** 품목 */
String item;
String agency, employee, memo;
LocalDate entryDate;
BigDecimal amount;
}
@Entity @Table(name="homes_agencies") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesAgency extends BaseEntity {
String name, managerName, phone, fax, mobile, agencyType, address, memo;
boolean active = true;
LocalDate registeredDate;
}
@Entity @Table(name="homes_partners") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesPartner extends BaseEntity {
/** 1=상부점, 2=하부점 */
String partnerLevel, name, parentName, managerName, phone, address, memo;
/** 상부점 연결 아이디 */
String userid;
/** 대기 / 승인 / 거절 */
String status;
LocalDate registeredDate;
}
@Entity @Table(name="homes_logs") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesLog extends BaseEntity {
String domain, action, content, staff;
LocalDateTime processedAt;
}
@Entity @Table(name="homes_notices") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesNotice extends BaseEntity {
String title, authorName;
@Column(columnDefinition="TEXT") String contents;
Integer views;
}
@Entity @Table(name="homes_questions") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesQuestion extends BaseEntity {
String title, authorName, status;
@Column(columnDefinition="TEXT") String content, reply;
}
@Entity @Table(name="homes_bbs") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesBbs extends BaseEntity {
String title, authorName;
@Column(columnDefinition="TEXT") String contents;
Integer views;
}
/** 접수관리 — billing_react orders 목록 필드를 Care Homes 멀티테넌트에 맞게 정리 */
@Entity @Table(name="homes_receptions") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesReception extends BaseEntity {
/** 접수일시 */
LocalDateTime orderDate;
/** 상담자 */
String counselor;
/** 지점/영업점 */
String branch;
/** 영업점 그룹 */
String branchGroup;
/** 영업점 연락처 */
String branchPhone;
/** 통신사 */
String carrier;
/** 사은품분류 */
String giftCategory;
/** 상품분류 */
String productCategory;
/** 상품종류 e01~e04 */
String productType;
/** 상품명 */
String productName;
/** 고객유형 public/company/foreigner */
String customerType;
/** 고객명 */
String customerName;
/** 연락처(휴대) */
String phone;
/** 유선전화 */
String tel;
/** 이메일 */
String email;
/** 우편번호 */
String zipcode;
/** 주소 */
String address;
/** 상세주소 */
String addressDetail;
/** 지역(시도) */
String region;
/** 진행상황 */
String progressStatus;
/** 주문번호 */
String orderNumber;
/** 연결된 ISP id */
Long ispId;
/** ISP 표시명 */
String ispName;
/** 입고/물류 정보 */
String inboundInfo;
/** 처리자 */
String processor;
/** 세부진행 */
String detailProgress;
/** 지급구분(표시용) */
String paymentType;
/** 납부방식 transfer/card/paper/telephone */
String paymentMethod;
String paymentName;
String paymentSsid;
String paymentTel;
String paymentBank;
String paymentAccountNumber;
String paymentCardCompany;
String paymentCardYear;
String paymentCardMonth;
String paymentCardNumber;
/** 금액 */
BigDecimal amount;
/** 정산상태 */
String settleStatus;
/** 개통일 */
LocalDate openDate;
/** 해지일 */
LocalDate cancelDate;
/** 사은품 지급일 */
LocalDate giftDate;
/** 사은품 진행 */
String giftStatus;
/** 지급처 */
String giftPlace;
LocalDate giftRequested;
LocalDate giftSended;
String giftBank;
String giftAccountNumber;
String giftAccountName;
String giftSsid;
String giftGoods;
BigDecimal giftCashAmount;
BigDecimal giftGoodsPrice;
String deliveryCompany;
String trackingNumber;
/** 약정명 */
String agreementName;
/** 개통/계약번호 */
String contractNumber;
/** 설치일정 */
String installSchedule;
/** 고객아이디(통신사) */
String customerLoginId;
/** 신규전화번호 */
String newTel;
Boolean multiLine;
Boolean addPayer;
Boolean recordFlag;
/** 주민/식별번호 */
String identifyNumber;
/** 포스트잇 */
String postIt;
/** 추가메모 */
String postAdd;
@Column(columnDefinition="TEXT") String memo;
@Column(columnDefinition="TEXT") String adminMemo;
String file1;
String file2;
String file3;
String file4;
String file5;
String adminFile1;
String adminFile2;
String adminFile3;
}
/** 접수 진행 이력 */
@Entity @Table(name="homes_reception_progress_logs") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesReceptionProgressLog extends BaseEntity {
Long receptionId;
String status;
String authorName;
LocalDateTime loggedAt;
@Column(columnDefinition="TEXT") String memo;
Boolean adminOnly;
}
/** 사은품 진행 이력 — billing_react order_gift_progress_logs */
@Entity @Table(name="homes_reception_gift_progress_logs") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesReceptionGiftProgressLog extends BaseEntity {
Long receptionId;
String status;
String authorName;
LocalDateTime loggedAt;
@Column(columnDefinition="TEXT") String memo;
}
/** 신규신청 통신사(ISP) 마스터 — billing_react isp 대응 */
@Entity @Table(name="homes_reception_isps") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesReceptionIsp extends BaseEntity {
String name;
String telecom;
Integer seq;
Boolean active;
Boolean isSktb;
}
/** ISP 상품 */
@Entity @Table(name="homes_reception_isp_products") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesReceptionIspProduct extends BaseEntity {
Long ispId;
/** e01 인터넷 / e02 전화 / e03 TV / e04 렌탈 */
String productType;
String name;
Boolean active;
}
/** ISP 약정 */
@Entity @Table(name="homes_reception_isp_agreements") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesReceptionIspAgreement extends BaseEntity {
Long ispId;
String name;
Boolean active;
}
@@ -0,0 +1,85 @@
package com.bizcare.agent;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.*;
import java.util.UUID;
/** 공통 파일 저장소 — billing_react FileStorageService 이식. */
@Service
class FileStorageService {
private final Path baseDir;
FileStorageService(@Value("${app.upload-dir:./storage/uploads}") String uploadDir) {
this.baseDir = Paths.get(uploadDir).toAbsolutePath().normalize();
}
@PostConstruct
void init() {
try {
Files.createDirectories(baseDir);
} catch (IOException e) {
throw new IllegalStateException("업로드 디렉터리를 생성할 수 없습니다: " + baseDir, e);
}
}
String store(String subDir, MultipartFile file) {
if (file == null || file.isEmpty()) return null;
try {
Path dir = baseDir.resolve(subDir).normalize();
if (!dir.startsWith(baseDir)) throw new IllegalArgumentException("잘못된 저장 경로입니다.");
Files.createDirectories(dir);
String original = file.getOriginalFilename() == null ? "file" : file.getOriginalFilename();
String safe = original.replaceAll("[/\\\\]", "_").replaceAll("[\\r\\n\\t]", "");
if (safe.length() > 200) {
String ext = safe.contains(".") ? safe.substring(safe.lastIndexOf('.')) : "";
safe = UUID.randomUUID().toString().replace("-", "") + ext;
}
Path target = dir.resolve(safe);
if (Files.exists(target)) {
safe = UUID.randomUUID().toString().substring(0, 8) + "_" + safe;
target = dir.resolve(safe);
}
file.transferTo(target);
return subDir + "/" + safe;
} catch (IOException e) {
throw new IllegalStateException("파일 저장 중 오류가 발생하였습니다.", e);
}
}
Resource loadAsResource(String relativePath) {
try {
Path file = baseDir.resolve(relativePath).normalize();
if (!file.startsWith(baseDir)) throw new IllegalArgumentException("잘못된 파일 경로입니다.");
Resource resource = new UrlResource(file.toUri());
if (!resource.exists() || !resource.isReadable()) {
throw new IllegalArgumentException("파일을 찾을 수 없습니다.");
}
return resource;
} catch (java.net.MalformedURLException e) {
throw new IllegalArgumentException("파일을 찾을 수 없습니다.");
}
}
void delete(String relativePath) {
if (relativePath == null || relativePath.isBlank()) return;
try {
Path file = baseDir.resolve(relativePath).normalize();
if (file.startsWith(baseDir)) Files.deleteIfExists(file);
} catch (IOException ignored) {
}
}
static String displayName(String relativePath) {
if (relativePath == null) return "";
int idx = relativePath.lastIndexOf('/');
return idx >= 0 ? relativePath.substring(idx + 1) : relativePath;
}
}
@@ -0,0 +1,14 @@
package com.bizcare.agent;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
class HealthController {
@GetMapping("/api/health")
Map<String, Object> health() {
return Map.of("status", "UP");
}
}
@@ -0,0 +1,111 @@
package com.bizcare.agent;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.*;
import lombok.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.*;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.*;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.*;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.*;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.stereotype.Component;
import org.springframework.web.cors.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.*;
@Configuration @EnableWebSecurity
class SecurityConfig {
@Value("${app.cors.origins}")
private String corsOrigins;
@Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
@Bean SecurityFilterChain filterChain(HttpSecurity http, JwtFilter filter) throws Exception {
return http.csrf(c -> c.disable())
.cors(c -> c.configurationSource(cors()))
.httpBasic(b -> b.disable())
.formLogin(f -> f.disable())
.logout(l -> l.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a
.requestMatchers(org.springframework.http.HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/auth/**", "/api/health").permitAll()
.requestMatchers("/api/agent/**").hasRole("AGENT")
.requestMatchers("/api/shop/**").hasAnyRole("SHOP", "AGENT")
.anyRequest().authenticated())
.exceptionHandling(e -> e
.authenticationEntryPoint((req, res, ex) -> {
res.setStatus(401);
res.setContentType("application/json;charset=UTF-8");
res.getWriter().write("{\"result\":false,\"msg\":\"로그인이 필요합니다.\",\"data\":null}");
})
.accessDeniedHandler((req, res, ex) -> {
res.setStatus(403);
res.setContentType("application/json;charset=UTF-8");
res.getWriter().write("{\"result\":false,\"msg\":\"권한이 없습니다.\",\"data\":null}");
}))
.addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean CorsConfigurationSource cors() {
CorsConfiguration c = new CorsConfiguration();
List<String> origins = Arrays.stream(corsOrigins.split(",")).map(String::trim).filter(s -> !s.isEmpty()).toList();
if (origins.isEmpty() || origins.contains("*")) {
c.setAllowedOriginPatterns(List.of("*"));
} else {
// 로컬/사설망 개발 접근을 허용하면서도 credentials 유지
List<String> patterns = new ArrayList<>(origins);
patterns.add("http://localhost:*");
patterns.add("http://127.0.0.1:*");
patterns.add("http://192.168.*.*:*");
patterns.add("http://10.*.*.*:*");
c.setAllowedOriginPatterns(patterns);
}
c.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
c.setAllowedHeaders(List.of("*"));
c.setExposedHeaders(List.of("*"));
c.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", c);
return source;
}
}
@Component
class JwtService {
private final byte[] secret; private final long expiration;
JwtService(@Value("${app.jwt.secret}") String secret, @Value("${app.jwt.expiration-ms}") long expiration) { this.secret=secret.getBytes(StandardCharsets.UTF_8); this.expiration=expiration; }
String create(Member m) { return Jwts.builder().subject(m.getUserid()).claim("role",m.getRole()).claim("groupId",m.getGroupId()).claim("companyId",m.getCompanyId()).issuedAt(new Date()).expiration(new Date(System.currentTimeMillis()+expiration)).signWith(Keys.hmacShaKeyFor(secret)).compact(); }
Claims claims(String token) { return Jwts.parser().verifyWith(Keys.hmacShaKeyFor(secret)).build().parseSignedClaims(token).getPayload(); }
}
@Component @RequiredArgsConstructor
class JwtFilter extends OncePerRequestFilter {
private final JwtService jwt;
@Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, jakarta.servlet.ServletException {
String header=req.getHeader(HttpHeaders.AUTHORIZATION);
if(header != null && header.startsWith("Bearer ")) try {
Claims c=jwt.claims(header.substring(7)); JwtUser user=new JwtUser(c.getSubject(), c.get("role",String.class), c.get("groupId",String.class), c.get("companyId",Long.class));
var auth=new UsernamePasswordAuthenticationToken(user,null,user.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(auth);
} catch (JwtException | IllegalArgumentException ignored) { SecurityContextHolder.clearContext(); }
chain.doFilter(req,res);
}
}
@Getter @AllArgsConstructor
class JwtUser implements UserDetails {
private String userid, role, groupId; private Long companyId;
@Override public Collection<? extends GrantedAuthority> getAuthorities() { return List.of(new SimpleGrantedAuthority("ROLE_"+role)); }
@Override public String getPassword() { return ""; } @Override public String getUsername() { return userid; }
@Override public boolean isAccountNonExpired(){return true;} @Override public boolean isAccountNonLocked(){return true;} @Override public boolean isCredentialsNonExpired(){return true;} @Override public boolean isEnabled(){return true;}
}
@@ -0,0 +1,32 @@
package com.bizcare.agent;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController @RequestMapping("/api/shop") @RequiredArgsConstructor
class ShopController {
private final CrudService crud;
@GetMapping({"/bbs","/stocks","/opens","/settlements","/fareboxes","/indocs","/returns","/scans"})
ApiResponse list(HttpServletRequest r,@AuthenticationPrincipal JwtUser u,@RequestParam Map<String,String> f,@RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="30") int size) {
Map<String,String> scoped=new HashMap<>(f); Class<? extends BaseEntity> type=type(r.getRequestURI());
if(type!=BbsPost.class && u.getCompanyId()!=null) scoped.put("outcompanyId",u.getCompanyId().toString());
return ApiResponse.ok(crud.list(type,u,scoped,page,size));
}
@GetMapping("/company") ApiResponse company(@AuthenticationPrincipal JwtUser u) {
if(u.getCompanyId()==null) return ApiResponse.fail("소속 매장이 없습니다."); return ApiResponse.ok(crud.get(Company.class,u.getCompanyId(),u));
}
@PutMapping("/company") ApiResponse companyPut(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> body) {
if(u.getCompanyId()==null) return ApiResponse.fail("소속 매장이 없습니다."); return ApiResponse.ok(crud.update(Company.class,u.getCompanyId(),body,u));
}
@PostMapping("/scans") ApiResponse scan(@AuthenticationPrincipal JwtUser u,@RequestBody Map<String,Object> body) {
body.put("outcompanyId",u.getCompanyId()); return ApiResponse.ok(crud.create(ScanDoc.class,body,u));
}
@PutMapping("/scans/{id}") ApiResponse scanPut(@AuthenticationPrincipal JwtUser u,@PathVariable Long id,@RequestBody Map<String,Object> body) { return ApiResponse.ok(crud.update(ScanDoc.class,id,body,u)); }
@SuppressWarnings("unchecked") private <T extends BaseEntity> Class<T> type(String uri) {
if(uri.contains("stocks"))return (Class<T>)Stock.class;if(uri.contains("opens"))return (Class<T>)OpenRecord.class;if(uri.contains("settlements"))return (Class<T>)SettlementMonth.class;
if(uri.contains("fareboxes"))return (Class<T>)Farebox.class;if(uri.contains("indocs"))return (Class<T>)IncompleteDoc.class;if(uri.contains("returns"))return (Class<T>)UnreturnedPhone.class;if(uri.contains("scans"))return (Class<T>)ScanDoc.class;return (Class<T>)BbsPost.class;
}
}
@@ -0,0 +1 @@
spring.application.name=agent
@@ -0,0 +1,30 @@
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}
jpa:
hibernate:
ddl-auto: update
open-in-view: false
properties:
hibernate:
format_sql: true
jackson:
time-zone: Asia/Seoul
servlet:
multipart:
max-file-size: 20MB
max-request-size: 25MB
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration
server:
port: 8080
app:
jwt:
secret: ${APP_JWT_SECRET:billcare-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}
upload-dir: ${APP_UPLOAD_DIR:./storage/uploads}
@@ -0,0 +1,13 @@
package com.bizcare.agent;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class AgentApplicationTests {
@Test
void contextLoads() {
}
}
+60
View File
@@ -0,0 +1,60 @@
services:
mysql:
image: mysql:8.0
container_name: billcare-mysql
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: billcare
MYSQL_USER: billcare
MYSQL_PASSWORD: billcare
ports:
- "3308:3306"
volumes:
- billcare_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"]
interval: 5s
timeout: 5s
retries: 20
start_period: 20s
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: billcare-backend
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: http://localhost:82,http://127.0.0.1:82,http://localhost:5174
APP_UPLOAD_DIR: /app/storage/uploads
volumes:
- billcare_uploads:/app/storage/uploads
ports:
- "8082:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/api/health"]
interval: 5s
timeout: 3s
retries: 20
start_period: 40s
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: billcare-frontend
depends_on:
backend:
condition: service_healthy
ports:
- "82:80"
volumes:
billcare_mysql:
billcare_uploads:
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.git
.DS_Store
*.log
.env*
+24
View File
@@ -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?
+8
View File
@@ -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 }]
}
}
+14
View File
@@ -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;"]
+32
View File
@@ -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.
+12
View File
@@ -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>
+28
View File
@@ -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;
}
}
+3607
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -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"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -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

+163
View File
@@ -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>
)
}
+43
View File
@@ -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)
}
+46
View File
@@ -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
+31
View File
@@ -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)
},
}
+45
View File
@@ -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)
},
}
+77
View File
@@ -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}`
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+1
View File
@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+197
View File
@@ -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>
</>
)
}
+261
View File
@@ -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>
)
}
+64
View File
@@ -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>
)
}
+8
View File
@@ -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)} />
}
+310
View File
@@ -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>
)
}
+24
View File
@@ -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>
)}
</>
)
}
+267
View File
@@ -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 &amp; 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>
)
}
+12
View File
@@ -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>
</>
)
}
+407
View File
@@ -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; }
}
+36
View File
@@ -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>,
)
+190
View File
@@ -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}/`))
}
+68
View File
@@ -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>
)
}
+20
View File
@@ -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" />}
/>
)
}
+612
View File
@@ -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>
)
}
+296
View File
@@ -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" />}
/>
)
}
+584
View File
@@ -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>
)
}
+255
View File
@@ -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>
)
}
+225
View File
@@ -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>
)
}
+318
View File
@@ -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" />}
/>
)
}
+723
View File
@@ -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>
)
}
+287
View File
@@ -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>
)
}
+342
View File
@@ -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" />}
/>
)
}
+163
View File
@@ -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>
)
}

Some files were not shown because too many files have changed in this diff Show More