BillCare 초기 소스 등록
비즈케어 홈즈 홈상품 운용관리(BillCare) 프론트/백엔드/Docker 구성을 exdev git에 등록합니다. Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -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
|
||||
@@ -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명을 분리했습니다.
|
||||
@@ -0,0 +1,6 @@
|
||||
target/
|
||||
.mvn/wrapper/maven-wrapper.jar
|
||||
.git
|
||||
.idea
|
||||
*.iml
|
||||
.DS_Store
|
||||
@@ -0,0 +1,2 @@
|
||||
/mvnw text eol=lf
|
||||
*.cmd text eol=crlf
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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 "$@"
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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:
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,32 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>빌링 케어 / 비즈케어 홈즈</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
client_max_body_size 20m;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8080/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_pass_request_headers on;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_next_upstream error timeout http_502 http_503;
|
||||
proxy_next_upstream_tries 2;
|
||||
proxy_next_upstream_timeout 10s;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@ant-design/plots": "^2.6.8",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"antd": "^6.5.0",
|
||||
"axios": "^1.18.1",
|
||||
"dayjs": "^1.11.21",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"oxlint": "^1.71.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,163 @@
|
||||
import { Navigate, Outlet, Route, Routes } from 'react-router-dom'
|
||||
import {
|
||||
App as AntApp,
|
||||
ConfigProvider,
|
||||
theme as antTheme,
|
||||
} from 'antd'
|
||||
import koKR from 'antd/locale/ko_KR'
|
||||
import AgentShell from './components/AgentShell'
|
||||
import { useTheme } from './themeContext'
|
||||
import { THEME_PRIMARY } from './theme'
|
||||
import { formRequiredMark } from './formRequiredMark'
|
||||
import './styles/app.css'
|
||||
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import HomeDashboardPage from './pages/care/HomeDashboardPage'
|
||||
import ProductInternetPage from './pages/care/ProductInternetPage'
|
||||
import ProductHomePage from './pages/care/ProductHomePage'
|
||||
import ProductLogPage from './pages/care/ProductLogPage'
|
||||
import ContractInternetPage from './pages/care/ContractInternetPage'
|
||||
import ContractHomePage from './pages/care/ContractHomePage'
|
||||
import ContractLogPage from './pages/care/ContractLogPage'
|
||||
import ContractAdjustPage from './pages/care/ContractAdjustPage'
|
||||
import ContractBalancePage from './pages/care/ContractBalancePage'
|
||||
import ContractWritePage from './pages/care/ContractWritePage'
|
||||
import ReceptionListPage from './pages/care/ReceptionListPage'
|
||||
import InvoicePublishPage from './pages/care/InvoicePublishPage'
|
||||
import InvoiceMyPage from './pages/care/InvoiceMyPage'
|
||||
import InvoiceLogPage from './pages/care/InvoiceLogPage'
|
||||
import PendingCalendarPage from './pages/care/PendingCalendarPage'
|
||||
import PendingPage from './pages/care/PendingPage'
|
||||
import PendingLogPage from './pages/care/PendingLogPage'
|
||||
import BookingPage from './pages/care/BookingPage'
|
||||
import BookingLogPage from './pages/care/BookingLogPage'
|
||||
import CustomerPage from './pages/care/CustomerPage'
|
||||
import CustomerLogPage from './pages/care/CustomerLogPage'
|
||||
import AbooksPage from './pages/care/AbooksPage'
|
||||
import AbooksLogPage from './pages/care/AbooksLogPage'
|
||||
import ReportDailyPage from './pages/care/ReportDailyPage'
|
||||
import ReportMonthlyPage from './pages/care/ReportMonthlyPage'
|
||||
import ReportInternetPage from './pages/care/ReportInternetPage'
|
||||
import ReportHomePage from './pages/care/ReportHomePage'
|
||||
import ReportMemberPage from './pages/care/ReportMemberPage'
|
||||
import ReportAgencyPage from './pages/care/ReportAgencyPage'
|
||||
import ReportIncentivePage from './pages/care/ReportIncentivePage'
|
||||
import SettingAgencyPage from './pages/care/SettingAgencyPage'
|
||||
import SettingMemberPage from './pages/care/SettingMemberPage'
|
||||
import SettingPreferencePage from './pages/care/SettingPreferencePage'
|
||||
import SettingLevelPage from './pages/care/SettingLevelPage'
|
||||
import SettingPartner1Page from './pages/care/SettingPartner1Page'
|
||||
import SettingPartner2Page from './pages/care/SettingPartner2Page'
|
||||
import CsNoticePage from './pages/care/CsNoticePage'
|
||||
import CsQuestionPage from './pages/care/CsQuestionPage'
|
||||
import BbsPage from './pages/care/BbsPage'
|
||||
import SitemapPage from './pages/care/SitemapPage'
|
||||
|
||||
function ProtectedRoute() {
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) return <Navigate to="/login" replace />
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={koKR}
|
||||
form={{ requiredMark: formRequiredMark }}
|
||||
theme={{
|
||||
algorithm: theme === 'light' ? antTheme.defaultAlgorithm : antTheme.darkAlgorithm,
|
||||
token: {
|
||||
colorPrimary: THEME_PRIMARY[theme] || '#1677ff',
|
||||
colorBgContainer: theme === 'light' ? '#ffffff' : theme === 'blue' ? '#15273d' : '#24283b',
|
||||
colorTextLightSolid: '#ffffff',
|
||||
colorBorder: theme === 'light' ? undefined : theme === 'blue' ? 'rgba(143, 187, 224, 0.55)' : 'rgba(192, 202, 245, 0.5)',
|
||||
colorBorderSecondary: theme === 'light' ? undefined : theme === 'blue' ? 'rgba(143, 187, 224, 0.35)' : 'rgba(192, 202, 245, 0.32)',
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
primaryShadow: 'none',
|
||||
defaultShadow: 'none',
|
||||
dangerShadow: 'none',
|
||||
},
|
||||
Checkbox: {
|
||||
colorBorder: theme === 'light' ? undefined : theme === 'blue' ? 'rgba(143, 187, 224, 0.65)' : 'rgba(192, 202, 245, 0.58)',
|
||||
},
|
||||
Select: {
|
||||
selectorBg: theme === 'light' ? '#ffffff' : theme === 'blue' ? '#15273d' : '#24283b',
|
||||
optionSelectedBg:
|
||||
theme === 'light' ? '#dbeafe' : theme === 'blue' ? 'rgba(30, 138, 212, 0.28)' : 'rgba(122, 162, 247, 0.22)',
|
||||
optionActiveBg:
|
||||
theme === 'light' ? '#f1f5f9' : theme === 'blue' ? 'rgba(30, 138, 212, 0.16)' : 'rgba(122, 162, 247, 0.12)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AntApp>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/care" element={<AgentShell />}>
|
||||
<Route path="main" element={<HomeDashboardPage />} />
|
||||
<Route path="main/sitemap" element={<SitemapPage />} />
|
||||
|
||||
<Route path="product/internet" element={<ProductInternetPage />} />
|
||||
<Route path="product/home" element={<ProductHomePage />} />
|
||||
<Route path="product/log" element={<ProductLogPage />} />
|
||||
|
||||
<Route path="contract/internet" element={<ContractInternetPage />} />
|
||||
<Route path="contract/home" element={<ContractHomePage />} />
|
||||
<Route path="contract/log" element={<ContractLogPage />} />
|
||||
<Route path="contract/adjust" element={<ContractAdjustPage />} />
|
||||
<Route path="contract/balance" element={<ContractBalancePage />} />
|
||||
<Route path="contract/write" element={<ContractWritePage />} />
|
||||
|
||||
<Route path="reception/list" element={<ReceptionListPage />} />
|
||||
|
||||
<Route path="invoice/publish" element={<InvoicePublishPage />} />
|
||||
<Route path="invoice/my" element={<InvoiceMyPage />} />
|
||||
<Route path="invoice/log" element={<InvoiceLogPage />} />
|
||||
|
||||
<Route path="pending/calendar" element={<PendingCalendarPage />} />
|
||||
<Route path="pending/pending" element={<PendingPage />} />
|
||||
<Route path="pending/log" element={<PendingLogPage />} />
|
||||
|
||||
<Route path="booking/booking" element={<BookingPage />} />
|
||||
<Route path="booking/log" element={<BookingLogPage />} />
|
||||
|
||||
<Route path="customer/customer" element={<CustomerPage />} />
|
||||
<Route path="customer/log" element={<CustomerLogPage />} />
|
||||
|
||||
<Route path="abooks/abooks-mon" element={<AbooksPage />} />
|
||||
<Route path="abooks/log" element={<AbooksLogPage />} />
|
||||
|
||||
<Route path="report/daily" element={<ReportDailyPage />} />
|
||||
<Route path="report/monthly" element={<ReportMonthlyPage />} />
|
||||
<Route path="report/internet" element={<ReportInternetPage />} />
|
||||
<Route path="report/home" element={<ReportHomePage />} />
|
||||
<Route path="report/member" element={<ReportMemberPage />} />
|
||||
<Route path="report/agency" element={<ReportAgencyPage />} />
|
||||
<Route path="report/incentive" element={<ReportIncentivePage />} />
|
||||
|
||||
<Route path="setting/agency" element={<SettingAgencyPage />} />
|
||||
<Route path="setting/member" element={<SettingMemberPage />} />
|
||||
<Route path="setting/preference" element={<SettingPreferencePage />} />
|
||||
<Route path="setting/level" element={<SettingLevelPage />} />
|
||||
<Route path="setting/partner1" element={<SettingPartner1Page />} />
|
||||
<Route path="setting/partner2" element={<SettingPartner2Page />} />
|
||||
|
||||
<Route path="cs/notice" element={<CsNoticePage />} />
|
||||
<Route path="cs/question" element={<CsQuestionPage />} />
|
||||
<Route path="bbs/bbs" element={<BbsPage />} />
|
||||
|
||||
<Route path="sitemap" element={<Navigate to="/care/main/sitemap" replace />} />
|
||||
<Route path="*" element={<Navigate to="/care/main" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="/" element={<Navigate to="/care/main" replace />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from './client'
|
||||
import type { User } from '../types'
|
||||
|
||||
type LoginData = {
|
||||
token: string
|
||||
role: string
|
||||
userid: string
|
||||
name: string
|
||||
groupId: string
|
||||
companyId: number | null
|
||||
staffPermission?: string
|
||||
menuFlags?: string | null
|
||||
}
|
||||
|
||||
export async function login(userid: string, password: string): Promise<User & { token: string }> {
|
||||
const { data } = await client.post<ApiResponse<LoginData>>('/auth/login', { userid, password })
|
||||
const body = unwrap(data)
|
||||
return {
|
||||
token: body.token,
|
||||
role: body.role,
|
||||
userid: body.userid,
|
||||
name: body.name,
|
||||
groupId: body.groupId,
|
||||
companyId: body.companyId,
|
||||
staffPermission: body.staffPermission,
|
||||
menuFlags: body.menuFlags,
|
||||
}
|
||||
}
|
||||
|
||||
export type MyMenusData = {
|
||||
staffPermission: string
|
||||
menus: string[]
|
||||
}
|
||||
|
||||
export async function fetchMyMenus(): Promise<MyMenusData> {
|
||||
const { data } = await client.get<ApiResponse<MyMenusData>>(`${apiPrefix()}/homes/my-menus`)
|
||||
return unwrap(data)
|
||||
}
|
||||
|
||||
export async function me(): Promise<User> {
|
||||
const { data } = await client.get<ApiResponse<User>>('/auth/me')
|
||||
return unwrap(data)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export type ApiResponse<T = unknown> = {
|
||||
result: boolean
|
||||
msg?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
const client = axios.create({ baseURL: '/api', timeout: 20_000 })
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
client.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.clear()
|
||||
if (!window.location.pathname.includes('/login')) window.location.href = '/login'
|
||||
}
|
||||
const msg = err.response?.data?.msg
|
||||
if (typeof msg === 'string' && msg && !err.message?.includes(msg)) {
|
||||
err.message = msg
|
||||
}
|
||||
return Promise.reject(err)
|
||||
},
|
||||
)
|
||||
|
||||
export function unwrap<T>(payload: ApiResponse<T> | T): T {
|
||||
if (payload && typeof payload === 'object' && 'result' in (payload as object)) {
|
||||
const r = payload as ApiResponse<T>
|
||||
if (!r.result) throw new Error(r.msg || '요청 실패')
|
||||
return r.data as T
|
||||
}
|
||||
return payload as T
|
||||
}
|
||||
|
||||
export function apiPrefix() {
|
||||
const role = localStorage.getItem('role')
|
||||
return role === 'SHOP' ? '/shop' : '/agent'
|
||||
}
|
||||
|
||||
export default client
|
||||
@@ -0,0 +1,31 @@
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from './client'
|
||||
|
||||
export type OpenRecord = Record<string, unknown> & { id: number }
|
||||
export type OpenSearch = { total: number; list: OpenRecord[]; counts: Record<string, number> }
|
||||
|
||||
export const openApi = {
|
||||
async search(params: Record<string, unknown>) {
|
||||
const { data } = await client.get<ApiResponse<OpenSearch>>(`${apiPrefix()}/opens/search`, { params })
|
||||
return unwrap(data)
|
||||
},
|
||||
async get(id: number) {
|
||||
const { data } = await client.get<ApiResponse<OpenRecord>>(`${apiPrefix()}/opens/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
async create(payload: Record<string, unknown>) {
|
||||
const { data } = await client.post<ApiResponse>(`${apiPrefix()}/opens`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
async save(id: number, payload: Record<string, unknown>) {
|
||||
const { data } = await client.put<ApiResponse>(`${apiPrefix()}/opens/${id}`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
async remove(id: number) {
|
||||
const { data } = await client.delete<ApiResponse>(`${apiPrefix()}/opens/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
async serialLookup(serial: string, gubun = '휴대폰') {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${apiPrefix()}/opens/serial-lookup`, { params: { serial, gubun } })
|
||||
return unwrap(data)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from './client'
|
||||
import type { Entity } from '../types'
|
||||
|
||||
export type PageResult<T = Entity> = { total: number; list: T[] }
|
||||
|
||||
export const resourceApi = {
|
||||
async list(path: string, params: Record<string, unknown> = {}) {
|
||||
const { data } = await client.get<ApiResponse<PageResult> | ApiResponse<Record<string, unknown>>>(
|
||||
`${apiPrefix()}/${path}`,
|
||||
{ params },
|
||||
)
|
||||
const body = unwrap(data) as PageResult | Record<string, unknown>
|
||||
if (body && typeof body === 'object' && 'list' in body) {
|
||||
const page = body as PageResult
|
||||
return { items: page.list ?? [], total: Number(page.total ?? 0) }
|
||||
}
|
||||
// dashboard / summary style
|
||||
return { items: [body as Entity], total: 1, raw: body }
|
||||
},
|
||||
|
||||
async get(path: string, params: Record<string, unknown> = {}) {
|
||||
const { data } = await client.get<ApiResponse>(`${apiPrefix()}/${path}`, { params })
|
||||
return unwrap(data)
|
||||
},
|
||||
|
||||
async create(path: string, payload: Record<string, unknown>) {
|
||||
const { data } = await client.post<ApiResponse<Entity>>(`${apiPrefix()}/${path}`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
|
||||
async update(path: string, id: number | string, payload: Record<string, unknown>) {
|
||||
const { data } = await client.put<ApiResponse<Entity>>(`${apiPrefix()}/${path}/${id}`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
|
||||
async put(path: string, payload: Record<string, unknown>, params?: Record<string, unknown>) {
|
||||
const { data } = await client.put<ApiResponse>(`${apiPrefix()}/${path}`, payload, { params })
|
||||
return unwrap(data)
|
||||
},
|
||||
|
||||
async remove(path: string, id: number) {
|
||||
const { data } = await client.delete<ApiResponse>(`${apiPrefix()}/${path}/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from './client'
|
||||
|
||||
export type Stock = Record<string, unknown> & { id: number; state: string; statusLabel: string }
|
||||
export type StockSearch = { total: number; list: Stock[]; counts: Record<string, number> }
|
||||
|
||||
export type StockHistoryItem = {
|
||||
id: number
|
||||
processDate?: string
|
||||
statusLabel?: string
|
||||
companyLabel?: string
|
||||
processor?: string
|
||||
}
|
||||
|
||||
export type StockNoteItem = {
|
||||
id: number
|
||||
stockId?: number
|
||||
contents?: string
|
||||
authorName?: string
|
||||
noteDate?: string
|
||||
}
|
||||
|
||||
export type StockDetail = {
|
||||
stock: Stock
|
||||
histories: StockHistoryItem[]
|
||||
notes: StockNoteItem[]
|
||||
}
|
||||
|
||||
export const stockApi = {
|
||||
async search(params: Record<string, unknown>) {
|
||||
const { data } = await client.get<ApiResponse<StockSearch>>(`${apiPrefix()}/stocks/search`, { params })
|
||||
return unwrap(data)
|
||||
},
|
||||
async detail(id: number) {
|
||||
const { data } = await client.get<ApiResponse<StockDetail>>(`${apiPrefix()}/stocks/${id}/detail`)
|
||||
return unwrap(data)
|
||||
},
|
||||
async save(id: number, payload: Record<string, unknown>) {
|
||||
const { data } = await client.put<ApiResponse<StockDetail>>(`${apiPrefix()}/stocks/${id}`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
async create(payload: Record<string, unknown>) {
|
||||
const { data } = await client.post<ApiResponse<StockDetail | Stock>>(`${apiPrefix()}/stocks`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
async remove(id: number) {
|
||||
const { data } = await client.delete<ApiResponse>(`${apiPrefix()}/stocks/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
async action(id: number, payload: Record<string, unknown>) {
|
||||
const { data } = await client.post<ApiResponse<StockDetail>>(`${apiPrefix()}/stocks/${id}/action`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
async addNote(id: number, payload: { contents: string; authorName?: string }) {
|
||||
const { data } = await client.post<ApiResponse<StockNoteItem>>(`${apiPrefix()}/stocks/${id}/notes`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
async deleteNote(id: number, noteId: number) {
|
||||
const { data } = await client.delete<ApiResponse>(`${apiPrefix()}/stocks/${id}/notes/${noteId}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
async bulkIn(payload: Record<string, unknown>) {
|
||||
const { data } = await client.post<ApiResponse>(`${apiPrefix()}/stocks/bulk-in`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
async bulkAction(ids: number[], state: string) {
|
||||
const { data } = await client.post<ApiResponse>(`${apiPrefix()}/stocks/bulk-action`, { ids, state })
|
||||
return unwrap(data)
|
||||
},
|
||||
async sheet(params: Record<string, unknown>) {
|
||||
const { data } = await client.get<ApiResponse<StockSearch>>(`${apiPrefix()}/stocks/sheet`, { params })
|
||||
return unwrap(data)
|
||||
},
|
||||
exportUrl(params: Record<string, unknown>) {
|
||||
const query = new URLSearchParams(Object.entries(params).filter(([, value]) => value !== undefined && value !== '').map(([key, value]) => [key, String(value)]))
|
||||
return `${apiPrefix()}/stocks/export?${query}`
|
||||
},
|
||||
}
|
||||
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Form, Input, Popover, message } from 'antd'
|
||||
import { LockOutlined, PoweroffOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../api/client'
|
||||
import Modal from './DraggableModal'
|
||||
|
||||
export type AccountProfile = {
|
||||
name: string
|
||||
userid: string
|
||||
staffPermission: string
|
||||
orgLabel: string
|
||||
memberCount: number
|
||||
expireDate: string
|
||||
remainDays: number
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
function formatExpireKo(iso: string) {
|
||||
const [y, m, d] = iso.split('-')
|
||||
if (!y || !m || !d) return iso
|
||||
return `${y}년 ${Number(m)}월 ${Number(d)}일`
|
||||
}
|
||||
|
||||
export default function AccountPopover({ onLogout }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [profile, setProfile] = useState<AccountProfile | null>(null)
|
||||
const [pwOpen, setPwOpen] = useState(false)
|
||||
const [pwLoading, setPwLoading] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
client
|
||||
.get<ApiResponse<AccountProfile>>(`${apiPrefix()}/homes/account-profile`)
|
||||
.then(({ data }) => {
|
||||
if (!cancelled) setProfile(unwrap(data))
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setProfile({
|
||||
name: localStorage.getItem('userName') || '사용자',
|
||||
userid: localStorage.getItem('userid') || '',
|
||||
staffPermission: localStorage.getItem('staffPermission') || '',
|
||||
orgLabel: '데모',
|
||||
memberCount: 0,
|
||||
expireDate: '2030-12-31',
|
||||
remainDays: 0,
|
||||
})
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [open])
|
||||
|
||||
const openPassword = () => {
|
||||
setOpen(false)
|
||||
form.resetFields()
|
||||
setPwOpen(true)
|
||||
}
|
||||
|
||||
const submitPassword = async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setPwLoading(true)
|
||||
const { data } = await client.put<ApiResponse>(`${apiPrefix()}/homes/password`, {
|
||||
currentPassword: values.currentPassword,
|
||||
newPassword: values.newPassword,
|
||||
})
|
||||
unwrap(data)
|
||||
message.success('비밀번호를 변경했습니다.')
|
||||
setPwOpen(false)
|
||||
form.resetFields()
|
||||
} catch (e) {
|
||||
if (e && typeof e === 'object' && 'errorFields' in e) return
|
||||
message.error(e instanceof Error ? e.message : '비밀번호 변경에 실패했습니다.')
|
||||
} finally {
|
||||
setPwLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const p = profile
|
||||
const remain = p?.remainDays ?? 0
|
||||
const remainText = remain >= 0 ? `${remain}일 남았습니다.` : `${Math.abs(remain)}일 지났습니다.`
|
||||
|
||||
const content = (
|
||||
<div className="account-popover">
|
||||
<div className="account-popover-head">
|
||||
<div className="account-popover-name">{p?.name || '…'}</div>
|
||||
<div className="account-popover-sub">
|
||||
{p ? `${p.orgLabel} / ${p.staffPermission}` : '…'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="account-popover-meta">
|
||||
<div className="account-popover-row">
|
||||
<span className="account-popover-label">계정</span>
|
||||
<span className="account-popover-value">{p?.userid || '…'}</span>
|
||||
</div>
|
||||
<div className="account-popover-row">
|
||||
<span className="account-popover-label">직원</span>
|
||||
<span className="account-popover-value">{p ? `${p.memberCount}명` : '…'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="account-popover-expire">
|
||||
<div className="account-popover-expire-title">이용만료일</div>
|
||||
<div className="account-popover-expire-date">
|
||||
{p ? formatExpireKo(p.expireDate) : '…'}
|
||||
</div>
|
||||
<div className="account-popover-expire-remain">{p ? remainText : ''}</div>
|
||||
</div>
|
||||
<div className="account-popover-actions">
|
||||
<button type="button" className="account-popover-action" onClick={openPassword}>
|
||||
<LockOutlined />
|
||||
<span>비밀번호변경</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="account-popover-action"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onLogout()
|
||||
}}
|
||||
>
|
||||
<PoweroffOutlined />
|
||||
<span>로그아웃</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
trigger="click"
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottomRight"
|
||||
arrow={false}
|
||||
overlayClassName="account-popover-overlay"
|
||||
content={content}
|
||||
>
|
||||
<button type="button" className={`header-icon-btn${open ? ' active' : ''}`} aria-label="프로필">
|
||||
<UserOutlined />
|
||||
</button>
|
||||
</Popover>
|
||||
|
||||
<Modal
|
||||
title="비밀번호변경"
|
||||
open={pwOpen}
|
||||
onCancel={() => setPwOpen(false)}
|
||||
onOk={() => void submitPassword()}
|
||||
okText="변경"
|
||||
cancelText="취소"
|
||||
confirmLoading={pwLoading}
|
||||
destroyOnClose
|
||||
width={400}
|
||||
>
|
||||
<Form form={form} layout="vertical" className="account-pw-form" requiredMark={false}>
|
||||
<Form.Item
|
||||
name="currentPassword"
|
||||
label="현재 비밀번호"
|
||||
rules={[{ required: true, message: '현재 비밀번호를 입력하세요.' }]}
|
||||
>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="newPassword"
|
||||
label="새 비밀번호"
|
||||
rules={[
|
||||
{ required: true, message: '새 비밀번호를 입력하세요.' },
|
||||
{ pattern: /^[A-Za-z0-9]{4,20}$/, message: '영문, 숫자 4~20자입니다.' },
|
||||
]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
label="새 비밀번호 확인"
|
||||
dependencies={['newPassword']}
|
||||
rules={[
|
||||
{ required: true, message: '새 비밀번호 확인을 입력하세요.' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('newPassword') === value) return Promise.resolve()
|
||||
return Promise.reject(new Error('새 비밀번호 확인이 일치하지 않습니다.'))
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
AppstoreOutlined, CalendarOutlined, ShoppingCartOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import SchedulePanel from './SchedulePanel'
|
||||
import BillCareLogo from './BillCareLogo'
|
||||
import AccountPopover from './AccountPopover'
|
||||
import { careMenuSections, filterMenuSections, resolveSelectedKey, sectionContainsPath, shopMenuSections } from '../menu'
|
||||
import { fetchMyMenus } from '../api/auth'
|
||||
import { THEMES } from '../theme'
|
||||
import { useTheme } from '../themeContext'
|
||||
|
||||
const PANEL_MIN = 170
|
||||
const PANEL_MAX = 420
|
||||
|
||||
type Props = {
|
||||
shop?: boolean
|
||||
}
|
||||
|
||||
export default function AgentShell({ shop = false }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { theme, changeTheme } = useTheme()
|
||||
const [scheduleOpen, setScheduleOpen] = useState(false)
|
||||
const userName = localStorage.getItem('userName') || (shop ? '판매점' : '관리자')
|
||||
const [allowedMenus, setAllowedMenus] = useState<string[] | null>(() => {
|
||||
if (shop) return null
|
||||
try {
|
||||
const cached = localStorage.getItem('allowedMenus')
|
||||
return cached ? (JSON.parse(cached) as string[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const [panelWidth, setPanelWidth] = useState(() => {
|
||||
const v = Number(localStorage.getItem('lp.width'))
|
||||
return v >= PANEL_MIN && v <= PANEL_MAX ? v : 220
|
||||
})
|
||||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('lp.collapsed') === '1')
|
||||
const draggingRef = useRef(false)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (shop) {
|
||||
setAllowedMenus(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
fetchMyMenus()
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
if (res.staffPermission) localStorage.setItem('staffPermission', res.staffPermission)
|
||||
setAllowedMenus(res.menus || [])
|
||||
try {
|
||||
localStorage.setItem('allowedMenus', JSON.stringify(res.menus || []))
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
try {
|
||||
const cached = localStorage.getItem('allowedMenus')
|
||||
if (cached) setAllowedMenus(JSON.parse(cached) as string[])
|
||||
else setAllowedMenus(['home'])
|
||||
} catch {
|
||||
setAllowedMenus(['home'])
|
||||
}
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [shop])
|
||||
|
||||
const allSections = useMemo(() => (shop ? shopMenuSections() : careMenuSections()), [shop])
|
||||
const sections = useMemo(
|
||||
() => (shop ? allSections : filterMenuSections(allSections, allowedMenus)),
|
||||
[shop, allSections, allowedMenus],
|
||||
)
|
||||
const selectedKey = resolveSelectedKey(location.pathname)
|
||||
|
||||
const [openCards, setOpenCards] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
setOpenCards((prev) => {
|
||||
const next = { ...prev }
|
||||
sections.forEach((sec) => {
|
||||
if (next[sec.id] === undefined) {
|
||||
next[sec.id] = sectionContainsPath(sec, location.pathname) || sec.items.length <= 1
|
||||
}
|
||||
if (sectionContainsPath(sec, location.pathname)) next[sec.id] = true
|
||||
})
|
||||
return next
|
||||
})
|
||||
}, [location.pathname, sections])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('lp.width', String(panelWidth))
|
||||
}, [panelWidth])
|
||||
useEffect(() => {
|
||||
localStorage.setItem('lp.collapsed', collapsed ? '1' : '0')
|
||||
}, [collapsed])
|
||||
|
||||
const startResize = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
draggingRef.current = true
|
||||
setDragging(true)
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'col-resize'
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!draggingRef.current) return
|
||||
setPanelWidth(Math.min(PANEL_MAX, Math.max(PANEL_MIN, e.clientX)))
|
||||
}
|
||||
const onUp = () => {
|
||||
if (!draggingRef.current) return
|
||||
draggingRef.current = false
|
||||
setDragging(false)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const logout = () => {
|
||||
localStorage.clear()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const toggleCard = (id: string) => {
|
||||
setOpenCards((prev) => ({ ...prev, [id]: !prev[id] }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
<div className={`topbar-brand${collapsed ? ' is-collapsed' : ''}`} style={collapsed ? undefined : { width: panelWidth }}>
|
||||
{!collapsed && (
|
||||
<div className="brand-inline">
|
||||
<BillCareLogo title="BillCare" size="header" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="topbar-main">
|
||||
<div className="topbar-left-tools">
|
||||
{shop ? (
|
||||
<span className="topbar-user muted">에이전트 데모 / {userName}</span>
|
||||
) : (
|
||||
<div className="agent-header-tools">
|
||||
<strong>[ BillCare ]</strong>
|
||||
<span className="header-divider" />
|
||||
<ShoppingCartOutlined />
|
||||
<span>문자발송(장바구니)</span>
|
||||
<span className="sms-remain">잔여SMS <b>996</b>건</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="topbar-right">
|
||||
{!shop && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`header-icon-btn${location.pathname.includes('/sitemap') ? ' active' : ''}`}
|
||||
aria-label="전체보기"
|
||||
title="전체보기"
|
||||
onClick={() => navigate('/care/main/sitemap')}
|
||||
>
|
||||
<AppstoreOutlined />
|
||||
</button>
|
||||
<AccountPopover onLogout={logout} />
|
||||
<button
|
||||
type="button"
|
||||
className={`header-icon-btn${scheduleOpen ? ' active' : ''}`}
|
||||
aria-label="일정표"
|
||||
onClick={() => setScheduleOpen((v) => !v)}
|
||||
>
|
||||
<CalendarOutlined />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<select
|
||||
className="theme-select"
|
||||
value={theme}
|
||||
onChange={(e) => changeTheme(e.target.value as typeof theme)}
|
||||
title="테마 선택"
|
||||
>
|
||||
{THEMES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="app-body">
|
||||
{!collapsed && (
|
||||
<aside className="leftpanel" style={{ width: panelWidth }}>
|
||||
{sections.map((sec) => {
|
||||
const open = openCards[sec.id] !== false
|
||||
const activeSec = sectionContainsPath(sec, location.pathname)
|
||||
return (
|
||||
<div className={`lp-card${activeSec ? ' is-active' : ''}`} key={sec.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="lp-card-title"
|
||||
onClick={() => toggleCard(sec.id)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span>{sec.title}</span>
|
||||
<span className="lp-card-chevron">{open ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="lp-card-body">
|
||||
{sec.items.map((it) => (
|
||||
<NavLink
|
||||
key={it.key}
|
||||
to={it.key}
|
||||
title={it.label}
|
||||
className={({ isActive }) => (isActive || selectedKey === it.key ? 'active' : undefined)}
|
||||
>
|
||||
{it.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{!collapsed && (
|
||||
<div className={`panel-resizer${dragging ? ' dragging' : ''}`} onMouseDown={startResize} />
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="panel-toggle"
|
||||
style={{ left: collapsed ? 0 : panelWidth }}
|
||||
title={collapsed ? '패널 펼치기' : '패널 접기'}
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
>
|
||||
{collapsed ? '›' : '‹'}
|
||||
</button>
|
||||
|
||||
<div className="main-area">
|
||||
<div className="content">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!shop && <SchedulePanel open={scheduleOpen} onClose={() => setScheduleOpen(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import logoImage from '../assets/logo_image.png'
|
||||
import logoMark from '../assets/logo_mark_light.png'
|
||||
import { useTheme } from '../themeContext'
|
||||
import type { Theme } from '../theme'
|
||||
|
||||
interface BillCareLogoProps {
|
||||
className?: string
|
||||
title?: string
|
||||
size?: 'header' | 'login'
|
||||
forceTheme?: Theme
|
||||
}
|
||||
|
||||
function isDarkTheme(theme: Theme) {
|
||||
return theme === 'dark' || theme === 'blue'
|
||||
}
|
||||
|
||||
/**
|
||||
* logo_image.png — 라이트 헤더 가로 로고
|
||||
* logo_mark_light.png / logo_image_theme.png — 마크 (로그인·다크 헤더)
|
||||
*/
|
||||
export default function BillCareLogo({
|
||||
className = '',
|
||||
title = 'BillCare',
|
||||
size = 'header',
|
||||
forceTheme,
|
||||
}: BillCareLogoProps) {
|
||||
const { theme } = useTheme()
|
||||
const active = forceTheme ?? theme
|
||||
const dark = isDarkTheme(active)
|
||||
const root = ['billcare-logo', `billcare-logo--${size}`, `billcare-logo--${active}`, className]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
// 로그인: 항상 마크 + BillCare (색상은 CSS로 고정)
|
||||
if (size === 'login') {
|
||||
return (
|
||||
<div className={root} title={title}>
|
||||
<img src={logoMark} alt="" className="billcare-logo-mark" draggable={false} />
|
||||
<span className="billcare-logo-title">{title}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 라이트 헤더: 가로 로고
|
||||
if (!dark) {
|
||||
return (
|
||||
<img
|
||||
src={logoImage}
|
||||
alt={title}
|
||||
title={title}
|
||||
draggable={false}
|
||||
className={root}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 다크/블루 헤더: 마크 + BillCare
|
||||
return (
|
||||
<div className={root} title={title}>
|
||||
<img src={logoMark} alt="" className="billcare-logo-mark" draggable={false} />
|
||||
<span className="billcare-logo-title">{title}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Table, type TableProps } from 'antd'
|
||||
import { normalizeTableColumns } from '../tableAlign'
|
||||
|
||||
/** Ant Design Table 래퍼 — 목록 정렬 규칙(헤더/텍스트 중앙, 숫자·금액 우측) 적용 */
|
||||
export default function CareTable<RecordType extends object>(props: TableProps<RecordType>) {
|
||||
const { columns, ...rest } = props
|
||||
return <Table<RecordType> {...rest} columns={normalizeTableColumns(columns)} />
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { Modal as AntModal, type ModalFuncProps, type ModalProps } from 'antd'
|
||||
|
||||
export type DraggableModalProps = ModalProps & {
|
||||
/** 타이틀바 우측(닫기 앞) 추가 액션 */
|
||||
titleActions?: ReactNode
|
||||
/** 타이틀바 드래그 (기본 true) */
|
||||
draggable?: boolean
|
||||
}
|
||||
|
||||
type DragState = {
|
||||
startX: number
|
||||
startY: number
|
||||
origX: number
|
||||
origY: number
|
||||
}
|
||||
|
||||
function useDraggableOffset(open?: boolean) {
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 })
|
||||
const offsetRef = useRef(offset)
|
||||
const dragRef = useRef<DragState | null>(null)
|
||||
/** 드래그 후 mask 합성 click 으로 닫히는 것 방지 */
|
||||
const suppressCloseUntilRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
offsetRef.current = offset
|
||||
}, [offset])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setOffset({ x: 0, y: 0 })
|
||||
offsetRef.current = { x: 0, y: 0 }
|
||||
dragRef.current = null
|
||||
suppressCloseUntilRef.current = 0
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const onTitleMouseDown = useCallback((e: ReactMouseEvent) => {
|
||||
if (e.button !== 0) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (target?.closest('button, a, input, select, textarea, .app-modal-close, .ant-btn')) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const current = offsetRef.current
|
||||
dragRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origX: current.x,
|
||||
origY: current.y,
|
||||
}
|
||||
let moved = false
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const d = dragRef.current
|
||||
if (!d) return
|
||||
const dx = ev.clientX - d.startX
|
||||
const dy = ev.clientY - d.startY
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) moved = true
|
||||
setOffset({ x: d.origX + dx, y: d.origY + dy })
|
||||
}
|
||||
|
||||
const blockSyntheticClick = (ev: MouseEvent) => {
|
||||
if (Date.now() < suppressCloseUntilRef.current) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
dragRef.current = null
|
||||
// mousedown(헤더) → mouseup(마스크) 시 wrap 에 click 이 가서 닫히는 문제 방지
|
||||
suppressCloseUntilRef.current = Date.now() + (moved ? 300 : 120)
|
||||
window.removeEventListener('mousemove', onMove, true)
|
||||
window.removeEventListener('mouseup', onUp, true)
|
||||
window.setTimeout(() => {
|
||||
window.removeEventListener('click', blockSyntheticClick, true)
|
||||
}, 320)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMove, true)
|
||||
window.addEventListener('mouseup', onUp, true)
|
||||
window.addEventListener('click', blockSyntheticClick, true)
|
||||
}, [])
|
||||
|
||||
const shouldSuppressClose = useCallback(() => Date.now() < suppressCloseUntilRef.current, [])
|
||||
|
||||
return { offset, onTitleMouseDown, shouldSuppressClose }
|
||||
}
|
||||
|
||||
function withModalClass(className?: string) {
|
||||
return ['app-draggable-modal', className].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function TitleBar({
|
||||
title,
|
||||
titleActions,
|
||||
draggable,
|
||||
showClose,
|
||||
onClose,
|
||||
onMouseDown,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
titleActions?: ReactNode
|
||||
draggable: boolean
|
||||
showClose: boolean
|
||||
onClose?: (e: ReactMouseEvent<HTMLButtonElement>) => void
|
||||
onMouseDown: (e: ReactMouseEvent) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`app-modal-titlebar${draggable ? ' is-draggable' : ''}`}
|
||||
onMouseDown={draggable ? onMouseDown : undefined}
|
||||
>
|
||||
<div className="app-modal-title">{title}</div>
|
||||
<div className="app-modal-title-right">
|
||||
{titleActions}
|
||||
{showClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="app-modal-close"
|
||||
onClick={onClose}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
aria-label="닫기"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DraggableModal({
|
||||
className,
|
||||
rootClassName,
|
||||
modalRender,
|
||||
open,
|
||||
title,
|
||||
titleActions,
|
||||
draggable = true,
|
||||
centered = false,
|
||||
closable = true,
|
||||
onCancel,
|
||||
classNames,
|
||||
styles,
|
||||
...rest
|
||||
}: DraggableModalProps) {
|
||||
const { offset, onTitleMouseDown, shouldSuppressClose } = useDraggableOffset(open)
|
||||
const showClose = closable !== false
|
||||
|
||||
const handleCancel: ModalProps['onCancel'] = (e) => {
|
||||
if (shouldSuppressClose()) return
|
||||
onCancel?.(e)
|
||||
}
|
||||
|
||||
const baseStyles = {
|
||||
container: {
|
||||
padding: 0,
|
||||
overflow: 'hidden' as const,
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
boxShadow: 'var(--shadow)',
|
||||
},
|
||||
header: {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
background: 'transparent',
|
||||
borderBottom: 'none',
|
||||
},
|
||||
body: {
|
||||
padding: '16px 18px 18px',
|
||||
background: 'var(--surface)',
|
||||
color: 'var(--text)',
|
||||
},
|
||||
footer: {
|
||||
margin: 0,
|
||||
padding: '12px 16px 14px',
|
||||
borderTop: '1px solid var(--border)',
|
||||
background: 'var(--surface)',
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<AntModal
|
||||
{...rest}
|
||||
open={open}
|
||||
centered={centered}
|
||||
closable={false}
|
||||
onCancel={handleCancel}
|
||||
className={withModalClass(className)}
|
||||
rootClassName={['app-draggable-modal-root', rootClassName].filter(Boolean).join(' ')}
|
||||
classNames={classNames}
|
||||
styles={baseStyles}
|
||||
title={(
|
||||
<TitleBar
|
||||
title={title}
|
||||
titleActions={titleActions}
|
||||
draggable={draggable}
|
||||
showClose={showClose}
|
||||
onClose={(e) => onCancel?.(e)}
|
||||
onMouseDown={onTitleMouseDown}
|
||||
/>
|
||||
)}
|
||||
modalRender={(node) => {
|
||||
const wrapped = (
|
||||
<div
|
||||
className="app-modal-drag-root"
|
||||
style={{ transform: `translate(${offset.x}px, ${offset.y}px)` }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{node}
|
||||
</div>
|
||||
)
|
||||
return modalRender ? modalRender(wrapped) : wrapped
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function StaticDragShell({
|
||||
title,
|
||||
children,
|
||||
onTitleMouseDown,
|
||||
offset,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
children: ReactNode
|
||||
onTitleMouseDown: (e: ReactMouseEvent) => void
|
||||
offset: { x: number; y: number }
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="app-modal-drag-root"
|
||||
style={{ transform: `translate(${offset.x}px, ${offset.y}px)` }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="app-modal-static-panel">
|
||||
<TitleBar
|
||||
title={title}
|
||||
draggable
|
||||
showClose={false}
|
||||
onMouseDown={onTitleMouseDown}
|
||||
/>
|
||||
<div className="app-modal-slot">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StaticDragWrap({ title, children }: { title?: ReactNode; children: ReactNode }) {
|
||||
const [open] = useState(true)
|
||||
const { offset, onTitleMouseDown, shouldSuppressClose } = useDraggableOffset(open)
|
||||
|
||||
useEffect(() => {
|
||||
const block = (ev: MouseEvent) => {
|
||||
if (shouldSuppressClose()) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
}
|
||||
}
|
||||
window.addEventListener('click', block, true)
|
||||
return () => window.removeEventListener('click', block, true)
|
||||
}, [shouldSuppressClose])
|
||||
|
||||
return (
|
||||
<StaticDragShell title={title} offset={offset} onTitleMouseDown={onTitleMouseDown}>
|
||||
{children}
|
||||
</StaticDragShell>
|
||||
)
|
||||
}
|
||||
|
||||
function wrapStatic(config: ModalFuncProps = {}): ModalFuncProps {
|
||||
const userRender = config.modalRender
|
||||
const title = config.title
|
||||
return {
|
||||
...config,
|
||||
title: null,
|
||||
closable: false,
|
||||
centered: config.centered ?? false,
|
||||
className: withModalClass(config.className),
|
||||
rootClassName: ['app-draggable-modal-root', config.rootClassName].filter(Boolean).join(' '),
|
||||
modalRender: (node) => {
|
||||
const wrapped = <StaticDragWrap title={title}>{node}</StaticDragWrap>
|
||||
return userRender ? userRender(wrapped) : wrapped
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
DraggableModal.confirm = (props?: ModalFuncProps) => AntModal.confirm(wrapStatic(props))
|
||||
DraggableModal.info = (props?: ModalFuncProps) => AntModal.info(wrapStatic(props))
|
||||
DraggableModal.success = (props?: ModalFuncProps) => AntModal.success(wrapStatic(props))
|
||||
DraggableModal.error = (props?: ModalFuncProps) => AntModal.error(wrapStatic(props))
|
||||
DraggableModal.warning = (props?: ModalFuncProps) => AntModal.warning(wrapStatic(props))
|
||||
DraggableModal.warn = (props?: ModalFuncProps) => AntModal.warn(wrapStatic(props))
|
||||
DraggableModal.destroyAll = AntModal.destroyAll
|
||||
DraggableModal.useModal = AntModal.useModal
|
||||
DraggableModal.config = AntModal.config
|
||||
|
||||
export default DraggableModal
|
||||
export type { ModalProps }
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** 카테고리 섹션 (billing_react OrderFormLayout 이식) */
|
||||
export function FormSection({
|
||||
title,
|
||||
desc,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
desc?: string
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="popup-section of-section">
|
||||
<div className="of-section-head">
|
||||
<div className="of-section-titles">
|
||||
<h3 className="of-section-title">{title}</h3>
|
||||
{desc ? <p className="of-section-desc">{desc}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="of-section-actions">{actions}</div> : null}
|
||||
</div>
|
||||
<div className="of-section-body">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function FormGroup({ label, children }: { label?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="of-group">
|
||||
{label ? <div className="of-group-label">{label}</div> : null}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FormGrid({ children, cols = 2 }: { children: ReactNode; cols?: 1 | 2 }) {
|
||||
return <div className={`of-grid of-grid-${cols}`}>{children}</div>
|
||||
}
|
||||
|
||||
export function FormField({
|
||||
label,
|
||||
hint,
|
||||
full,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
hint?: string
|
||||
full?: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={`of-field${full ? ' of-field-full' : ''}`}>
|
||||
<div className="of-field-meta">
|
||||
<span className="of-field-label">{label}</span>
|
||||
{hint ? <span className="of-field-hint">{hint}</span> : null}
|
||||
</div>
|
||||
<div className="of-field-control">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FormChoiceRow({ children }: { children: ReactNode }) {
|
||||
return <div className="of-choice-row">{children}</div>
|
||||
}
|
||||
|
||||
export function FormChoice({ children }: { children: ReactNode }) {
|
||||
return <label className="of-choice">{children}</label>
|
||||
}
|
||||
|
||||
export function FormBlock({
|
||||
title,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title?: string
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="of-block">
|
||||
{(title || actions) && (
|
||||
<div className="of-block-head">
|
||||
{title ? <span className="of-block-title">{title}</span> : null}
|
||||
{actions ? <div className="of-block-actions">{actions}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** 업무진행 팝업 섹션 카드 (billing_react PrgSection) */
|
||||
export default function PrgSection({
|
||||
title,
|
||||
extra,
|
||||
children,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
extra?: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="popup-section prg-section">
|
||||
{(title || extra) && (
|
||||
<div className="prg-section-head">
|
||||
<h3 className="prg-section-title">{title}</h3>
|
||||
{extra ? <div className="prg-section-actions">{extra}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
<div className="prg-section-body">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Select,
|
||||
message,
|
||||
} from 'antd'
|
||||
import Modal from './DraggableModal'
|
||||
import {
|
||||
FormBlock,
|
||||
FormField,
|
||||
FormGrid,
|
||||
FormGroup,
|
||||
FormSection,
|
||||
} from './OrderFormLayout'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../api/client'
|
||||
import { options } from '../pages/care/homesShared'
|
||||
|
||||
type Isp = { id: number; name: string; telecom: string; isSktb?: boolean }
|
||||
type Named = { id: number; name: string; type?: string }
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
isp: Isp | null
|
||||
counselorOptions?: string[]
|
||||
branchOptions?: string[]
|
||||
onCancel: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const PRODUCT_TYPES = [
|
||||
{ value: 'e01', label: '인터넷' },
|
||||
{ value: 'e02', label: '전화' },
|
||||
{ value: 'e03', label: 'TV' },
|
||||
{ value: 'e04', label: '렌탈' },
|
||||
]
|
||||
|
||||
const GIFT_TYPES = [
|
||||
{ value: 'a01', label: '없음' },
|
||||
{ value: 'a02', label: '현금' },
|
||||
{ value: 'a03', label: '상품' },
|
||||
{ value: 'a04', label: '현금+상품' },
|
||||
]
|
||||
|
||||
const BANKS = [
|
||||
'국민은행', '우리은행', '신한은행', '하나은행', '기업은행', '농협',
|
||||
'SC제일', '카카오뱅크', '토스뱅크', '새마을금고', '우체국', '기타',
|
||||
]
|
||||
|
||||
const CARD_COMPANIES = [
|
||||
'삼성카드', '신한카드', '현대카드', 'KB국민카드', '롯데카드',
|
||||
'우리카드', '하나카드', 'BC카드', 'NH농협카드', '기타',
|
||||
]
|
||||
|
||||
const CARD_YEARS = Array.from({ length: 31 }, (_, i) => String(i).padStart(2, '0'))
|
||||
const CARD_MONTHS = Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, '0'))
|
||||
|
||||
const DISCOUNTS = [
|
||||
{ value: 'none', label: '해당없음' },
|
||||
{ value: '국가유공자', label: '국가유공자(30% 할인)' },
|
||||
{ value: '장애인', label: '장애인(30% 할인)' },
|
||||
]
|
||||
|
||||
export default function ReceptionApplyModal({
|
||||
open,
|
||||
isp,
|
||||
counselorOptions = [],
|
||||
branchOptions = [],
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}: Props) {
|
||||
const [form] = Form.useForm()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [createFiles, setCreateFiles] = useState<Record<string, File | null>>({})
|
||||
const ispId = isp?.id
|
||||
const staffPermission = localStorage.getItem('staffPermission') || ''
|
||||
const isAdmin = ['대표', '총괄', '팀장', '개발자'].includes(staffPermission)
|
||||
|
||||
const productsQuery = useQuery({
|
||||
queryKey: ['reception-isp-products', ispId],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Named[]>>(`${apiPrefix()}/homes/reception-isps/${ispId}/products`)
|
||||
return unwrap(data)
|
||||
},
|
||||
enabled: open && ispId != null,
|
||||
})
|
||||
|
||||
const agreementsQuery = useQuery({
|
||||
queryKey: ['reception-isp-agreements', ispId],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Named[]>>(`${apiPrefix()}/homes/reception-isps/${ispId}/agreements`)
|
||||
return unwrap(data)
|
||||
},
|
||||
enabled: open && ispId != null,
|
||||
})
|
||||
|
||||
const customerType = Form.useWatch('customerType', form) || 'public'
|
||||
const paymentMethod = Form.useWatch('paymentMethod', form) || 'transfer'
|
||||
const productType = Form.useWatch('productType', form)
|
||||
const giftType = Form.useWatch('giftType', form) || 'a01'
|
||||
const isIssueDate = Form.useWatch('isIssueDate', form) ?? '1'
|
||||
const sameCustomerToPayment = Form.useWatch('sameCustomerToPayment', form)
|
||||
const sameCustomerToGift = Form.useWatch('sameCustomerToGift', form)
|
||||
const samePaymentToGift = Form.useWatch('samePaymentToGift', form)
|
||||
const watchName = Form.useWatch('name', form)
|
||||
const watchSsid = Form.useWatch('ssid', form)
|
||||
const watchPhone = Form.useWatch('phone', form)
|
||||
const watchTel = Form.useWatch('tel', form)
|
||||
const watchBank = Form.useWatch('bank', form)
|
||||
const watchAccountNumber = Form.useWatch('accountNumber', form)
|
||||
const watchPaymentName = Form.useWatch('paymentName', form)
|
||||
const watchPaymentSsid = Form.useWatch('paymentSsid', form)
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
const list = productsQuery.data || []
|
||||
if (!productType) return list
|
||||
return list.filter((p) => !p.type || p.type === productType)
|
||||
}, [productsQuery.data, productType])
|
||||
|
||||
const giftCash = giftType === 'a02' || giftType === 'a04'
|
||||
const giftGoods = giftType === 'a03' || giftType === 'a04'
|
||||
|
||||
const branchEmployeeOptions = useMemo(() => {
|
||||
const counselors = counselorOptions.length ? counselorOptions : ['홍길동', '김상담', '이상담', '정상담']
|
||||
const branches = branchOptions.length ? branchOptions : ['강남점', '서초점', '송파점']
|
||||
const out: { value: string; label: string }[] = []
|
||||
for (const b of branches) {
|
||||
for (const c of counselors) {
|
||||
out.push({ value: `${b}|${c}`, label: `[${b}] ${c}` })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [counselorOptions, branchOptions])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !isp) return
|
||||
setCreateFiles({})
|
||||
const rental = isp.telecom === 'SK매직' || isp.telecom === 'LG렌탈'
|
||||
const firstOpt = branchEmployeeOptions[0]?.value
|
||||
form.setFieldsValue({
|
||||
branchEmployee: firstOpt,
|
||||
customerType: 'public',
|
||||
paymentMethod: 'transfer',
|
||||
productType: rental ? 'e04' : 'e01',
|
||||
productId: undefined,
|
||||
agreementId: undefined,
|
||||
price: rental ? 120000 : 80000,
|
||||
installDate: dayjs(),
|
||||
discount: 'none',
|
||||
giftType: 'a01',
|
||||
giftPlace: 'b01',
|
||||
giftDate: dayjs(),
|
||||
cashAmount: 0,
|
||||
goods: '',
|
||||
goodsPrice: 0,
|
||||
name: '',
|
||||
postIt: '',
|
||||
postAdd: '',
|
||||
ssid: '',
|
||||
isIssueDate: '1',
|
||||
issueDate: undefined,
|
||||
driversLicenseNumber: '',
|
||||
phone: '',
|
||||
tel: '',
|
||||
email: '',
|
||||
zipcode: '',
|
||||
address: '',
|
||||
addressDetail: '',
|
||||
blName: '',
|
||||
blNumber: '',
|
||||
blCorpNumber: '',
|
||||
blType: '',
|
||||
blItem: '',
|
||||
blZipcode: '',
|
||||
blAddress: '',
|
||||
blAddressDetail: '',
|
||||
paymentName: '',
|
||||
paymentSsid: '',
|
||||
paymentTel: '',
|
||||
bank: undefined,
|
||||
accountNumber: '',
|
||||
cardCompany: undefined,
|
||||
cardYear: undefined,
|
||||
cardMonth: undefined,
|
||||
cardNumber: '',
|
||||
giftBank: undefined,
|
||||
giftAccountNumber: '',
|
||||
giftAccountName: '',
|
||||
giftSsid: '',
|
||||
sameCustomerToPayment: false,
|
||||
sameCustomerToGift: false,
|
||||
samePaymentToGift: false,
|
||||
memo: '',
|
||||
})
|
||||
}, [open, isp, form, branchEmployeeOptions])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sameCustomerToPayment) return
|
||||
form.setFieldsValue({
|
||||
paymentName: watchName,
|
||||
paymentSsid: watchSsid,
|
||||
paymentTel: watchPhone || watchTel,
|
||||
})
|
||||
}, [sameCustomerToPayment, watchName, watchSsid, watchPhone, watchTel, form])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sameCustomerToGift) return
|
||||
form.setFieldsValue({
|
||||
giftAccountName: watchName,
|
||||
giftSsid: watchSsid,
|
||||
})
|
||||
}, [sameCustomerToGift, watchName, watchSsid, form])
|
||||
|
||||
useEffect(() => {
|
||||
if (!samePaymentToGift) return
|
||||
form.setFieldsValue({
|
||||
giftBank: watchBank,
|
||||
giftAccountNumber: watchAccountNumber,
|
||||
giftAccountName: watchPaymentName || watchName,
|
||||
giftSsid: watchPaymentSsid || watchSsid,
|
||||
})
|
||||
}, [samePaymentToGift, watchBank, watchAccountNumber, watchPaymentName, watchPaymentSsid, watchName, watchSsid, form])
|
||||
|
||||
const searchAddress = (prefix: '' | 'bl') => {
|
||||
message.info('주소검색(데모) — 직접 입력해 주세요.')
|
||||
if (prefix === 'bl') {
|
||||
form.setFieldsValue({
|
||||
blZipcode: form.getFieldValue('blZipcode') || '06236',
|
||||
blAddress: form.getFieldValue('blAddress') || '서울특별시 강남구 테헤란로',
|
||||
})
|
||||
} else {
|
||||
form.setFieldsValue({
|
||||
zipcode: form.getFieldValue('zipcode') || '06236',
|
||||
address: form.getFieldValue('address') || '서울특별시 강남구 테헤란로',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleFinish = async (values: Record<string, unknown>) => {
|
||||
if (!isp) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const be = String(values.branchEmployee || '')
|
||||
const [branch, counselor] = be.includes('|') ? be.split('|') : [values.branch, values.counselor]
|
||||
const addrParts = [values.zipcode, values.address, values.addressDetail].filter(Boolean)
|
||||
const fullAddress = addrParts.join(' ')
|
||||
const memoExtra: string[] = []
|
||||
if (values.postAdd) memoExtra.push(`추가메모: ${values.postAdd}`)
|
||||
if (values.email) memoExtra.push(`이메일: ${values.email}`)
|
||||
if (values.tel) memoExtra.push(`일반전화: ${values.tel}`)
|
||||
if (values.paymentName) memoExtra.push(`납입자: ${values.paymentName}`)
|
||||
if (values.bank) memoExtra.push(`은행: ${values.bank} ${values.accountNumber || ''}`)
|
||||
if (values.cardCompany) memoExtra.push(`카드: ${values.cardCompany} ${values.cardNumber || ''}`)
|
||||
if (values.memo) memoExtra.push(String(values.memo))
|
||||
|
||||
const { data } = await client.post<ApiResponse<{ id: number }>>(`${apiPrefix()}/homes/receptions/apply`, {
|
||||
ispId: isp.id,
|
||||
counselor: counselor || counselorOptions[0] || '홍길동',
|
||||
branch: branch || branchOptions[0] || '',
|
||||
memo: memoExtra.join('\n'),
|
||||
customer: {
|
||||
type: values.customerType,
|
||||
name: values.name,
|
||||
phone: values.phone,
|
||||
tel: values.tel,
|
||||
ssid: values.ssid,
|
||||
email: values.email,
|
||||
postIt: values.postIt,
|
||||
postAdd: values.postAdd,
|
||||
zipcode: values.zipcode,
|
||||
address: fullAddress || values.address,
|
||||
addressDetail: values.addressDetail,
|
||||
issueDate: values.issueDate ? dayjs(values.issueDate as dayjs.Dayjs).format('YYYY-MM-DD') : undefined,
|
||||
driversLicenseNumber: values.driversLicenseNumber,
|
||||
blName: values.blName,
|
||||
blNumber: values.blNumber,
|
||||
},
|
||||
payment: {
|
||||
method: values.paymentMethod,
|
||||
name: values.paymentName,
|
||||
ssid: values.paymentSsid,
|
||||
tel: values.paymentTel,
|
||||
bank: values.bank,
|
||||
accountNumber: values.accountNumber,
|
||||
cardCompany: values.cardCompany,
|
||||
cardYear: values.cardYear,
|
||||
cardMonth: values.cardMonth,
|
||||
cardNumber: values.cardNumber,
|
||||
},
|
||||
product: {
|
||||
productType: values.productType,
|
||||
productId: values.productId,
|
||||
agreementId: values.agreementId,
|
||||
price: values.price,
|
||||
installDate: values.installDate ? dayjs(values.installDate as dayjs.Dayjs).format('YYYY-MM-DD') : undefined,
|
||||
discount: values.discount === 'none' ? '' : values.discount,
|
||||
},
|
||||
gift: {
|
||||
type: values.giftType,
|
||||
place: values.giftPlace,
|
||||
cashAmount: values.cashAmount,
|
||||
goods: values.goods,
|
||||
goodsPrice: values.goodsPrice,
|
||||
giftDate: values.giftDate ? dayjs(values.giftDate as dayjs.Dayjs).format('YYYY-MM-DD') : undefined,
|
||||
accountBank: values.giftBank,
|
||||
accountNumber: values.giftAccountNumber,
|
||||
accountName: values.giftAccountName,
|
||||
ssid: values.giftSsid,
|
||||
},
|
||||
})
|
||||
const created = unwrap(data) as { id: number }
|
||||
await uploadCreateAttachments(created.id)
|
||||
message.success(`[${isp.name}] 신규신청을 등록했습니다.`)
|
||||
onSuccess()
|
||||
} catch {
|
||||
message.error('신규신청 등록에 실패했습니다.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const uploadCreateAttachments = async (receptionId: number) => {
|
||||
for (const slot of [1, 2, 3, 4, 5] as const) {
|
||||
const file = createFiles[`file${slot}`]
|
||||
if (!file) continue
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
await client.post(`${apiPrefix()}/homes/receptions/${receptionId}/file/${slot}`, fd)
|
||||
}
|
||||
if (isAdmin) {
|
||||
for (const slot of [1, 2, 3] as const) {
|
||||
const file = createFiles[`adminFile${slot}`]
|
||||
if (!file) continue
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
await client.post(`${apiPrefix()}/homes/receptions/${receptionId}/admin-file/${slot}`, fd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const buildMemoAuto = () => {
|
||||
const v = form.getFieldsValue()
|
||||
const giftLabel = GIFT_TYPES.find((g) => g.value === v.giftType)?.label || ''
|
||||
const productLabel = PRODUCT_TYPES.find((p) => p.value === v.productType)?.label || ''
|
||||
const productName = (productsQuery.data || []).find((p) => p.id === v.productId)?.name || ''
|
||||
const agreementName = (agreementsQuery.data || []).find((a) => a.id === v.agreementId)?.name || ''
|
||||
let content = ''
|
||||
content += `◈ 고객명 : ${v.name || ''}\n`
|
||||
content += `◈ 연락처 : ${v.phone || ''}\n`
|
||||
content += `◈ 상품 : ${productLabel} / ${productName}\n`
|
||||
content += `◈ 약정 : ${agreementName}\n`
|
||||
content += `◈ 사은품 : ${giftLabel}`
|
||||
if (v.giftType && v.giftType !== 'a01') {
|
||||
const parts: string[] = []
|
||||
if (v.giftType === 'a02' || v.giftType === 'a04') parts.push(`현금(${v.cashAmount || 0})`)
|
||||
if (v.giftType === 'a03' || v.giftType === 'a04') parts.push(`${v.goods || ''}(${v.goodsPrice || 0})`)
|
||||
if (parts.length) content += ` / ${parts.join(', ')}`
|
||||
}
|
||||
content += '\n'
|
||||
content += `◈ 납입자 : ${v.paymentName || ''}\n`
|
||||
content += `◈ 주소 : ${[v.zipcode, v.address, v.addressDetail].filter(Boolean).join(' ')}\n`
|
||||
return content
|
||||
}
|
||||
|
||||
const issueLabel = customerType === 'foreigner' ? '외국인등록증 발급일자' : '주민등록증 발급일자'
|
||||
const ssidLabel = customerType === 'foreigner' ? '외국인등록번호' : '주민등록번호'
|
||||
const addrLabel = customerType === 'foreigner' ? '설치주소' : '주소'
|
||||
|
||||
const addrStack = (prefix: '' | 'bl') => (
|
||||
<div className="of-addr-stack">
|
||||
<div className="of-addr-zip">
|
||||
<Form.Item name={prefix ? 'blZipcode' : 'zipcode'} noStyle>
|
||||
<Input placeholder="우편번호" style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Button type="default" onClick={() => searchAddress(prefix)}>주소검색</Button>
|
||||
</div>
|
||||
<Form.Item name={prefix ? 'blAddress' : 'address'} noStyle>
|
||||
<Input placeholder="주소" />
|
||||
</Form.Item>
|
||||
<Form.Item name={prefix ? 'blAddressDetail' : 'addressDetail'} noStyle>
|
||||
<Input placeholder="상세주소" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isp ? `신규가입 신청 - ${isp.name}` : '신규가입 신청'}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width="96%"
|
||||
style={{ maxWidth: 1040 }}
|
||||
className="reception-apply-modal order-form-modal"
|
||||
styles={{ body: { maxHeight: 'calc(92vh - 56px)', overflowY: 'auto' } }}
|
||||
>
|
||||
<Form form={form} layout="vertical" className="order-form-embedded-inner order-form-page reception-apply-form" onFinish={handleFinish}>
|
||||
<FormSection title="기본 정보">
|
||||
<FormGrid>
|
||||
<FormField label="영업점" full>
|
||||
<Form.Item name="branchEmployee" noStyle rules={[{ required: true, message: '영업점을 선택하세요' }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={branchEmployeeOptions}
|
||||
placeholder="--- 영업점 선택 ---"
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="통신사" full>
|
||||
<Input value={isp ? `[${isp.telecom}] ${isp.name}` : ''} disabled />
|
||||
</FormField>
|
||||
<FormField label="인증 링크" full>
|
||||
<p className="of-muted" style={{ padding: 0, border: 'none', margin: 0 }}>등록된 인증 링크가 없습니다.</p>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="고객정보">
|
||||
<FormGrid cols={1}>
|
||||
<FormField label="고객유형" full>
|
||||
<Form.Item name="customerType" noStyle>
|
||||
<Radio.Group className="of-antd-choice-row" style={{ padding: 0, border: 'none' }}>
|
||||
<Radio value="public">개인</Radio>
|
||||
<Radio value="company">사업자</Radio>
|
||||
<Radio value="foreigner">외국인</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
|
||||
{customerType === 'company' ? (
|
||||
<FormGrid>
|
||||
<FormField label="사업장명">
|
||||
<Form.Item name="name" noStyle rules={[{ required: true, message: '사업장명을 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="대표자명">
|
||||
<Form.Item name="blName" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="사업자등록번호">
|
||||
<Form.Item name="blNumber" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="법인등록번호">
|
||||
<Form.Item name="blCorpNumber" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="업태">
|
||||
<Form.Item name="blType" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="종목">
|
||||
<Form.Item name="blItem" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="포스트잇">
|
||||
<Form.Item name="postIt" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="추가메모">
|
||||
<Form.Item name="postAdd" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="사업장주소" full>{addrStack('bl')}</FormField>
|
||||
<FormField label="휴대폰">
|
||||
<Form.Item name="phone" noStyle rules={[{ required: true, message: '휴대폰을 입력하세요' }]}>
|
||||
<Input placeholder="010-0000-0000" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="일반전화">
|
||||
<Form.Item name="tel" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="이메일" full>
|
||||
<Form.Item name="email" noStyle><Input type="email" /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="주소" full>{addrStack('')}</FormField>
|
||||
</FormGrid>
|
||||
) : (
|
||||
<FormGrid>
|
||||
<FormField label="고객명">
|
||||
<Form.Item name="name" noStyle rules={[{ required: true, message: '고객명을 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="포스트잇">
|
||||
<Form.Item name="postIt" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="추가메모" full>
|
||||
<Form.Item name="postAdd" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label={ssidLabel} hint="예) 140101-*******">
|
||||
<Form.Item name="ssid" noStyle>
|
||||
<Input placeholder="예) 140101-*******" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label={isIssueDate === '1' ? issueLabel : '운전면허번호'}>
|
||||
<div className="order-form-inline">
|
||||
<Form.Item name="isIssueDate" noStyle>
|
||||
<Select
|
||||
style={{ minWidth: 140, flex: '0 0 auto' }}
|
||||
options={[
|
||||
{ value: '1', label: issueLabel },
|
||||
{ value: '0', label: '운전면허번호' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{isIssueDate === '1' ? (
|
||||
<Form.Item name="issueDate" noStyle>
|
||||
<DatePicker className="full-width" style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name="driversLicenseNumber" noStyle>
|
||||
<Input placeholder="예) 대구 00-123456-00" style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="휴대폰">
|
||||
<Form.Item name="phone" noStyle rules={[{ required: true, message: '휴대폰을 입력하세요' }]}>
|
||||
<Input placeholder="010-0000-0000" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="일반전화">
|
||||
<Form.Item name="tel" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="이메일" full>
|
||||
<Form.Item name="email" noStyle><Input type="email" /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label={addrLabel} full>{addrStack('')}</FormField>
|
||||
</FormGrid>
|
||||
)}
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="결제납입정보">
|
||||
<FormGrid cols={1}>
|
||||
<FormField label="납부방식" full>
|
||||
<Form.Item name="paymentMethod" noStyle>
|
||||
<Radio.Group className="of-antd-choice-row" style={{ padding: 0, border: 'none' }}>
|
||||
<Radio value="transfer">자동이체</Radio>
|
||||
<Radio value="card">신용카드</Radio>
|
||||
<Radio value="paper">지로청구</Radio>
|
||||
<Radio value="telephone">전화번호요금통합</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
|
||||
{(paymentMethod === 'transfer' || paymentMethod === 'card') && (
|
||||
<FormGrid>
|
||||
<FormField label="납입자">
|
||||
<div className="order-form-inline">
|
||||
<Form.Item name="paymentName" noStyle>
|
||||
<Input style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sameCustomerToPayment" valuePropName="checked" noStyle>
|
||||
<Checkbox>고객정보와 동일</Checkbox>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="주민등록번호">
|
||||
<Form.Item name="paymentSsid" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="전화번호" full>
|
||||
<Form.Item name="paymentTel" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
{paymentMethod === 'transfer' ? (
|
||||
<>
|
||||
<FormField label="은행">
|
||||
<Form.Item name="bank" noStyle>
|
||||
<Select allowClear placeholder="--- 은행선택 ---" options={options(BANKS)} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="계좌번호">
|
||||
<Form.Item name="accountNumber" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FormField label="카드">
|
||||
<div className="order-form-inline">
|
||||
<Form.Item name="cardCompany" noStyle>
|
||||
<Select allowClear placeholder="카드사" options={options(CARD_COMPANIES)} style={{ minWidth: 110 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="cardYear" noStyle>
|
||||
<Select allowClear placeholder="YY" options={CARD_YEARS.map((y) => ({ value: y, label: y }))} style={{ width: 72 }} />
|
||||
</Form.Item>
|
||||
<span>년</span>
|
||||
<Form.Item name="cardMonth" noStyle>
|
||||
<Select allowClear placeholder="MM" options={CARD_MONTHS.map((m) => ({ value: m, label: m }))} style={{ width: 72 }} />
|
||||
</Form.Item>
|
||||
<span>월</span>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="카드번호">
|
||||
<Form.Item name="cardNumber" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</FormGrid>
|
||||
)}
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="상품 정보">
|
||||
<FormBlock>
|
||||
<FormGrid>
|
||||
<FormField label="상품종류">
|
||||
<Form.Item name="productType" noStyle rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={PRODUCT_TYPES}
|
||||
onChange={() => form.setFieldValue('productId', undefined)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="상품">
|
||||
<Form.Item name="productId" noStyle rules={[{ required: true, message: '상품을 선택하세요' }]}>
|
||||
<Select
|
||||
loading={productsQuery.isLoading}
|
||||
options={(filteredProducts || []).map((p) => ({ value: p.id, label: p.name }))}
|
||||
placeholder="선택"
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="약정">
|
||||
<Form.Item name="agreementId" noStyle>
|
||||
<Select
|
||||
allowClear
|
||||
loading={agreementsQuery.isLoading}
|
||||
options={(agreementsQuery.data || []).map((a) => ({ value: a.id, label: a.name }))}
|
||||
placeholder="선택"
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="월사용요금">
|
||||
<Form.Item name="price" noStyle>
|
||||
<InputNumber className="full-width" min={0} controls={false} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="설치희망일자" full>
|
||||
<Form.Item name="installDate" noStyle>
|
||||
<DatePicker className="full-width" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="할인혜택" full>
|
||||
<Form.Item name="discount" noStyle>
|
||||
<Radio.Group className="of-antd-choice-row">
|
||||
{DISCOUNTS.map((d) => (
|
||||
<Radio key={d.value} value={d.value}>{d.label}</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
|
||||
<FormGroup label="상품별 사은품">
|
||||
<FormGrid>
|
||||
<FormField label="사은품종류">
|
||||
<Form.Item name="giftType" noStyle>
|
||||
<Select options={GIFT_TYPES} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="현금금액">
|
||||
<Form.Item name="cashAmount" noStyle>
|
||||
<InputNumber className="full-width" min={0} controls={false} disabled={!giftCash} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="물품명">
|
||||
<Form.Item name="goods" noStyle>
|
||||
<Input disabled={!giftGoods} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="물품금액">
|
||||
<Form.Item name="goodsPrice" noStyle>
|
||||
<InputNumber className="full-width" min={0} controls={false} disabled={!giftGoods} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormGroup>
|
||||
</FormBlock>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="사은품 정보">
|
||||
<FormGrid>
|
||||
<FormField label="지급예정일자">
|
||||
<Form.Item name="giftDate" noStyle>
|
||||
<DatePicker className="full-width" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="사은품지급처">
|
||||
<Form.Item name="giftPlace" noStyle>
|
||||
<Radio.Group className="of-antd-choice-row">
|
||||
<Radio value="b01">본사요청</Radio>
|
||||
<Radio value="b02">자체발송</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="결제은행">
|
||||
<div className="order-form-inline">
|
||||
<Form.Item name="giftBank" noStyle>
|
||||
<Select allowClear placeholder="--- 은행선택 ---" options={options(BANKS)} style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="samePaymentToGift" valuePropName="checked" noStyle>
|
||||
<Checkbox>결제정보와 동일</Checkbox>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="계좌번호">
|
||||
<Form.Item name="giftAccountNumber" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="예금주명">
|
||||
<div className="order-form-inline">
|
||||
<Form.Item name="giftAccountName" noStyle>
|
||||
<Input style={{ flex: 1 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sameCustomerToGift" valuePropName="checked" noStyle>
|
||||
<Checkbox>고객정보와 동일</Checkbox>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="주민등록번호">
|
||||
<Form.Item name="giftSsid" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="기타 정보">
|
||||
<FormGrid cols={1}>
|
||||
<FormField label="기타사항" full>
|
||||
<div className="of-toolbar-inline">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost sm"
|
||||
onClick={() => form.setFieldsValue({ memo: `${form.getFieldValue('memo') || ''}${buildMemoAuto()}` })}
|
||||
>
|
||||
기타사항 자동입력
|
||||
</button>
|
||||
<button type="button" className="ghost sm" onClick={() => form.setFieldsValue({ memo: '' })}>
|
||||
기타사항 삭제
|
||||
</button>
|
||||
</div>
|
||||
<Form.Item name="memo" noStyle>
|
||||
<Input.TextArea rows={6} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
{[1, 2, 3, 4, 5].map((slot) => (
|
||||
<FormField key={slot} label={`첨부파일${slot}`} full>
|
||||
<div className="order-form-inline">
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0] || null
|
||||
setCreateFiles((prev) => ({ ...prev, [`file${slot}`]: f }))
|
||||
}}
|
||||
/>
|
||||
{createFiles[`file${slot}`] ? (
|
||||
<span className="muted">{createFiles[`file${slot}`]?.name}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</FormField>
|
||||
))}
|
||||
{isAdmin
|
||||
? [1, 2, 3].map((slot) => (
|
||||
<FormField key={`a${slot}`} label={`관리자 첨부파일${slot}`} full>
|
||||
<div className="order-form-inline">
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0] || null
|
||||
setCreateFiles((prev) => ({ ...prev, [`adminFile${slot}`]: f }))
|
||||
}}
|
||||
/>
|
||||
{createFiles[`adminFile${slot}`] ? (
|
||||
<span className="muted">{createFiles[`adminFile${slot}`]?.name}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</FormField>
|
||||
))
|
||||
: null}
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
|
||||
<div className="order-form-actions">
|
||||
<Button type="primary" htmlType="submit" loading={saving} className="pending-submit-btn">
|
||||
등록
|
||||
</Button>
|
||||
<Button onClick={onCancel}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Checkbox, DatePicker, Input, Select, message } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import Modal from './DraggableModal'
|
||||
import { FormField, FormGrid, FormSection } from './OrderFormLayout'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../api/client'
|
||||
import { options } from '../pages/care/homesShared'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
selectedIds: number[]
|
||||
progressStatuses: string[]
|
||||
giftStatuses: string[]
|
||||
centerOptions?: string[]
|
||||
onCancel: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const GIFT_PLACES = [
|
||||
{ value: 'b01', label: '본사요청' },
|
||||
{ value: 'b02', label: '자체발송' },
|
||||
{ value: '본사', label: '본사발송' },
|
||||
{ value: '영업점', label: '영업점발송' },
|
||||
]
|
||||
|
||||
export default function ReceptionBulkModal({
|
||||
open,
|
||||
selectedIds,
|
||||
progressStatuses,
|
||||
giftStatuses,
|
||||
centerOptions = [],
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}: Props) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const [pgAdminOnly, setPgAdminOnly] = useState(false)
|
||||
const [pgStatus, setPgStatus] = useState<string | undefined>()
|
||||
const [pgNotNotice, setPgNotNotice] = useState(false)
|
||||
const [pgCenterId, setPgCenterId] = useState<string | undefined>()
|
||||
const [pgMemo, setPgMemo] = useState('')
|
||||
const [pgOpended, setPgOpended] = useState('')
|
||||
const [pgCanceled, setPgCanceled] = useState('')
|
||||
|
||||
const [gPlace, setGPlace] = useState<string | undefined>()
|
||||
const [gStatus, setGStatus] = useState<string | undefined>()
|
||||
const [gProgressLog, setGProgressLog] = useState('')
|
||||
const [gDeliveryCompany, setGDeliveryCompany] = useState('')
|
||||
const [gTrackingNumber, setGTrackingNumber] = useState('')
|
||||
const [gRequested, setGRequested] = useState('')
|
||||
const [gSended, setGSended] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setPgAdminOnly(false)
|
||||
setPgStatus(undefined)
|
||||
setPgNotNotice(false)
|
||||
setPgCenterId(undefined)
|
||||
setPgMemo('')
|
||||
setPgOpended('')
|
||||
setPgCanceled('')
|
||||
setGPlace(undefined)
|
||||
setGStatus(undefined)
|
||||
setGProgressLog('')
|
||||
setGDeliveryCompany('')
|
||||
setGTrackingNumber('')
|
||||
setGRequested('')
|
||||
setGSended('')
|
||||
}, [open])
|
||||
|
||||
const need = () => {
|
||||
if (!selectedIds.length) {
|
||||
message.warning('대상 접수를 선택하세요.')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const run = async (fn: () => Promise<void>) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await fn()
|
||||
onSuccess()
|
||||
} catch (e: unknown) {
|
||||
message.error(e instanceof Error ? e.message : '일괄 처리에 실패했습니다.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runProgress = () => {
|
||||
if (!need()) return
|
||||
if (!pgStatus) {
|
||||
message.warning('진행상황을 선택하세요.')
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`선택 ${selectedIds.length}건을 일괄 처리하시겠습니까?`)) return
|
||||
void run(async () => {
|
||||
const { data } = await client.post<ApiResponse<{ count: number }>>(`${apiPrefix()}/homes/receptions/bulk-progress`, {
|
||||
ids: selectedIds,
|
||||
status: pgStatus,
|
||||
memo: pgMemo,
|
||||
centerId: pgCenterId || null,
|
||||
opended: pgOpended || null,
|
||||
canceled: pgCanceled || null,
|
||||
adminOnly: pgAdminOnly,
|
||||
notNotice: pgNotNotice,
|
||||
})
|
||||
const res = unwrap(data) as { count: number }
|
||||
message.success(`${res.count}건 진행상황을 변경했습니다.`)
|
||||
})
|
||||
}
|
||||
|
||||
const runGift = () => {
|
||||
if (!need()) return
|
||||
if (!gStatus) {
|
||||
message.warning('사은품진행상황을 선택하세요.')
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`선택 ${selectedIds.length}건을 일괄 처리하시겠습니까?`)) return
|
||||
void run(async () => {
|
||||
const { data } = await client.post<ApiResponse<{ count: number }>>(`${apiPrefix()}/homes/receptions/bulk-gift`, {
|
||||
ids: selectedIds,
|
||||
place: gPlace || null,
|
||||
status: gStatus,
|
||||
giftProgressLog: gProgressLog,
|
||||
deliveryCompany: gDeliveryCompany || null,
|
||||
trackingNumber: gTrackingNumber || null,
|
||||
requested: gRequested || null,
|
||||
sended: gSended || null,
|
||||
})
|
||||
const res = unwrap(data) as { count: number }
|
||||
message.success(`${res.count}건 사은품진행을 변경했습니다.`)
|
||||
})
|
||||
}
|
||||
|
||||
const runCenterWait = () => {
|
||||
if (!need()) return
|
||||
if (!window.confirm('입력처 정산대기로 일괄처리 하시겠습니까?')) return
|
||||
void run(async () => {
|
||||
const { data } = await client.post<ApiResponse<{ count: number }>>(`${apiPrefix()}/homes/receptions/bulk-center-progress`, {
|
||||
ids: selectedIds,
|
||||
})
|
||||
const res = unwrap(data) as { count: number }
|
||||
message.success(`${res.count}건을 입력처 정산대기로 변경했습니다.`)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`일괄진행 (${selectedIds.length}건 선택)`}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width="96%"
|
||||
style={{ maxWidth: 1040 }}
|
||||
className="reception-bulk-modal reception-apply-modal order-form-modal"
|
||||
styles={{ body: { maxHeight: 'calc(92vh - 56px)', overflowY: 'auto' } }}
|
||||
>
|
||||
<div className="order-form-embedded-inner order-form-page reception-apply-form reception-bulk-form">
|
||||
<FormSection title="진행상황 일괄 처리">
|
||||
<FormGrid>
|
||||
<FormField label="관리자 전용로그" full>
|
||||
<div className="order-form-inline">
|
||||
<Checkbox checked={pgAdminOnly} onChange={(e) => setPgAdminOnly(e.target.checked)}>
|
||||
관리자 전용로그
|
||||
</Checkbox>
|
||||
<span className="bulk-hint">■ 관리자만 보이고 영업점은 보이지 않습니다.</span>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="진행상황" full>
|
||||
<div className="order-form-inline">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="진행상황 선택"
|
||||
value={pgStatus}
|
||||
onChange={(v) => setPgStatus(v)}
|
||||
options={options(progressStatuses)}
|
||||
style={{ flex: 1, minWidth: 180 }}
|
||||
/>
|
||||
<Checkbox checked={pgNotNotice} onChange={(e) => setPgNotNotice(e.target.checked)}>
|
||||
하부점에 전달하지 않음
|
||||
</Checkbox>
|
||||
</div>
|
||||
<p className="bulk-hint-line">■ 퀵전송으로 등록된 접수인 경우 진행상황을 하부점에 알리지 않습니다.</p>
|
||||
<p className="bulk-hint-line">■ 진행상황을 1개 선택후 검색하여야 진행상황을 변경할 수 있습니다.</p>
|
||||
</FormField>
|
||||
<FormField label="입력처" full>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="입력처 선택"
|
||||
value={pgCenterId}
|
||||
onChange={(v) => setPgCenterId(v)}
|
||||
options={options(centerOptions.length ? centerOptions : ['본사'])}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="업무진행메모(LOG)" full>
|
||||
<Input.TextArea rows={3} value={pgMemo} onChange={(e) => setPgMemo(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="개통일자">
|
||||
<DatePicker
|
||||
value={pgOpended ? dayjs(pgOpended) : null}
|
||||
onChange={(d) => setPgOpended(d ? d.format('YYYY-MM-DD') : '')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="해지일자">
|
||||
<DatePicker
|
||||
value={pgCanceled ? dayjs(pgCanceled) : null}
|
||||
onChange={(d) => setPgCanceled(d ? d.format('YYYY-MM-DD') : '')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
<div className="bulk-foot">
|
||||
<button type="button" className="bulk-submit" disabled={busy} onClick={runProgress}>선택 일괄 처리</button>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="본사발송 사은품 일괄 처리">
|
||||
<FormGrid>
|
||||
<FormField label="사은품 지급처">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="지급처 선택"
|
||||
value={gPlace}
|
||||
onChange={(v) => setGPlace(v)}
|
||||
options={GIFT_PLACES}
|
||||
/>
|
||||
<p className="bulk-hint-line">■ 지급처를 검색하세요.</p>
|
||||
</FormField>
|
||||
<FormField label="사은품진행상황">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="사은품 진행상황 선택"
|
||||
value={gStatus}
|
||||
onChange={(v) => setGStatus(v)}
|
||||
options={options(giftStatuses)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="사은품진행메모(LOG)" full>
|
||||
<Input.TextArea rows={3} value={gProgressLog} onChange={(e) => setGProgressLog(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="택배회사">
|
||||
<Input value={gDeliveryCompany} onChange={(e) => setGDeliveryCompany(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="송장번호">
|
||||
<Input value={gTrackingNumber} onChange={(e) => setGTrackingNumber(e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="사은품지급요청일">
|
||||
<DatePicker
|
||||
value={gRequested ? dayjs(gRequested) : null}
|
||||
onChange={(d) => setGRequested(d ? d.format('YYYY-MM-DD') : '')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="사은품지급완료일">
|
||||
<DatePicker
|
||||
value={gSended ? dayjs(gSended) : null}
|
||||
onChange={(d) => setGSended(d ? d.format('YYYY-MM-DD') : '')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
<div className="bulk-foot">
|
||||
<button type="button" className="bulk-submit" disabled={busy} onClick={runGift}>선택 일괄 처리</button>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="입력처 - 정산대기로 일괄처리">
|
||||
<FormGrid cols={1}>
|
||||
<FormField label="안내" full>
|
||||
<p className="bulk-desc-block" style={{ padding: 0, margin: 0 }}>
|
||||
■ 개통완료된 고객중에 입력처 정산이 진행안된 고객은 입력처 정산대기로 일괄처리 진행할수 있습니다.
|
||||
</p>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
<div className="bulk-foot">
|
||||
<button type="button" className="bulk-submit" disabled={busy} onClick={runCenterWait}>선택 일괄 처리</button>
|
||||
</div>
|
||||
</FormSection>
|
||||
</div>
|
||||
|
||||
<div className="balance-modal-footer" style={{ marginTop: 12 }}>
|
||||
<Button onClick={onCancel}>닫기</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
message,
|
||||
} from 'antd'
|
||||
import Modal from './DraggableModal'
|
||||
import PrgSection from './PrgSection'
|
||||
import ReceptionProgressReadOnly from './ReceptionProgressReadOnly'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../api/client'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
receptionId: number | null
|
||||
onClose: () => void
|
||||
onChanged?: () => void
|
||||
onEdit?: (id: number) => void
|
||||
onCopyLine?: (ispId: number) => void
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
postIt: string
|
||||
postAdd: string
|
||||
status: string
|
||||
adminOnly: boolean
|
||||
contractNumber: string
|
||||
installSchedule: string
|
||||
customerLoginId: string
|
||||
newTel: string
|
||||
multiLine: boolean
|
||||
addPayer: boolean
|
||||
record: boolean
|
||||
progressLog: string
|
||||
adminMemo: string
|
||||
openDate: string
|
||||
cancelDate: string
|
||||
giftStatus: string
|
||||
giftPlace: string
|
||||
giftProgressLog: string
|
||||
giftRequested: string
|
||||
giftSended: string
|
||||
changeOrderDate: boolean
|
||||
orderDate: string
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
postIt: '',
|
||||
postAdd: '',
|
||||
status: '',
|
||||
adminOnly: false,
|
||||
contractNumber: '',
|
||||
installSchedule: '',
|
||||
customerLoginId: '',
|
||||
newTel: '',
|
||||
multiLine: false,
|
||||
addPayer: false,
|
||||
record: false,
|
||||
progressLog: '',
|
||||
adminMemo: '',
|
||||
openDate: '',
|
||||
cancelDate: '',
|
||||
giftStatus: '',
|
||||
giftPlace: '',
|
||||
giftProgressLog: '',
|
||||
giftRequested: '',
|
||||
giftSended: '',
|
||||
changeOrderDate: false,
|
||||
orderDate: '',
|
||||
}
|
||||
|
||||
function d10(v?: string | null) {
|
||||
return v ? String(v).replace('T', ' ').substring(0, 10) : ''
|
||||
}
|
||||
function dt16(v?: string | null) {
|
||||
return v ? String(v).replace('T', ' ').substring(0, 16) : ''
|
||||
}
|
||||
|
||||
export default function ReceptionProgressModal({
|
||||
open,
|
||||
receptionId,
|
||||
onClose,
|
||||
onChanged,
|
||||
onEdit,
|
||||
onCopyLine,
|
||||
}: Props) {
|
||||
const qc = useQueryClient()
|
||||
const [f, setF] = useState<FormState>(EMPTY)
|
||||
const [recreateIsp, setRecreateIsp] = useState<number | undefined>()
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ['reception-progress', receptionId],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>>>(
|
||||
`${apiPrefix()}/homes/receptions/${receptionId}`,
|
||||
)
|
||||
return unwrap(data) as Record<string, any>
|
||||
},
|
||||
enabled: open && receptionId != null,
|
||||
})
|
||||
|
||||
const ispQuery = useQuery({
|
||||
queryKey: ['reception-isps'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ id: number; name: string }[]>>(
|
||||
`${apiPrefix()}/homes/reception-isps`,
|
||||
)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const data = detailQuery.data
|
||||
const reception = data?.reception || data
|
||||
const progressStatuses: string[] = data?.progressStatuses || []
|
||||
const giftStatuses: string[] = data?.giftStatuses || []
|
||||
const progressLogs: any[] = data?.progressLogs || data?.logs || []
|
||||
const giftLogs: any[] = data?.giftProgressLogs || []
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !reception) return
|
||||
const gift = data.gift || {}
|
||||
const customer = data.customer || {}
|
||||
setF({
|
||||
postIt: customer.postIt ?? reception.postIt ?? '',
|
||||
postAdd: customer.postAdd ?? reception.postAdd ?? '',
|
||||
status: '',
|
||||
adminOnly: false,
|
||||
contractNumber: reception.contractNumber ?? '',
|
||||
installSchedule: reception.installSchedule ?? '',
|
||||
customerLoginId: reception.customerLoginId ?? '',
|
||||
newTel: reception.newTel ?? '',
|
||||
multiLine: !!reception.multiLine,
|
||||
addPayer: !!reception.addPayer,
|
||||
record: !!reception.record,
|
||||
progressLog: '',
|
||||
adminMemo: data.etc?.adminMemo ?? reception.adminMemo ?? '',
|
||||
openDate: d10(reception.openDate),
|
||||
cancelDate: d10(reception.cancelDate),
|
||||
giftStatus: '',
|
||||
giftPlace: gift.place ?? reception.giftPlace ?? '',
|
||||
giftProgressLog: '',
|
||||
giftRequested: d10(gift.requested ?? reception.giftRequested),
|
||||
giftSended: d10(gift.sended ?? reception.giftSended),
|
||||
changeOrderDate: false,
|
||||
orderDate: d10(reception.orderDate),
|
||||
})
|
||||
}, [data]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const set = (patch: Partial<FormState>) => setF((p) => ({ ...p, ...patch }))
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async (closeAfter: boolean) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
postIt: f.postIt,
|
||||
postAdd: f.postAdd,
|
||||
contractNumber: f.contractNumber,
|
||||
installSchedule: f.installSchedule,
|
||||
customerLoginId: f.customerLoginId,
|
||||
newTel: f.newTel,
|
||||
multiLine: f.multiLine,
|
||||
addPayer: f.addPayer,
|
||||
record: f.record,
|
||||
adminMemo: f.adminMemo,
|
||||
openDate: f.openDate || null,
|
||||
cancelDate: f.cancelDate || null,
|
||||
giftPlace: f.giftPlace,
|
||||
giftRequested: f.giftRequested || null,
|
||||
giftSended: f.giftSended || null,
|
||||
adminOnly: f.adminOnly,
|
||||
memo: f.progressLog,
|
||||
giftProgressLog: f.giftProgressLog,
|
||||
changeOrderDate: f.changeOrderDate,
|
||||
orderDate: f.changeOrderDate && f.orderDate ? `${f.orderDate} 00:00:00` : undefined,
|
||||
}
|
||||
if (f.status) payload.status = f.status
|
||||
if (f.giftStatus) payload.giftStatus = f.giftStatus
|
||||
const { data: res } = await client.post(
|
||||
`${apiPrefix()}/homes/receptions/${receptionId}/progress`,
|
||||
payload,
|
||||
)
|
||||
return { body: unwrap(res), closeAfter }
|
||||
},
|
||||
onSuccess: ({ closeAfter }) => {
|
||||
message.success('저장했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['reception-progress', receptionId] })
|
||||
qc.invalidateQueries({ queryKey: ['receptions'] })
|
||||
onChanged?.()
|
||||
if (closeAfter) onClose()
|
||||
else {
|
||||
setF((p) => ({ ...p, status: '', giftStatus: '', progressLog: '', giftProgressLog: '', adminOnly: false }))
|
||||
detailQuery.refetch()
|
||||
}
|
||||
},
|
||||
onError: () => message.error('저장에 실패했습니다.'),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data: res } = await client.delete(`${apiPrefix()}/homes/receptions/${receptionId}`)
|
||||
return unwrap(res)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['receptions'] })
|
||||
onChanged?.()
|
||||
onClose()
|
||||
},
|
||||
onError: () => message.error('삭제에 실패했습니다.'),
|
||||
})
|
||||
|
||||
const delProgressLog = async (logId: number) => {
|
||||
try {
|
||||
await client.delete(`${apiPrefix()}/homes/receptions/progress-log/${logId}`)
|
||||
detailQuery.refetch()
|
||||
message.success('로그를 삭제했습니다.')
|
||||
} catch {
|
||||
message.error('로그 삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const delGiftLog = async (logId: number) => {
|
||||
try {
|
||||
await client.delete(`${apiPrefix()}/homes/receptions/gift-progress-log/${logId}`)
|
||||
detailQuery.refetch()
|
||||
message.success('사은품 로그를 삭제했습니다.')
|
||||
} catch {
|
||||
message.error('로그 삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const titleWarn = reception?.progressStatus === '9.취소' || reception?.detailProgress?.includes?.('주의')
|
||||
const title = receptionId
|
||||
? `${titleWarn ? '⚠ 주의 요망 ' : ''}업무 진행 내역 — 접수 #${receptionId}`
|
||||
: '업무 진행 내역'
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width="96%"
|
||||
style={{ maxWidth: 900 }}
|
||||
className="reception-progress-modal order-progress-modal"
|
||||
styles={{ body: { maxHeight: 'calc(92vh - 56px)', overflowY: 'auto', padding: '12px 14px 16px' } }}
|
||||
>
|
||||
{detailQuery.isLoading || !data ? (
|
||||
<div style={{ padding: 12 }}>로딩 중...</div>
|
||||
) : (
|
||||
<div className="prg-body">
|
||||
<ReceptionProgressReadOnly data={data} />
|
||||
|
||||
<PrgSection title="업무 진행 내역">
|
||||
<table className="prg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>접수자</th>
|
||||
<td colSpan={3}>
|
||||
<div className="prg-inline">
|
||||
<Input readOnly value={data.employeeName || reception?.counselor || ''} style={{ width: 200 }} />
|
||||
<label className="prg-chk">
|
||||
<Checkbox checked={f.adminOnly} onChange={(e) => set({ adminOnly: e.target.checked })} />
|
||||
관리자 전용로그
|
||||
</label>
|
||||
<span className="prg-note-i">(관리자만 보이고 영업점은 보이지 않습니다.)</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>현재진행상황</th>
|
||||
<td colSpan={3}>
|
||||
<Input readOnly value={reception?.progressStatus || ''} style={{ width: 200 }} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>포스트잇</th>
|
||||
<td colSpan={3}>
|
||||
<Input value={f.postIt} onChange={(e) => set({ postIt: e.target.value })} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>추가메모</th>
|
||||
<td colSpan={3}>
|
||||
<Input value={f.postAdd} onChange={(e) => set({ postAdd: e.target.value })} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>진행상황</th>
|
||||
<td colSpan={3}>
|
||||
<div className="prg-inline">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="진행상황 선택"
|
||||
style={{ minWidth: 180 }}
|
||||
value={f.status || undefined}
|
||||
onChange={(v) => set({ status: v || '' })}
|
||||
options={progressStatuses.map((s) => ({ value: s, label: s }))}
|
||||
/>
|
||||
<Button size="small" loading={saveMut.isPending} onClick={() => saveMut.mutate(false)}>
|
||||
적 용
|
||||
</Button>
|
||||
</div>
|
||||
<span className="prg-note">적용 버튼은 창을 닫지 않고 현재 설정을 저장합니다.</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>계약번호</th>
|
||||
<td>
|
||||
<Input value={f.contractNumber} onChange={(e) => set({ contractNumber: e.target.value })} />
|
||||
</td>
|
||||
<th>설치일정</th>
|
||||
<td>
|
||||
<Input value={f.installSchedule} onChange={(e) => set({ installSchedule: e.target.value })} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>고객아이디</th>
|
||||
<td>
|
||||
<Input value={f.customerLoginId} onChange={(e) => set({ customerLoginId: e.target.value })} />
|
||||
</td>
|
||||
<th>신규전화번호</th>
|
||||
<td>
|
||||
<Input value={f.newTel} onChange={(e) => set({ newTel: e.target.value })} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>접수유형</th>
|
||||
<td colSpan={3}>
|
||||
<div className="prg-inline">
|
||||
<label className="prg-chk">
|
||||
<Checkbox checked={f.multiLine} onChange={(e) => set({ multiLine: e.target.checked })} /> 다회선
|
||||
</label>
|
||||
<label className="prg-chk">
|
||||
<Checkbox checked={f.addPayer} onChange={(e) => set({ addPayer: e.target.checked })} /> 납입자추가
|
||||
</label>
|
||||
<label className="prg-chk">
|
||||
<Checkbox checked={f.record} onChange={(e) => set({ record: e.target.checked })} /> 녹취
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>업무진행메모(LOG)</th>
|
||||
<td colSpan={3}>
|
||||
<Input.TextArea rows={4} value={f.progressLog} onChange={(e) => set({ progressLog: e.target.value })} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>관리자메모</th>
|
||||
<td colSpan={3}>
|
||||
<Input.TextArea rows={3} value={f.adminMemo} onChange={(e) => set({ adminMemo: e.target.value })} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>접수일자</th>
|
||||
<td colSpan={3}>
|
||||
<div className="prg-inline">
|
||||
<DatePicker
|
||||
value={f.orderDate ? dayjs(f.orderDate) : null}
|
||||
onChange={(d) => set({ orderDate: d ? d.format('YYYY-MM-DD') : '' })}
|
||||
/>
|
||||
<label className="prg-chk">
|
||||
<Checkbox checked={f.changeOrderDate} onChange={(e) => set({ changeOrderDate: e.target.checked })} />
|
||||
접수일자 변경
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>개통일자</th>
|
||||
<td>
|
||||
<DatePicker
|
||||
className="full-width"
|
||||
style={{ width: '100%' }}
|
||||
value={f.openDate ? dayjs(f.openDate) : null}
|
||||
onChange={(d) => set({ openDate: d ? d.format('YYYY-MM-DD') : '' })}
|
||||
/>
|
||||
</td>
|
||||
<th>해지일자</th>
|
||||
<td>
|
||||
<DatePicker
|
||||
className="full-width"
|
||||
style={{ width: '100%' }}
|
||||
value={f.cancelDate ? dayjs(f.cancelDate) : null}
|
||||
onChange={(d) => set({ cancelDate: d ? d.format('YYYY-MM-DD') : '' })}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
|
||||
<PrgSection title="업무 진행 현황">
|
||||
<table className="prg-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 150 }}>날짜</th>
|
||||
<th style={{ width: 100 }}>진행상태</th>
|
||||
<th style={{ width: 100 }}>담당자</th>
|
||||
<th>내용</th>
|
||||
<th style={{ width: 60 }}>삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{progressLogs.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td className="center">{dt16(l.loggedAt || l.createdAt)}</td>
|
||||
<td className="center">{l.status}</td>
|
||||
<td className="center">{l.authorName || l.writer}</td>
|
||||
<td>{l.adminOnly ? '[관리자] ' : ''}{l.memo}</td>
|
||||
<td className="center">
|
||||
<Button danger size="small" onClick={() => delProgressLog(l.id)}>삭제</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{progressLogs.length === 0 && (
|
||||
<tr><td colSpan={5} className="muted" style={{ textAlign: 'center' }}>진행 내역이 없습니다.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
|
||||
<PrgSection title="사은품 진행 내역">
|
||||
<table className="prg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>사은품현재상황</th>
|
||||
<td>
|
||||
<Input readOnly value={data.gift?.status || reception?.giftStatus || ''} />
|
||||
</td>
|
||||
<th>사은품진행상황</th>
|
||||
<td>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="사은품 진행상황 선택"
|
||||
style={{ width: '100%' }}
|
||||
value={f.giftStatus || undefined}
|
||||
onChange={(v) => set({ giftStatus: v || '' })}
|
||||
options={giftStatuses.map((s) => ({ value: s, label: s }))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사은품 지급처</th>
|
||||
<td>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
value={f.giftPlace || undefined}
|
||||
onChange={(v) => set({ giftPlace: v || '' })}
|
||||
options={[
|
||||
{ value: 'b01', label: '본사요청' },
|
||||
{ value: 'b02', label: '자체발송' },
|
||||
{ value: '본사', label: '본사' },
|
||||
{ value: '영업점', label: '영업점' },
|
||||
]}
|
||||
/>
|
||||
</td>
|
||||
<th>지급요청/완료</th>
|
||||
<td>
|
||||
<Space>
|
||||
<DatePicker
|
||||
placeholder="요청일"
|
||||
value={f.giftRequested ? dayjs(f.giftRequested) : null}
|
||||
onChange={(d) => set({ giftRequested: d ? d.format('YYYY-MM-DD') : '' })}
|
||||
/>
|
||||
<DatePicker
|
||||
placeholder="완료일"
|
||||
value={f.giftSended ? dayjs(f.giftSended) : null}
|
||||
onChange={(d) => set({ giftSended: d ? d.format('YYYY-MM-DD') : '' })}
|
||||
/>
|
||||
</Space>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사은품진행메모</th>
|
||||
<td colSpan={3}>
|
||||
<Input.TextArea rows={3} value={f.giftProgressLog} onChange={(e) => set({ giftProgressLog: e.target.value })} />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
|
||||
<PrgSection title="사은품 진행 현황">
|
||||
<table className="prg-grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 150 }}>날짜</th>
|
||||
<th style={{ width: 150 }}>사은품진행상태</th>
|
||||
<th style={{ width: 100 }}>담당자</th>
|
||||
<th>내용</th>
|
||||
<th style={{ width: 60 }}>삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{giftLogs.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td className="center">{dt16(l.loggedAt || l.createdAt)}</td>
|
||||
<td className="center">{l.status}</td>
|
||||
<td className="center">{l.authorName || l.writer}</td>
|
||||
<td>{l.memo}</td>
|
||||
<td className="center">
|
||||
<Button danger size="small" onClick={() => delGiftLog(l.id)}>삭제</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{giftLogs.length === 0 && (
|
||||
<tr><td colSpan={5} className="muted" style={{ textAlign: 'center' }}>사은품 진행 내역이 없습니다.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
|
||||
<div className="prg-foot">
|
||||
<div className="prg-foot-row">
|
||||
<Button type="primary" loading={saveMut.isPending} onClick={() => saveMut.mutate(true)}>
|
||||
등록
|
||||
</Button>
|
||||
<Button onClick={() => receptionId && onEdit?.(receptionId)}>수정</Button>
|
||||
<Button
|
||||
danger
|
||||
loading={deleteMut.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm('이 접수를 삭제하시겠습니까?')) deleteMut.mutate()
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={onClose}>닫기</Button>
|
||||
</div>
|
||||
<div className="prg-foot-row">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="추가 회선 선택"
|
||||
style={{ minWidth: 220 }}
|
||||
value={recreateIsp}
|
||||
onChange={(v) => setRecreateIsp(v)}
|
||||
options={(ispQuery.data || []).map((i) => ({ value: i.id, label: i.name }))}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!recreateIsp) {
|
||||
message.warning('추가 회선 통신사를 선택하세요.')
|
||||
return
|
||||
}
|
||||
onCopyLine?.(recreateIsp)
|
||||
}}
|
||||
>
|
||||
추가 회선 등록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import PrgSection from './PrgSection'
|
||||
|
||||
type ProgressDetail = Record<string, any>
|
||||
|
||||
const pmChecked = (method: string | undefined, code: string) => {
|
||||
const m = method || 'transfer'
|
||||
if (code === 'transfer') return m === 'transfer' || m === '계좌'
|
||||
if (code === 'card') return m === 'card' || m === '카드'
|
||||
if (code === 'giro' || code === 'paper') return m === 'giro' || m === 'paper' || m === '지로'
|
||||
if (code === 'phone' || code === 'telephone') return m === 'phone' || m === 'telephone' || m === '전화납부'
|
||||
return false
|
||||
}
|
||||
|
||||
/** 업무 진행 팝업 상단 read-only 섹션 */
|
||||
export default function ReceptionProgressReadOnly({ data }: { data: ProgressDetail | null }) {
|
||||
if (!data) return null
|
||||
const customer = data.customer || {}
|
||||
const payment = data.payment || data.paymentCard || data.paymentTransfer || {}
|
||||
const product = data.product || {}
|
||||
const gift = data.gift || {}
|
||||
const reception = data.reception || data
|
||||
const paymentMethod = payment.method || reception.paymentMethod || reception.paymentType
|
||||
const productType = product.productType || reception.productType || 'e01'
|
||||
const ctype = customer.type || reception.customerType || 'public'
|
||||
const amount = Number(product.price ?? reception.amount ?? 0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<PrgSection title="영업점 정보">
|
||||
<table className="prg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>영업점명</th>
|
||||
<td>{data.branchOfficeName || reception.branch || '-'}</td>
|
||||
<th>연락처</th>
|
||||
<td>{[data.branchPhone, data.branchTel, reception.branchPhone].filter(Boolean).join(', ') || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
|
||||
<PrgSection title="고객정보">
|
||||
<div className="prg-inline" style={{ justifyContent: 'center', marginBottom: 8 }}>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={ctype === 'public'} /> 개인</label>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={ctype === 'company'} /> 사업자</label>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={ctype === 'foreigner'} /> 외국인</label>
|
||||
</div>
|
||||
<table className="prg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>고객명</th>
|
||||
<td>{customer.name || reception.customerName || '-'}</td>
|
||||
<th>포스트잇</th>
|
||||
<td>{customer.postIt || reception.postIt || ''}</td>
|
||||
</tr>
|
||||
{(customer.postAdd || reception.postAdd) && (
|
||||
<tr>
|
||||
<th>추가메모</th>
|
||||
<td colSpan={3}>{customer.postAdd || reception.postAdd}</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<th>식별번호</th>
|
||||
<td>{customer.ssid || reception.identifyNumber || ''}</td>
|
||||
<th>고객유형</th>
|
||||
<td>{ctype === 'company' ? '사업자' : ctype === 'foreigner' ? '외국인' : '개인'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>휴대전화</th>
|
||||
<td>{customer.phone || reception.phone || '-'}</td>
|
||||
<th>유선전화</th>
|
||||
<td>{customer.tel || reception.tel || ''}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>이메일</th>
|
||||
<td colSpan={3}>{customer.email || reception.email || ''}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>주소</th>
|
||||
<td colSpan={3}>
|
||||
{[customer.zipcode || reception.zipcode ? `[${customer.zipcode || reception.zipcode}]` : '',
|
||||
customer.address || reception.address,
|
||||
customer.addressDetail || reception.addressDetail].filter(Boolean).join(' ') || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
{(customer.sido || reception.region) && (
|
||||
<tr>
|
||||
<th>지역</th>
|
||||
<td colSpan={3}>{customer.sido || reception.region}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
|
||||
<PrgSection title="결제납입정보">
|
||||
<table className="prg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>결제방법</th>
|
||||
<td colSpan={3}>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={pmChecked(paymentMethod, 'transfer')} /> 자동이체</label>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={pmChecked(paymentMethod, 'card')} /> 카드</label>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={pmChecked(paymentMethod, 'giro')} /> 지로</label>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={pmChecked(paymentMethod, 'phone')} /> 전화번호요금통합</label>
|
||||
</td>
|
||||
</tr>
|
||||
{(payment.name || payment.bank || payment.cardCompany) && (
|
||||
<>
|
||||
<tr>
|
||||
<th>납입자명</th>
|
||||
<td>{payment.name || ''}</td>
|
||||
<th>주민번호</th>
|
||||
<td>{payment.ssid || ''}</td>
|
||||
</tr>
|
||||
{pmChecked(paymentMethod, 'card') ? (
|
||||
<>
|
||||
<tr>
|
||||
<th>카드사</th>
|
||||
<td>{payment.cardCompany || ''}</td>
|
||||
<th>유효기간</th>
|
||||
<td>{[payment.cardYear, payment.cardMonth].filter(Boolean).join('/')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>카드번호</th>
|
||||
<td colSpan={3}>{payment.cardNumber || ''}</td>
|
||||
</tr>
|
||||
</>
|
||||
) : pmChecked(paymentMethod, 'transfer') ? (
|
||||
<tr>
|
||||
<th>은행</th>
|
||||
<td>{payment.bank || ''}</td>
|
||||
<th>계좌번호</th>
|
||||
<td>{payment.accountNumber || ''}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
|
||||
<PrgSection title="상품 정보">
|
||||
<table className="prg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>통신사</th>
|
||||
<td colSpan={3}>{data.ispName || reception.ispName || reception.carrier || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>상품종류</th>
|
||||
<td colSpan={3}>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={productType === 'e01' || (!productType && reception.productCategory === '인터넷')} /> 인터넷</label>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={productType === 'e02' || reception.productCategory === '전화'} /> 전화</label>
|
||||
<label className="prg-chk"><input type="radio" readOnly checked={productType === 'e03' || reception.productCategory === 'TV'} /> TV</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{productType === 'e02' ? '전화' : productType === 'e03' ? 'TV' : '인터넷'}</th>
|
||||
<td colSpan={3} style={{ padding: 4 }}>
|
||||
<table className="prg-table" style={{ margin: 0 }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>상품</th>
|
||||
<td>{data.productName || product.productName || product.name || reception.productName || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>약정</th>
|
||||
<td>{data.agreementName || reception.agreementName || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>이벤트</th>
|
||||
<td>{(data.eventNames || []).join(', ') || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>월사용요금</th>
|
||||
<td>{amount.toLocaleString()} 원</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>총 요금</th>
|
||||
<td colSpan={3}>{amount.toLocaleString()} 원</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>할인혜택</th>
|
||||
<td colSpan={3}>-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>설치희망일자</th>
|
||||
<td>{reception.openDate || '-'}</td>
|
||||
<th>연락처</th>
|
||||
<td>{reception.progressStatus || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
|
||||
{(gift.status || gift.category || reception.giftStatus) && (
|
||||
<PrgSection title="사은품 정보">
|
||||
<table className="prg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>사은품분류</th>
|
||||
<td>{gift.category || reception.giftCategory || '-'}</td>
|
||||
<th>진행상황</th>
|
||||
<td>{gift.status || reception.giftStatus || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>지급처</th>
|
||||
<td>{gift.place || reception.giftPlace || '-'}</td>
|
||||
<th>지급일</th>
|
||||
<td>{gift.giftDate || reception.giftDate || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
)}
|
||||
|
||||
{(data.etc?.memo || reception.memo) && (
|
||||
<PrgSection title="기타 정보">
|
||||
<table className="prg-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>메모</th>
|
||||
<td colSpan={3} style={{ whiteSpace: 'pre-wrap' }}>{data.etc?.memo || reception.memo}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</PrgSection>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Form, Input, TimePicker, message } from 'antd'
|
||||
import Modal from './DraggableModal'
|
||||
import { CloseOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import 'dayjs/locale/ko'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../api/client'
|
||||
|
||||
dayjs.locale('ko')
|
||||
|
||||
type ScheduleRow = {
|
||||
id: number
|
||||
sdate?: string
|
||||
stime?: string
|
||||
contents?: string
|
||||
displayTime?: string
|
||||
}
|
||||
|
||||
type MonthData = {
|
||||
days?: Record<string, number>
|
||||
}
|
||||
|
||||
const weekDays = ['일', '월', '화', '수', '목', '금', '토']
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function SchedulePanel({ open, onClose }: Props) {
|
||||
const qc = useQueryClient()
|
||||
const [now, setNow] = useState(() => dayjs())
|
||||
const [cursor, setCursor] = useState(() => dayjs())
|
||||
const [selected, setSelected] = useState(() => dayjs())
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const t = window.setInterval(() => setNow(dayjs()), 1000)
|
||||
return () => window.clearInterval(t)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const today = dayjs()
|
||||
setNow(today)
|
||||
setCursor(today)
|
||||
setSelected(today)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const selectedKey = selected.format('YYYY-MM-DD')
|
||||
const year = cursor.year()
|
||||
const month = cursor.month() + 1
|
||||
|
||||
const monthQuery = useQuery({
|
||||
queryKey: ['schedules-month', year, month],
|
||||
enabled: open,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<MonthData>>(`${apiPrefix()}/schedules/month`, {
|
||||
params: { year, month },
|
||||
})
|
||||
return unwrap(data)
|
||||
},
|
||||
})
|
||||
|
||||
const dayQuery = useQuery({
|
||||
queryKey: ['schedules-day', selectedKey],
|
||||
enabled: open,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ total: number; list: ScheduleRow[] }>>(
|
||||
`${apiPrefix()}/schedules/search`,
|
||||
{ params: { sdate: selectedKey, size: 100 } },
|
||||
)
|
||||
return unwrap(data)
|
||||
},
|
||||
})
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ['schedules-month'] })
|
||||
qc.invalidateQueries({ queryKey: ['schedules-day'] })
|
||||
qc.invalidateQueries({ queryKey: ['schedules'] })
|
||||
}
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async (values: { contents: string; stime?: Dayjs }) => {
|
||||
const body = {
|
||||
sdate: selectedKey,
|
||||
contents: values.contents,
|
||||
stime: values.stime ? values.stime.format('HH:mm') : undefined,
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/schedules`, body)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('일정을 등록했습니다.')
|
||||
setCreateOpen(false)
|
||||
form.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/schedules/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('일정을 삭제했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const cells = useMemo(() => {
|
||||
const start = cursor.startOf('month')
|
||||
const startWeekday = start.day()
|
||||
const daysInMonth = cursor.daysInMonth()
|
||||
const prevMonth = cursor.subtract(1, 'month')
|
||||
const prevDays = prevMonth.daysInMonth()
|
||||
const list: { date: Dayjs; current: boolean }[] = []
|
||||
for (let i = startWeekday - 1; i >= 0; i -= 1) {
|
||||
list.push({ date: prevMonth.date(prevDays - i), current: false })
|
||||
}
|
||||
for (let d = 1; d <= daysInMonth; d += 1) {
|
||||
list.push({ date: cursor.date(d), current: true })
|
||||
}
|
||||
while (list.length % 7 !== 0) {
|
||||
const next = list[list.length - 1].date.add(1, 'day')
|
||||
list.push({ date: next, current: false })
|
||||
}
|
||||
return list
|
||||
}, [cursor])
|
||||
|
||||
const eventDays = monthQuery.data?.days || {}
|
||||
const events = dayQuery.data?.list || []
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="schedule-panel-mask" onClick={onClose} />
|
||||
<aside className="schedule-panel" aria-label="일정표">
|
||||
<div className="schedule-panel-head">일정표</div>
|
||||
|
||||
<div className="schedule-panel-body">
|
||||
<div className="schedule-block">
|
||||
<div className="schedule-label">TIME & DATE</div>
|
||||
<div className="schedule-clock">{now.format('hh:mm:ss A')}</div>
|
||||
<div className="schedule-today">{now.format('YYYY년 MM월 DD일 dddd')}</div>
|
||||
</div>
|
||||
|
||||
<div className="schedule-block">
|
||||
<div className="schedule-label">CALENDAR</div>
|
||||
<div className="schedule-calendar">
|
||||
<div className="schedule-cal-nav">
|
||||
<button type="button" onClick={() => setCursor((c) => c.subtract(1, 'month'))} aria-label="이전 달">
|
||||
<LeftOutlined />
|
||||
</button>
|
||||
<span>{cursor.format('YYYY년 M월')}</span>
|
||||
<button type="button" onClick={() => setCursor((c) => c.add(1, 'month'))} aria-label="다음 달">
|
||||
<RightOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="schedule-cal-weeks">
|
||||
{weekDays.map((d, i) => (
|
||||
<span key={d} className={i === 0 || i === 6 ? 'weekend' : undefined}>{d}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="schedule-cal-grid">
|
||||
{cells.map(({ date, current }) => {
|
||||
const key = date.format('YYYY-MM-DD')
|
||||
const isSelected = key === selectedKey
|
||||
const isToday = key === now.format('YYYY-MM-DD')
|
||||
const hasEvent = !!eventDays[key]
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={[
|
||||
'schedule-day',
|
||||
current ? 'current' : 'other',
|
||||
isSelected ? 'selected' : '',
|
||||
isToday ? 'today' : '',
|
||||
hasEvent ? 'has-event' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => {
|
||||
setSelected(date)
|
||||
if (!current) setCursor(date)
|
||||
}}
|
||||
>
|
||||
{date.date()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="schedule-block schedule-events">
|
||||
<div className="schedule-event-head">
|
||||
<span>{selectedKey} EVENT</span>
|
||||
<span>{events.length}건</span>
|
||||
</div>
|
||||
<div className="schedule-event-list">
|
||||
{events.length === 0 ? (
|
||||
<div className="schedule-event-empty">등록된 일정이 없습니다.</div>
|
||||
) : (
|
||||
events.map((ev) => (
|
||||
<div key={ev.id} className="schedule-event-card">
|
||||
<button
|
||||
type="button"
|
||||
className="schedule-event-del"
|
||||
aria-label="삭제"
|
||||
onClick={() => deleteMut.mutate(ev.id)}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
<div className="schedule-event-title">{ev.contents}</div>
|
||||
<div className="schedule-event-time">{ev.displayTime || ev.stime || '-'}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="schedule-panel-foot">
|
||||
<button
|
||||
type="button"
|
||||
className="schedule-add-btn"
|
||||
onClick={() => {
|
||||
form.setFieldsValue({ contents: undefined, stime: dayjs('09:00', 'HH:mm') })
|
||||
setCreateOpen(true)
|
||||
}}
|
||||
>
|
||||
+ 일정 등록
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Modal
|
||||
title={`${selectedKey} 일정 등록`}
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => createMut.mutate(v)}>
|
||||
<Form.Item name="contents" label="내용" rules={[{ required: true, message: '내용을 입력하세요.' }]}>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item name="stime" label="시간">
|
||||
<TimePicker format="HH:mm" className="full-width" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<div className="bbs-write-actions">
|
||||
<Button type="primary" htmlType="submit" loading={createMut.isPending}>등록</Button>
|
||||
<Button onClick={() => setCreateOpen(false)}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Input, Space, message } from 'antd'
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import Modal from './DraggableModal'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
items: string[]
|
||||
saving?: boolean
|
||||
submitClassName?: string
|
||||
onCancel: () => void
|
||||
onSave: (items: string[]) => void
|
||||
}
|
||||
|
||||
export default function SubjectSettingsModal({
|
||||
open,
|
||||
items,
|
||||
saving,
|
||||
submitClassName,
|
||||
onCancel,
|
||||
onSave }: Props) {
|
||||
const [list, setList] = useState<string[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setList(items.length ? [...items] : [])
|
||||
setInput('')
|
||||
// 팝업이 열릴 때만 초기화 (편집 중 items 참조 변경으로 목록이 리셋되지 않도록)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
const addItem = () => {
|
||||
const value = input.trim()
|
||||
if (!value) {
|
||||
message.warning('과목을 입력하세요.')
|
||||
return
|
||||
}
|
||||
if (list.some((item) => item === value)) {
|
||||
message.warning('이미 등록된 과목입니다.')
|
||||
return
|
||||
}
|
||||
setList((prev) => [...prev, value])
|
||||
setInput('')
|
||||
}
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
setList((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!list.length) {
|
||||
message.warning('과목을 1개 이상 등록하세요.')
|
||||
return
|
||||
}
|
||||
onSave(list)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="과목설정"
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={420}
|
||||
>
|
||||
<div className="subject-settings">
|
||||
<Space.Compact className="full-width subject-settings-add">
|
||||
<Input
|
||||
value={input}
|
||||
placeholder="과목명 입력"
|
||||
maxLength={40}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onPressEnter={(e) => {
|
||||
e.preventDefault()
|
||||
addItem()
|
||||
}}
|
||||
/>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={addItem}>
|
||||
추가
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
<ul className="subject-settings-list">
|
||||
{list.map((item, index) => (
|
||||
<li key={`${item}-${index}`} className="subject-settings-item">
|
||||
<span className="subject-settings-name">{item}</span>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label={`${item} 삭제`}
|
||||
onClick={() => removeItem(index)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
{!list.length ? (
|
||||
<li className="subject-settings-empty">등록된 과목이 없습니다.</li>
|
||||
) : null}
|
||||
</ul>
|
||||
|
||||
<div className="balance-modal-footer">
|
||||
<Button
|
||||
className={submitClassName}
|
||||
type="primary"
|
||||
loading={saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
<Button onClick={onCancel}>취소</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** Ant Design Form requiredMark: * 대신 '필수' 뱃지 */
|
||||
export function formRequiredMark(label: ReactNode, { required }: { required: boolean }) {
|
||||
if (!required) return label
|
||||
return (
|
||||
<>
|
||||
{label}
|
||||
<span className="form-req">필수</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
/* ===== 테마 토큰 (billing_react 방식) =====
|
||||
html.theme-* 에만 토큰을 두어 :root 와 충돌하지 않게 한다. */
|
||||
html.theme-light {
|
||||
--primary: #2563eb;
|
||||
--primary-dark: #1d4ed8;
|
||||
--on-primary: #ffffff;
|
||||
|
||||
--bg: #f1f5f9;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f8fafc;
|
||||
--surface-3: #ffffff;
|
||||
--elevated: #eff6ff;
|
||||
|
||||
--text: #0f172a;
|
||||
--muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
|
||||
--danger: #dc2626;
|
||||
--success: #16a34a;
|
||||
|
||||
--highlight-bg: #fffbe6;
|
||||
--highlight-bg-strong: #fff3bf;
|
||||
--highlight-text: #0f172a;
|
||||
|
||||
--shadow: 0 8px 24px rgba(0,0,0,.14);
|
||||
--shadow-lg: 0 20px 60px rgba(0,0,0,.3);
|
||||
--focus-ring: #bfdbfe;
|
||||
|
||||
--navy: #152536;
|
||||
--blue: #1769aa;
|
||||
--line: var(--border);
|
||||
}
|
||||
|
||||
html.theme-dark {
|
||||
--primary: #7aa2f7;
|
||||
--primary-dark: #5a7dc7;
|
||||
--on-primary: #0b1020;
|
||||
|
||||
--bg: #16181e;
|
||||
--surface: #24283b;
|
||||
--surface-2: #1f2335;
|
||||
--surface-3: #1a1b26;
|
||||
--elevated: #2a2f45;
|
||||
|
||||
--text: #c0caf5;
|
||||
--muted: #8a93b8;
|
||||
--border: rgba(122, 162, 247, 0.16);
|
||||
|
||||
--danger: #f7768e;
|
||||
--success: #9ece6a;
|
||||
|
||||
--highlight-bg: #2f3650;
|
||||
--highlight-bg-strong: #3a4463;
|
||||
--highlight-text: #c0caf5;
|
||||
|
||||
--shadow: 0 8px 24px rgba(0,0,0,.5);
|
||||
--shadow-lg: 0 20px 60px rgba(0,0,0,.45);
|
||||
--focus-ring: rgba(122, 162, 247, 0.5);
|
||||
}
|
||||
|
||||
html.theme-blue {
|
||||
/* 버튼·강조: 흰 글씨가 읽히도록 채도 높은 중청색 */
|
||||
--primary: #1e8ad4;
|
||||
--primary-dark: #1670ad;
|
||||
--on-primary: #ffffff;
|
||||
|
||||
--bg: #0a1522;
|
||||
--surface: #15273d;
|
||||
--surface-2: #0f1e30;
|
||||
--surface-3: #0c1929;
|
||||
--elevated: #1e3a5f;
|
||||
|
||||
--text: #e8f4ff;
|
||||
--muted: #8fbbe0;
|
||||
--border: rgba(94, 181, 255, 0.16);
|
||||
|
||||
--danger: #ff6b6b;
|
||||
--success: #5ce0a0;
|
||||
|
||||
--highlight-bg: #1a3352;
|
||||
--highlight-bg-strong: #21406a;
|
||||
--highlight-text: #e8f4ff;
|
||||
|
||||
--shadow: 0 8px 24px rgba(0,0,0,.5);
|
||||
--shadow-lg: 0 20px 60px rgba(0,0,0,.45);
|
||||
--focus-ring: rgba(30, 138, 212, 0.45);
|
||||
}
|
||||
|
||||
/* 테마 클래스 적용 전 깜빡임 방지용 기본값 = 라이트 (:root 는 theme-* 보다 특이도가 낮음) */
|
||||
html:not(.theme-light):not(.theme-dark):not(.theme-blue),
|
||||
:root:not(.theme-light):not(.theme-dark):not(.theme-blue) {
|
||||
--primary: #2563eb;
|
||||
--primary-dark: #1d4ed8;
|
||||
--on-primary: #ffffff;
|
||||
--bg: #f1f5f9;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f8fafc;
|
||||
--surface-3: #ffffff;
|
||||
--elevated: #eff6ff;
|
||||
--text: #0f172a;
|
||||
--muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--danger: #dc2626;
|
||||
--success: #16a34a;
|
||||
--highlight-bg: #fffbe6;
|
||||
--highlight-bg-strong: #fff3bf;
|
||||
--highlight-text: #0f172a;
|
||||
--shadow: 0 8px 24px rgba(0,0,0,.14);
|
||||
--shadow-lg: 0 20px 60px rgba(0,0,0,.3);
|
||||
--focus-ring: #bfdbfe;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Pretendard', 'Noto Sans KR', -apple-system, 'Malgun Gothic', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
}
|
||||
#root { min-height: 100vh; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
/* ===== App shell (billing_react Layout) ===== */
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.app-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 52px;
|
||||
background: var(--surface-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-shrink: 0;
|
||||
z-index: 30;
|
||||
}
|
||||
.topbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 14px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface-3);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.topbar-brand.is-collapsed {
|
||||
width: 0 !important;
|
||||
padding: 0;
|
||||
border-right: none;
|
||||
}
|
||||
.brand-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.topbar-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.topbar-left-tools { min-width: 0; overflow: hidden; }
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.topbar-user { font-size: 12px; white-space: nowrap; }
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
.theme-select {
|
||||
width: auto;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.leftpanel {
|
||||
flex-shrink: 0;
|
||||
background: var(--surface-3);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 12px 10px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--primary) 45%, var(--muted)) var(--surface-2);
|
||||
}
|
||||
.leftpanel::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.leftpanel::-webkit-scrollbar-track {
|
||||
background: var(--surface-2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.leftpanel::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--primary) 40%, var(--muted));
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--surface-2);
|
||||
}
|
||||
.leftpanel::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--primary) 70%, var(--muted));
|
||||
}
|
||||
|
||||
.main-area .content,
|
||||
.content {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--primary) 40%, var(--muted)) var(--surface-2);
|
||||
}
|
||||
.main-area .content::-webkit-scrollbar,
|
||||
.content::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
.main-area .content::-webkit-scrollbar-track,
|
||||
.content::-webkit-scrollbar-track {
|
||||
background: var(--bg);
|
||||
}
|
||||
.main-area .content::-webkit-scrollbar-thumb,
|
||||
.content::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--primary) 35%, var(--muted));
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--bg);
|
||||
}
|
||||
.main-area .content::-webkit-scrollbar-thumb:hover,
|
||||
.content::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--primary) 65%, var(--muted));
|
||||
}
|
||||
.lp-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.lp-card.is-active {
|
||||
border-color: color-mix(in srgb, var(--primary) 45%, var(--border));
|
||||
}
|
||||
.lp-card-title {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 9px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
background: var(--surface-2);
|
||||
border: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.lp-card:has(.lp-card-body) .lp-card-title,
|
||||
.lp-card-title[aria-expanded="true"] {
|
||||
border-bottom-color: var(--border);
|
||||
}
|
||||
.lp-card-chevron {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.lp-card-body { padding: 6px; }
|
||||
.leftpanel a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
border-radius: 7px;
|
||||
color: var(--muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.leftpanel a:hover { background: var(--surface-2); color: var(--text); }
|
||||
.leftpanel a.active {
|
||||
color: var(--on-primary);
|
||||
background: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel-resizer {
|
||||
width: 3px;
|
||||
flex-shrink: 0;
|
||||
cursor: col-resize;
|
||||
background: var(--surface-3);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.panel-resizer:hover,
|
||||
.panel-resizer.dragging {
|
||||
background: var(--primary);
|
||||
border-right-color: var(--primary);
|
||||
}
|
||||
.panel-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 20;
|
||||
width: 15px;
|
||||
height: 42px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface-3);
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-left: none;
|
||||
border-radius: 0 8px 8px 0;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
.panel-toggle:hover { color: var(--primary); background: var(--elevated); }
|
||||
|
||||
.main-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
}
|
||||
.main-area .content {
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* legacy stubs kept for pages that still reference them */
|
||||
.admin-layout { min-height: 100vh; }
|
||||
.side { background: var(--surface-3) !important; }
|
||||
.brand {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
color: var(--text);
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.brand span { color: var(--primary); margin-left: 5px; }
|
||||
.header {
|
||||
background: var(--surface) !important;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 0 28px !important;
|
||||
}
|
||||
.content { padding: 26px; overflow: auto; }
|
||||
.page-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
color: var(--text);
|
||||
}
|
||||
.page-title .ant-typography { margin: 0; font-weight: 700; color: var(--text) !important; }
|
||||
.filter-card { margin-bottom: 16px; background: var(--surface-2); }
|
||||
.dashboard-row { margin-top: 16px; }
|
||||
.login-wrap {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1e293b, #2563eb);
|
||||
padding: 24px;
|
||||
}
|
||||
.login-box { width: 400px; max-width: 100%; }
|
||||
.login-demo { color: #64748b; font-size: 12px; margin-top: 16px; text-align: center; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.main-area .content { padding: 16px; }
|
||||
.content { padding: 16px; }
|
||||
.page-title { align-items: flex-start; gap: 10px; flex-direction: column; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { applyTheme, loadTheme } from './theme'
|
||||
import { ThemeProvider } from './themeContext'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
applyTheme(loadTheme())
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: (failureCount, error) => {
|
||||
const status = (error as { response?: { status?: number } })?.response?.status
|
||||
if (status === 401 || status === 403) return false
|
||||
return failureCount < 3
|
||||
},
|
||||
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 5000),
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,190 @@
|
||||
export type MenuItem = {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type MenuSection = {
|
||||
id: string
|
||||
title: string
|
||||
items: MenuItem[]
|
||||
}
|
||||
|
||||
/** 비즈케어홈즈(h.bizcare.co.kr) 실제 메뉴 — 경로 그대로 */
|
||||
export function careMenuSections(): MenuSection[] {
|
||||
return [
|
||||
{
|
||||
id: 'home',
|
||||
title: '대시보드',
|
||||
items: [{ key: '/care/main', label: '대시보드' }],
|
||||
},
|
||||
{
|
||||
id: 'product',
|
||||
title: '상품관리',
|
||||
items: [
|
||||
{ key: '/care/product/internet', label: '인터넷상품' },
|
||||
{ key: '/care/product/home', label: '홈렌탈상품' },
|
||||
{ key: '/care/product/log', label: '상품이력' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'contract',
|
||||
title: '계약관리',
|
||||
items: [
|
||||
{ key: '/care/contract/internet', label: '인터넷접수' },
|
||||
{ key: '/care/contract/home', label: '홈렌탈접수' },
|
||||
{ key: '/care/contract/log', label: '계약이력' },
|
||||
{ key: '/care/contract/adjust', label: '일괄정책변경' },
|
||||
{ key: '/care/contract/balance', label: '계약후정산' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reception',
|
||||
title: '접수관리',
|
||||
items: [
|
||||
{ key: '/care/reception/list', label: '접수리스트' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'invoice',
|
||||
title: '정산관리',
|
||||
items: [
|
||||
{ key: '/care/invoice/publish', label: '정산서발행' },
|
||||
{ key: '/care/invoice/my', label: '나의정산서' },
|
||||
{ key: '/care/invoice/log', label: '정산서이력' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pending',
|
||||
title: '일정관리',
|
||||
items: [
|
||||
{ key: '/care/pending/calendar', label: '캘린더' },
|
||||
{ key: '/care/pending/pending', label: '일정관리' },
|
||||
{ key: '/care/pending/log', label: '일정이력' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'booking',
|
||||
title: '예약관리',
|
||||
items: [
|
||||
{ key: '/care/booking/booking', label: '예약관리' },
|
||||
{ key: '/care/booking/log', label: '예약이력' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'customer',
|
||||
title: '고객관리',
|
||||
items: [
|
||||
{ key: '/care/customer/customer', label: '고객관리' },
|
||||
{ key: '/care/customer/log', label: '고객이력' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'abooks',
|
||||
title: '장부관리',
|
||||
items: [
|
||||
{ key: '/care/abooks/abooks-mon', label: '간편장부' },
|
||||
{ key: '/care/abooks/log', label: '장부이력' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'report',
|
||||
title: '통계관리',
|
||||
items: [
|
||||
{ key: '/care/report/daily', label: '일일리포트' },
|
||||
{ key: '/care/report/monthly', label: '월간리포트' },
|
||||
{ key: '/care/report/internet', label: '인터넷통계' },
|
||||
{ key: '/care/report/home', label: '홈렌탈통계' },
|
||||
{ key: '/care/report/member', label: '직원별통계' },
|
||||
{ key: '/care/report/agency', label: '거래처별통계' },
|
||||
{ key: '/care/report/incentive', label: '인센티브계산' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'setting',
|
||||
title: '환경설정',
|
||||
items: [
|
||||
{ key: '/care/setting/agency', label: '거래처관리' },
|
||||
{ key: '/care/setting/member', label: '직원관리' },
|
||||
{ key: '/care/setting/preference', label: '기본설정' },
|
||||
{ key: '/care/setting/level', label: '권한설정' },
|
||||
{ key: '/care/setting/partner1', label: '상부점관리' },
|
||||
{ key: '/care/setting/partner2', label: '하부점관리' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cs',
|
||||
title: '고객센터',
|
||||
items: [
|
||||
{ key: '/care/cs/notice', label: '공지사항' },
|
||||
{ key: '/care/cs/question', label: '사용문의' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'bbs',
|
||||
title: '사내게시판',
|
||||
items: [{ key: '/care/bbs/bbs', label: '사내게시판' }],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** @deprecated 호환용 */
|
||||
export function agentMenuSections(): MenuSection[] {
|
||||
return careMenuSections()
|
||||
}
|
||||
|
||||
export function shopMenuSections(): MenuSection[] {
|
||||
return [
|
||||
{
|
||||
id: 'shop',
|
||||
title: '판매점',
|
||||
items: [
|
||||
{ key: '/shop/notice', label: '공지사항' },
|
||||
{ key: '/shop/policy', label: '정책정보' },
|
||||
{ key: '/shop/customer', label: '고객조회' },
|
||||
{ key: '/shop/contract', label: '계약조회' },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** 권한설정·노출설정에서 허용된 섹션 id만 남김. allowedIds가 null이면 전부 표시(로딩 전). */
|
||||
export function filterMenuSections(
|
||||
sections: MenuSection[],
|
||||
allowedIds: string[] | null | undefined,
|
||||
): MenuSection[] {
|
||||
if (!allowedIds) return sections
|
||||
const set = new Set(allowedIds)
|
||||
return sections.filter((sec) => set.has(sec.id))
|
||||
}
|
||||
|
||||
export const SITEMAP_TITLE_TO_ID: Record<string, string> = {
|
||||
대시보드: 'home',
|
||||
상품관리: 'product',
|
||||
계약관리: 'contract',
|
||||
접수관리: 'reception',
|
||||
정산관리: 'invoice',
|
||||
일정관리: 'pending',
|
||||
예약관리: 'booking',
|
||||
고객관리: 'customer',
|
||||
장부관리: 'abooks',
|
||||
통계관리: 'report',
|
||||
환경설정: 'setting',
|
||||
고객센터: 'cs',
|
||||
사내게시판: 'bbs',
|
||||
게시판: 'bbs',
|
||||
}
|
||||
|
||||
export function resolveSelectedKey(pathname: string): string {
|
||||
if (pathname.startsWith('/care/contract/write')) {
|
||||
const q = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('type') : null
|
||||
if (q === 'home') return '/care/contract/home'
|
||||
return '/care/contract/internet'
|
||||
}
|
||||
if (pathname === '/care/sitemap') return '/care/main/sitemap'
|
||||
return pathname
|
||||
}
|
||||
|
||||
export function sectionContainsPath(section: MenuSection, pathname: string): boolean {
|
||||
const selected = resolveSelectedKey(pathname)
|
||||
return section.items.some((it) => selected === it.key || selected.startsWith(`${it.key}/`))
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { type FormEvent, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { login } from '../api/auth'
|
||||
import BillCareLogo from '../components/BillCareLogo'
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const [userid, setUserid] = useState('demo')
|
||||
const [password, setPassword] = useState('demo123')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await login(userid, password)
|
||||
localStorage.setItem('token', data.token!)
|
||||
localStorage.setItem('role', data.role === 'SHOP' ? 'SHOP' : 'AGENT')
|
||||
localStorage.setItem('userName', data.name)
|
||||
localStorage.setItem('userid', data.userid)
|
||||
if (data.staffPermission) localStorage.setItem('staffPermission', data.staffPermission)
|
||||
else localStorage.removeItem('staffPermission')
|
||||
navigate('/care/main', { replace: true })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '로그인에 실패했습니다.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-wrap">
|
||||
<form className="login-box" onSubmit={submit}>
|
||||
<div className="login-brand">
|
||||
<BillCareLogo title="BillCare" size="login" />
|
||||
<p>통합 영업·정산 관리 시스템</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="login-userid">아이디</label>
|
||||
<input
|
||||
id="login-userid"
|
||||
value={userid}
|
||||
onChange={(e) => setUserid(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="login-password">비밀번호</label>
|
||||
<input
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
<button type="submit" className="login-submit" disabled={loading}>
|
||||
{loading ? '로그인 중...' : '로그인'}
|
||||
</button>
|
||||
<div className="login-demo">데모: demo / demo123</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { BookOutlined } from '@ant-design/icons'
|
||||
import { HomesLogListPage } from './HomesLogListPage'
|
||||
|
||||
const ACTIONS = [
|
||||
'간편장부-등록',
|
||||
'간편장부-수정',
|
||||
'간편장부-삭제',
|
||||
]
|
||||
|
||||
export default function AbooksLogPage() {
|
||||
return (
|
||||
<HomesLogListPage
|
||||
domain="ABOOKS"
|
||||
title="장부이력"
|
||||
description="장부관리의 변동이력을 확인하실 수 있습니다."
|
||||
defaultActions={ACTIONS}
|
||||
titleIcon={<BookOutlined className="product-title-icon" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Tabs,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import SubjectSettingsModal from '../../components/SubjectSettingsModal'
|
||||
import {
|
||||
BookOutlined,
|
||||
CloseOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
LeftOutlined,
|
||||
PlusSquareOutlined,
|
||||
ReloadOutlined,
|
||||
RightOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { options } from './homesShared'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
type AbookRow = {
|
||||
id: number
|
||||
entryDate?: string
|
||||
entryType?: string
|
||||
category?: string
|
||||
item?: string
|
||||
amount?: number
|
||||
deposit?: number | null
|
||||
withdraw?: number | null
|
||||
cardDeposit?: number | null
|
||||
cardWithdraw?: number | null
|
||||
memo?: string
|
||||
employee?: string
|
||||
}
|
||||
|
||||
type AbookSummary = {
|
||||
carryOver?: number
|
||||
deposit?: number
|
||||
withdraw?: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
type AbookResult = {
|
||||
total: number
|
||||
list: AbookRow[]
|
||||
counts?: Record<string, number>
|
||||
categoryCounts?: Record<string, number>
|
||||
categories?: string[]
|
||||
employees?: string[]
|
||||
items?: string[]
|
||||
summary?: AbookSummary
|
||||
}
|
||||
|
||||
const ENTRY_TYPES = ['입금', '출금', '카드입금', '카드출금'] as const
|
||||
|
||||
const money = (v?: number | null) => {
|
||||
if (v == null || Number.isNaN(Number(v))) return '-'
|
||||
return Number(v).toLocaleString('ko-KR')
|
||||
}
|
||||
|
||||
const isOut = (type?: string) => type === '출금' || type === '카드출금'
|
||||
const typeClass = (type?: string) => {
|
||||
if (type === '입금' || type === '카드입금') return 'abook-in'
|
||||
if (isOut(type)) return 'abook-out'
|
||||
return ''
|
||||
}
|
||||
|
||||
export default function AbooksPage() {
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const qc = useQueryClient()
|
||||
const [searchParams] = useSearchParams()
|
||||
const initialView = searchParams.get('view') === 'detail' ? 'detail' : 'month'
|
||||
const [view, setView] = useState<'month' | 'detail'>(initialView)
|
||||
const [month, setMonth] = useState(dayjs())
|
||||
const [day, setDay] = useState<number | 'ALL'>('ALL')
|
||||
const [categoryTab, setCategoryTab] = useState('ALL')
|
||||
const [detailFilters, setDetailFilters] = useState<Record<string, unknown>>({
|
||||
page: 0,
|
||||
size: 10,
|
||||
tab: 'ALL' })
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [editing, setEditing] = useState<AbookRow | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [catOpen, setCatOpen] = useState(false)
|
||||
|
||||
const monthParams = useMemo(() => ({
|
||||
yearMonth: month.format('YYYY-MM'),
|
||||
day: day === 'ALL' ? undefined : day,
|
||||
category: categoryTab === 'ALL' ? undefined : categoryTab,
|
||||
page: 0,
|
||||
size: 200 }), [month, day, categoryTab])
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-abooks', view, view === 'month' ? monthParams : detailFilters],
|
||||
queryFn: async () => {
|
||||
const params = view === 'month'
|
||||
? monthParams
|
||||
: {
|
||||
...detailFilters,
|
||||
entryType: detailFilters.tab === 'ALL' ? undefined : detailFilters.tab }
|
||||
const { data } = await client.get<ApiResponse<AbookResult>>(`${apiPrefix()}/homes/abooks`, { params })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const members = useQuery({
|
||||
queryKey: ['homes/members-options'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ list: { id: number; name?: string }[] }>>(`${apiPrefix()}/homes/members`, {
|
||||
params: { page: 0, size: 200 } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ['homes-abooks'] })
|
||||
setSelected([])
|
||||
}
|
||||
|
||||
const staffOptions = useMemo(() => {
|
||||
const fromApi = query.data?.employees || []
|
||||
const fromMembers = (members.data?.list || []).map((m) => String(m.name || '')).filter(Boolean)
|
||||
return Array.from(new Set([...fromApi, ...fromMembers, '홍길동'])).map((v) => ({ value: v, label: v }))
|
||||
}, [query.data, members.data])
|
||||
|
||||
const categories = useMemo(
|
||||
() => query.data?.categories || ['일반', '123'],
|
||||
[query.data?.categories],
|
||||
)
|
||||
|
||||
const categoryTabs = useMemo(
|
||||
() => [
|
||||
{ key: 'ALL', label: `전체 ${query.data?.categoryCounts?.ALL ?? query.data?.total ?? 0}` },
|
||||
...categories.map((c) => ({
|
||||
key: c,
|
||||
label: `${c} ${query.data?.categoryCounts?.[c] ?? 0}` })),
|
||||
],
|
||||
[query.data, categories],
|
||||
)
|
||||
|
||||
const detailTabs = useMemo(
|
||||
() => [
|
||||
{ key: 'ALL', label: `전체 ${query.data?.counts?.ALL ?? query.data?.total ?? 0}` },
|
||||
...ENTRY_TYPES.map((t) => ({ key: t, label: `${t} ${query.data?.counts?.[t] ?? 0}` })),
|
||||
],
|
||||
[query.data],
|
||||
)
|
||||
|
||||
const daysInMonth = month.daysInMonth()
|
||||
const today = dayjs()
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
employee: values.employee,
|
||||
entryDate: values.entryDate ? dayjs(values.entryDate as Dayjs).format('YYYY-MM-DD') : undefined,
|
||||
entryType: values.entryType,
|
||||
category: values.category,
|
||||
item: values.item,
|
||||
amount: values.amount,
|
||||
memo: values.memo }
|
||||
if (editing) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/abooks/${editing.id}`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/abooks`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '수정했습니다.' : '간편장부를 등록했습니다.')
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/abooks/delete`, { ids })
|
||||
return unwrap(data as ApiResponse<{ deleted: number }>)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
message.success(`${data.deleted}건을 삭제했습니다.`)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const saveCategories = useMutation({
|
||||
mutationFn: async (cats: string[]) => {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/abooks/categories`, { categories: cats })
|
||||
return unwrap(data as ApiResponse<{ categories: string[] }>)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('과목을 저장했습니다.')
|
||||
setCatOpen(false)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setCreating(true)
|
||||
editForm.setFieldsValue({
|
||||
employee: staffOptions[0]?.value || '홍길동',
|
||||
entryDate: dayjs(),
|
||||
entryType: '입금',
|
||||
category: categories[0] || '일반',
|
||||
item: undefined,
|
||||
amount: undefined,
|
||||
memo: undefined })
|
||||
}
|
||||
|
||||
const openEdit = (row: AbookRow) => {
|
||||
setCreating(false)
|
||||
setEditing(row)
|
||||
editForm.setFieldsValue({
|
||||
employee: row.employee,
|
||||
entryDate: row.entryDate ? dayjs(row.entryDate) : dayjs(),
|
||||
entryType: row.entryType || '입금',
|
||||
category: row.category || '일반',
|
||||
item: row.item,
|
||||
amount: row.amount,
|
||||
memo: row.memo })
|
||||
}
|
||||
|
||||
const openCatSettings = () => setCatOpen(true)
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onDetailSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setDetailFilters({
|
||||
page: 0,
|
||||
size: values.size ?? detailFilters.size ?? 10,
|
||||
tab: detailFilters.tab,
|
||||
category: values.category || undefined,
|
||||
employee: values.employee || undefined,
|
||||
item: values.item || undefined,
|
||||
q: values.q || undefined,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD') })
|
||||
}
|
||||
|
||||
const resetDetail = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ size: 10 })
|
||||
setDetailFilters({ page: 0, size: 10, tab: 'ALL' })
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const params = view === 'month'
|
||||
? monthParams
|
||||
: { ...detailFilters, entryType: detailFilters.tab === 'ALL' ? undefined : detailFilters.tab }
|
||||
const { data } = await client.get(`${apiPrefix()}/homes/abooks/export`, {
|
||||
params,
|
||||
responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '간편장부.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const summary = query.data?.summary
|
||||
const summaryText = `현금시제 : (전월) 이월금 ${money(summary?.carryOver)} 원 + 입금 ${money(summary?.deposit)} 원 - 출금 ${money(summary?.withdraw)} 원 = 합계 ${money(summary?.total)} 원`
|
||||
|
||||
const monthColumns = [
|
||||
{ title: '거래일', dataIndex: 'entryDate', width: 110 },
|
||||
{ title: '과목', dataIndex: 'category', width: 90 },
|
||||
{ title: '품목', dataIndex: 'item', width: 140, ellipsis: true },
|
||||
{
|
||||
title: '입금(+)',
|
||||
dataIndex: 'deposit',
|
||||
width: 100,
|
||||
align: 'right' as const,
|
||||
render: (v: number | null) => <span className="abook-in">{v != null ? money(v) : '-'}</span> },
|
||||
{
|
||||
title: '출금(-)',
|
||||
dataIndex: 'withdraw',
|
||||
width: 100,
|
||||
align: 'right' as const,
|
||||
render: (v: number | null) => <span className="abook-out">{v != null ? money(v) : '-'}</span> },
|
||||
{
|
||||
title: '카드입금(+)',
|
||||
dataIndex: 'cardDeposit',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (v: number | null) => <span className="abook-in">{v != null ? money(v) : '-'}</span> },
|
||||
{
|
||||
title: '카드출금(-)',
|
||||
dataIndex: 'cardWithdraw',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (v: number | null) => <span className="abook-out">{v != null ? money(v) : '-'}</span> },
|
||||
{ title: '비고', dataIndex: 'memo', width: 140, ellipsis: true, render: (v: string) => v || '-' },
|
||||
{ title: '직원', dataIndex: 'employee', width: 90 },
|
||||
{
|
||||
title: '관리',
|
||||
width: 64,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: AbookRow) => (
|
||||
<Button size="small" type="primary" className="abook-manage-btn" icon={<EditOutlined />} onClick={() => openEdit(row)} />
|
||||
) },
|
||||
]
|
||||
|
||||
const detailColumns = [
|
||||
{
|
||||
title: (
|
||||
<Checkbox
|
||||
checked={!!query.data?.list?.length && selected.length === query.data.list.length}
|
||||
indeterminate={selected.length > 0 && selected.length < (query.data?.list?.length || 0)}
|
||||
onChange={(e) => setSelected(e.target.checked ? (query.data?.list || []).map((r) => r.id) : [])}
|
||||
/>
|
||||
),
|
||||
width: 42,
|
||||
render: (_: unknown, row: AbookRow) => (
|
||||
<Checkbox
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={(e) => setSelected((prev) => (e.target.checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)))}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '거래일',
|
||||
dataIndex: 'entryDate',
|
||||
width: 110,
|
||||
sorter: (a: AbookRow, b: AbookRow) => String(a.entryDate || '').localeCompare(String(b.entryDate || '')) },
|
||||
{
|
||||
title: '구분',
|
||||
dataIndex: 'entryType',
|
||||
width: 90,
|
||||
render: (v: string) => <span className={typeClass(v)}>{v}</span> },
|
||||
{ title: '과목', dataIndex: 'category', width: 90 },
|
||||
{ title: '품목', dataIndex: 'item', width: 140, ellipsis: true },
|
||||
{
|
||||
title: '금액',
|
||||
dataIndex: 'amount',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
sorter: (a: AbookRow, b: AbookRow) => Number(a.amount || 0) - Number(b.amount || 0),
|
||||
render: (v: number, row: AbookRow) => <span className={typeClass(row.entryType)}>{money(v)}</span> },
|
||||
{ title: '비고', dataIndex: 'memo', width: 140, ellipsis: true, render: (v: string) => v || '-' },
|
||||
{ title: '직원', dataIndex: 'employee', width: 90 },
|
||||
{
|
||||
title: '관리',
|
||||
width: 64,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: AbookRow) => (
|
||||
<Button size="small" type="primary" className="abook-manage-btn" icon={<EditOutlined />} onClick={() => openEdit(row)} />
|
||||
) },
|
||||
]
|
||||
|
||||
const modalOpen = creating || Boolean(editing)
|
||||
|
||||
return (
|
||||
<div className="stock-page abook-manage-page">
|
||||
<div className="stock-title abook-title-row">
|
||||
<div>
|
||||
<h2>
|
||||
<BookOutlined className="product-title-icon" />
|
||||
간편장부
|
||||
</h2>
|
||||
<p>매장의 시제 및 관련 장부관리를 관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
<div className="abook-view-toggle">
|
||||
<Button className={view === 'month' ? 'abook-view-active' : ''} onClick={() => setView('month')}>
|
||||
월별 간편장부
|
||||
</Button>
|
||||
<Button className={view === 'detail' ? 'abook-view-active' : ''} onClick={() => setView('detail')}>
|
||||
상세 간편장부
|
||||
</Button>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap>
|
||||
{view === 'detail' && (
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
loading={deleteMut.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('삭제할 장부를 선택하세요.')
|
||||
return
|
||||
}
|
||||
deleteMut.mutate(selected)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
<Button className="abook-register-btn" type="primary" icon={<PlusSquareOutlined />} onClick={openCreate}>
|
||||
간편장부 등록
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{view === 'month' ? (
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="abook-month-nav">
|
||||
<Button type="text" icon={<LeftOutlined />} onClick={() => { setMonth((m) => m.subtract(1, 'month')); setDay('ALL') }} />
|
||||
<span className="abook-month-label">{month.format('YYYY년 M월')}</span>
|
||||
<Button type="text" icon={<RightOutlined />} onClick={() => { setMonth((m) => m.add(1, 'month')); setDay('ALL') }} />
|
||||
</div>
|
||||
<div className="abook-day-row">
|
||||
<button
|
||||
type="button"
|
||||
className={`abook-day-btn${day === 'ALL' ? ' active' : ''}`}
|
||||
onClick={() => setDay('ALL')}
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
{Array.from({ length: daysInMonth }, (_, i) => i + 1).map((d) => {
|
||||
const isToday = month.isSame(today, 'month') && d === today.date()
|
||||
return (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className={`abook-day-btn${day === d ? ' active' : ''}${isToday ? ' today' : ''}`}
|
||||
onClick={() => setDay(d)}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="stock-table-actions">
|
||||
<Tabs
|
||||
activeKey={categoryTab}
|
||||
onChange={setCategoryTab}
|
||||
items={categoryTabs}
|
||||
/>
|
||||
<Button type="link" icon={<SettingOutlined />} onClick={openCatSettings}>과목설정</Button>
|
||||
</div>
|
||||
<div className="abook-cash-summary">{summaryText}</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={monthColumns}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '등록되어 있는 간편장부가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form form={form} layout="inline" className="balance-filter" initialValues={{ size: 10 }} onFinish={onDetailSearch}>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={() => form.setFieldValue('dates', undefined)} />
|
||||
</div>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="category">
|
||||
<Select allowClear placeholder="과목" className="w-120" options={options(categories)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="employee">
|
||||
<Select allowClear showSearch placeholder="등록직원" className="w-120" options={staffOptions} optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="item">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder="품목"
|
||||
className="w-140"
|
||||
options={(query.data?.items || []).map((v) => ({ value: v, label: v }))}
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="q">
|
||||
<Input placeholder="검색" className="w-160" allowClear />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetDetail}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setDetailFilters((v) => ({ ...v, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="stock-table-actions">
|
||||
<Tabs
|
||||
activeKey={String(detailFilters.tab)}
|
||||
onChange={(tab) => setDetailFilters((v) => ({ ...v, tab, page: 0 }))}
|
||||
items={detailTabs}
|
||||
/>
|
||||
<Space>
|
||||
<Button type="link" icon={<SettingOutlined />} onClick={openCatSettings}>과목설정</Button>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={detailColumns}
|
||||
scroll={{ x: 1000 }}
|
||||
pagination={{
|
||||
current: Number(detailFilters.page) + 1,
|
||||
pageSize: Number(detailFilters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setDetailFilters((v) => ({ ...v, page: page - 1 })) }}
|
||||
locale={{ emptyText: '등록되어 있는 간편장부가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={editing ? '간편장부 수정' : '간편장부 등록'}
|
||||
open={modalOpen}
|
||||
onCancel={() => { setCreating(false); setEditing(null) }}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={560}
|
||||
className="abook-modal"
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '100px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
onFinish={(values) => save.mutate(values)}
|
||||
className="balance-edit-form"
|
||||
>
|
||||
<Form.Item name="employee" label="등록직원" rules={[{ required: true, message: '등록직원을 선택하세요' }]}>
|
||||
<Select options={staffOptions} showSearch optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="entryDate" label="거래일" rules={[{ required: true, message: '거래일을 선택하세요' }]}>
|
||||
<DatePicker className="full-width" />
|
||||
</Form.Item>
|
||||
<Form.Item name="entryType" label="구분" rules={[{ required: true, message: '구분을 선택하세요' }]}>
|
||||
<Radio.Group options={ENTRY_TYPES.map((t) => ({ value: t, label: t }))} />
|
||||
</Form.Item>
|
||||
<Form.Item label="과목" required>
|
||||
<Space.Compact className="full-width">
|
||||
<Form.Item name="category" noStyle rules={[{ required: true, message: '과목을 선택하세요' }]}>
|
||||
<Select options={options(categories)} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Button icon={<SettingOutlined />} onClick={openCatSettings}>설정</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item name="item" label="품목" rules={[{ required: true, message: '품목을 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="금액" rules={[{ required: true, message: '금액을 입력하세요' }]}>
|
||||
<InputNumber className="full-width" min={0} addonAfter="원" controls={false} />
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="비고" extra="최대 1,000자">
|
||||
<Input.TextArea rows={4} maxLength={1000} showCount />
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="abook-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<SubjectSettingsModal
|
||||
open={catOpen}
|
||||
items={categories}
|
||||
saving={saveCategories.isPending}
|
||||
submitClassName="abook-submit-btn"
|
||||
onCancel={() => setCatOpen(false)}
|
||||
onSave={(cats) => saveCategories.mutate(cats)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
EditOutlined,
|
||||
PlusSquareOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
UnorderedListOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type BbsRow = {
|
||||
id: number
|
||||
no?: number
|
||||
title?: string
|
||||
authorName?: string
|
||||
views?: number
|
||||
createdDate?: string
|
||||
contents?: string
|
||||
}
|
||||
|
||||
type BbsResult = {
|
||||
total: number
|
||||
list: BbsRow[]
|
||||
}
|
||||
|
||||
const initial = { page: 0, size: 10, searchType: '제목', sort: 'id,desc' } as Record<string, unknown>
|
||||
|
||||
export default function BbsPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [detail, setDetail] = useState<BbsRow | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-bbs', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<BbsResult>>(`${apiPrefix()}/homes/bbs`, {
|
||||
params: {
|
||||
...filters,
|
||||
q: filters.keyword || undefined } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-bbs'] })
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/bbs`, {
|
||||
title: values.title,
|
||||
contents: values.contents })
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('게시물을 등록했습니다.')
|
||||
setOpen(false)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const openDetail = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.get<ApiResponse<BbsRow>>(`${apiPrefix()}/homes/bbs/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (row) => {
|
||||
setDetail(row)
|
||||
qc.invalidateQueries({ queryKey: ['homes-bbs'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
setFilters({
|
||||
...initial,
|
||||
searchType: values.searchType || '제목',
|
||||
keyword: values.keyword || undefined,
|
||||
page: 0,
|
||||
size: Number(values.size ?? filters.size ?? 10),
|
||||
sort: filters.sort })
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ searchType: '제목', size: 10 })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '번호',
|
||||
dataIndex: 'no',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '제목',
|
||||
dataIndex: 'title',
|
||||
ellipsis: true,
|
||||
render: (v: string, row: BbsRow) => (
|
||||
<Button type="link" className="cs-notice-title" onClick={() => openDetail.mutate(row.id)}>
|
||||
{v}
|
||||
</Button>
|
||||
) },
|
||||
{
|
||||
title: '직원',
|
||||
dataIndex: 'authorName',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '조회',
|
||||
dataIndex: 'views',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
sorter: true,
|
||||
render: (v?: number) => (v == null ? 0 : Number(v).toLocaleString()) },
|
||||
{
|
||||
title: '등록일',
|
||||
dataIndex: 'createdDate',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="stock-page cs-notice-page homes-bbs-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<UnorderedListOutlined className="product-title-icon" />
|
||||
사내게시판
|
||||
</h2>
|
||||
<p>등록된 직원끼리 이용하실 수 있는 사내전용 게시판입니다.</p>
|
||||
</div>
|
||||
<Button
|
||||
className="bbs-register-btn"
|
||||
type="primary"
|
||||
icon={<PlusSquareOutlined />}
|
||||
onClick={() => {
|
||||
editForm.resetFields()
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
게시물 등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="farebox-filter"
|
||||
initialValues={{ searchType: '제목', size: 10 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="farebox-filter-row">
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-110" options={['제목', '직원', '내용'].map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-230" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
|
||||
검색
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={onReset}>
|
||||
초기화
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="size" className="farebox-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((prev) => ({ ...prev, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="farebox-table-toolbar">
|
||||
<Typography.Text>
|
||||
목록 <b>{query.data?.total ?? 0}</b>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<CareTable
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
rowKey="id"
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
bordered
|
||||
pagination={{
|
||||
current: Number(filters.page ?? 0) + 1,
|
||||
pageSize: Number(filters.size ?? 10),
|
||||
total: query.data?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
onChange={(_p, _f, sorter) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter
|
||||
if (!s?.field || !s.order) return
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort: `${String(s.field)},${s.order === 'ascend' ? 'asc' : 'desc'}`,
|
||||
page: 0 }))
|
||||
}}
|
||||
locale={{ emptyText: '등록되어 있는 게시물이 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="게시물 등록"
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
footer={null}
|
||||
width={560}
|
||||
destroyOnHidden
|
||||
centered
|
||||
>
|
||||
<Form form={editForm} layout="vertical" onFinish={(values) => createMut.mutate(values)}>
|
||||
<Form.Item name="title" label="제목" rules={[{ required: true, message: '제목을 입력하세요.' }]}>
|
||||
<Input placeholder="제목을 입력하세요" />
|
||||
</Form.Item>
|
||||
<Form.Item name="contents" label="내용" rules={[{ required: true, message: '내용을 입력하세요.' }]}>
|
||||
<Input.TextArea rows={10} placeholder="내용을 입력하세요" />
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button
|
||||
className="bbs-register-btn"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={createMut.isPending}
|
||||
icon={<EditOutlined />}
|
||||
>
|
||||
등록
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="사내게시판"
|
||||
open={Boolean(detail)}
|
||||
onCancel={() => setDetail(null)}
|
||||
footer={<Button onClick={() => setDetail(null)}>닫기</Button>}
|
||||
width={640}
|
||||
destroyOnHidden
|
||||
centered
|
||||
>
|
||||
{detail ? (
|
||||
<div>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
{detail.title}
|
||||
</Typography.Title>
|
||||
<div className="cs-notice-meta">
|
||||
<span>직원 {detail.authorName || '-'}</span>
|
||||
<span>조회 {detail.views ?? 0}</span>
|
||||
<span>등록일 {detail.createdDate || '-'}</span>
|
||||
</div>
|
||||
<div className="cs-notice-body">
|
||||
{(detail.contents || '')
|
||||
.split('\n')
|
||||
.map((line, idx) => (
|
||||
<p key={idx}>{line || '\u00A0'}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { HomesLogListPage } from './HomesLogListPage'
|
||||
|
||||
const ACTIONS = [
|
||||
'예약-등록',
|
||||
'예약-수정',
|
||||
'예약-삭제',
|
||||
'예약-상태',
|
||||
]
|
||||
|
||||
/** 원본 예약관리 타이틀 잎 아이콘 */
|
||||
function LeafOutlined({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={className} role="img" aria-label="leaf">
|
||||
<svg viewBox="0 0 24 24" width="1em" height="1em" fill="currentColor" aria-hidden>
|
||||
<path d="M17 8C8 10 5.9 16.17 3.82 21.34l1.89.66.95-2.3c.10.0.1.1.34.01C19 20 22 3 22 3c-1 2-8 2.25-13 3.25S2 11.5 2 13.5s1.75 3.75 1.75 3.75C7 8 17 8 17 8z" />
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BookingLogPage() {
|
||||
return (
|
||||
<HomesLogListPage
|
||||
domain="BOOKING"
|
||||
title="예약이력"
|
||||
description="예약관리와 관련된 이력을 확인하실 수 있습니다."
|
||||
defaultActions={ACTIONS}
|
||||
titleIcon={<LeafOutlined className="product-title-icon" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Tabs,
|
||||
Upload,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
CloseOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
PaperClipOutlined,
|
||||
PlusSquareOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { options } from './homesShared'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
/** 원본 예약관리 타이틀 잎 아이콘 */
|
||||
function LeafOutlined({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={className} role="img" aria-label="leaf">
|
||||
<svg viewBox="0 0 24 24" width="1em" height="1em" fill="currentColor" aria-hidden>
|
||||
<path d="M17 8C8 10 5.9 16.17 3.82 21.34l1.89.66.95-2.3c.10.0.1.1.34.01C19 20 22 3 22 3c-1 2-8 2.25-13 3.25S2 11.5 2 13.5s1.75 3.75 1.75 3.75C7 8 17 8 17 8z" />
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const INTERNET_OPTS = ['인터넷', 'TV', '전화', '와이파이', '스마트홈', '기타']
|
||||
const RENTAL_OPTS = ['정수기', '공기청정기', '비데', '매트리스', '가전', '기타']
|
||||
|
||||
type BookingRow = {
|
||||
id: number
|
||||
status?: string
|
||||
bookingDate?: string
|
||||
customerName?: string
|
||||
phone?: string
|
||||
internetProducts?: string
|
||||
rentalProducts?: string
|
||||
internetNote?: string
|
||||
rentalNote?: string
|
||||
zipcode?: string
|
||||
address?: string
|
||||
addressDetail?: string
|
||||
employee?: string
|
||||
receiveEmployee?: string
|
||||
memo?: string
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
type BookingResult = {
|
||||
total: number
|
||||
list: BookingRow[]
|
||||
counts?: Record<string, number>
|
||||
employees?: string[]
|
||||
internetOptions?: string[]
|
||||
rentalOptions?: string[]
|
||||
}
|
||||
|
||||
const initial = { page: 0, size: 10, tab: 'ALL' } as Record<string, unknown>
|
||||
|
||||
const maskName = (name?: string) => {
|
||||
if (!name) return '-'
|
||||
if (name.length <= 1) return name
|
||||
if (name.length === 2) return `${name[0]}*`
|
||||
return `${name[0]}*${name.slice(2)}`
|
||||
}
|
||||
|
||||
const maskPhone = (phone?: string) => {
|
||||
if (!phone) return '-'
|
||||
const digits = phone.replace(/\D/g, '')
|
||||
if (digits.length < 7) return phone
|
||||
if (digits.length === 10) return `${digits.slice(0, 3)}-****-${digits.slice(6)}`
|
||||
return `${digits.slice(0, 3)}-****-${digits.slice(-4)}`
|
||||
}
|
||||
|
||||
const splitPhone = (phone?: string) => {
|
||||
const digits = (phone || '').replace(/\D/g, '')
|
||||
if (digits.length >= 10) {
|
||||
return {
|
||||
phone1: digits.slice(0, 3),
|
||||
phone2: digits.slice(3, digits.length - 4),
|
||||
phone3: digits.slice(-4) }
|
||||
}
|
||||
return { phone1: '010', phone2: '', phone3: '' }
|
||||
}
|
||||
|
||||
const splitProducts = (v?: string) =>
|
||||
(v || '')
|
||||
.split(/[,,]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
export default function BookingPage() {
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const qc = useQueryClient()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [editing, setEditing] = useState<BookingRow | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-bookings', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<BookingResult>>(`${apiPrefix()}/homes/bookings`, {
|
||||
params: {
|
||||
...filters,
|
||||
status: filters.tab === 'ALL' ? undefined : filters.tab } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const members = useQuery({
|
||||
queryKey: ['homes/members-options'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ list: { id: number; name?: string }[] }>>(`${apiPrefix()}/homes/members`, {
|
||||
params: { page: 0, size: 200 } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ['homes-bookings'] })
|
||||
setSelected([])
|
||||
}
|
||||
|
||||
const staffOptions = useMemo(() => {
|
||||
const fromApi = query.data?.employees || []
|
||||
const fromMembers = (members.data?.list || []).map((m) => String(m.name || '')).filter(Boolean)
|
||||
return Array.from(new Set([...fromApi, ...fromMembers, '홍길동'])).map((v) => ({ value: v, label: v }))
|
||||
}, [query.data, members.data])
|
||||
|
||||
const internetOptions = useMemo(
|
||||
() => options(query.data?.internetOptions?.length ? query.data.internetOptions : INTERNET_OPTS),
|
||||
[query.data?.internetOptions],
|
||||
)
|
||||
const rentalOptions = useMemo(
|
||||
() => options(query.data?.rentalOptions?.length ? query.data.rentalOptions : RENTAL_OPTS),
|
||||
[query.data?.rentalOptions],
|
||||
)
|
||||
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{ key: 'ALL', label: `전체 ${query.data?.counts?.ALL ?? query.data?.total ?? 0}` },
|
||||
{ key: '접수', label: `접수 ${query.data?.counts?.['접수'] ?? 0}` },
|
||||
{ key: '완료', label: `완료 ${query.data?.counts?.['완료'] ?? 0}` },
|
||||
{ key: '보류', label: `보류 ${query.data?.counts?.['보류'] ?? 0}` },
|
||||
],
|
||||
[query.data],
|
||||
)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
status: values.status || editing?.status || '접수',
|
||||
bookingDate: values.bookingDate ? dayjs(values.bookingDate as Dayjs).format('YYYY-MM-DD') : undefined,
|
||||
receiveEmployee: values.receiveEmployee,
|
||||
employee: values.receiveEmployee,
|
||||
customerName: values.customerName,
|
||||
phone1: values.phone1,
|
||||
phone2: values.phone2,
|
||||
phone3: values.phone3,
|
||||
zipcode: values.zipcode,
|
||||
address: values.address,
|
||||
addressDetail: values.addressDetail,
|
||||
internetProducts: values.internetProducts || [],
|
||||
rentalProducts: values.rentalProducts || [],
|
||||
internetNote: values.internetNote,
|
||||
rentalNote: values.rentalNote,
|
||||
memo: values.memo,
|
||||
fileName: values.fileName }
|
||||
if (editing) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/bookings/${editing.id}`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/bookings`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '수정했습니다.' : '예약을 등록했습니다.')
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/bookings/delete`, { ids })
|
||||
return unwrap(data as ApiResponse<{ deleted: number }>)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
message.success(`${data.deleted}건을 삭제했습니다.`)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setFilters({
|
||||
...initial,
|
||||
receiveEmployee: values.receiveEmployee || undefined,
|
||||
internetProduct: values.internetProduct || undefined,
|
||||
rentalProduct: values.rentalProduct || undefined,
|
||||
phoneTail: values.phoneTail || undefined,
|
||||
q: values.q || undefined,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 10,
|
||||
tab: filters.tab })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ size: 10 })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setCreating(true)
|
||||
const defaultStaff = staffOptions[0]?.value || '홍길동'
|
||||
editForm.setFieldsValue({
|
||||
bookingDate: dayjs(),
|
||||
receiveEmployee: defaultStaff,
|
||||
customerName: undefined,
|
||||
phone1: '010',
|
||||
phone2: '',
|
||||
phone3: '',
|
||||
zipcode: undefined,
|
||||
address: undefined,
|
||||
addressDetail: undefined,
|
||||
internetProducts: [],
|
||||
rentalProducts: [],
|
||||
internetNote: undefined,
|
||||
rentalNote: undefined,
|
||||
memo: undefined,
|
||||
fileName: undefined,
|
||||
status: '접수' })
|
||||
}
|
||||
|
||||
const openEdit = (row: BookingRow) => {
|
||||
setCreating(false)
|
||||
setEditing(row)
|
||||
const phones = splitPhone(row.phone)
|
||||
editForm.setFieldsValue({
|
||||
bookingDate: row.bookingDate ? dayjs(row.bookingDate) : dayjs(),
|
||||
receiveEmployee: row.receiveEmployee || row.employee,
|
||||
customerName: row.customerName,
|
||||
...phones,
|
||||
zipcode: row.zipcode,
|
||||
address: row.address,
|
||||
addressDetail: row.addressDetail,
|
||||
internetProducts: splitProducts(row.internetProducts),
|
||||
rentalProducts: splitProducts(row.rentalProducts),
|
||||
internetNote: row.internetNote,
|
||||
rentalNote: row.rentalNote,
|
||||
memo: row.memo,
|
||||
fileName: row.fileName,
|
||||
status: row.status || '접수' })
|
||||
}
|
||||
|
||||
const searchAddress = () => {
|
||||
message.info('주소검색(데모) — 직접 입력해 주세요.')
|
||||
editForm.setFieldsValue({
|
||||
zipcode: editForm.getFieldValue('zipcode') || '06236',
|
||||
address: editForm.getFieldValue('address') || '서울특별시 강남구' })
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/homes/bookings/export`, {
|
||||
params: { ...filters, status: filters.tab === 'ALL' ? undefined : filters.tab },
|
||||
responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '예약관리.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: (
|
||||
<Checkbox
|
||||
checked={!!query.data?.list?.length && selected.length === query.data.list.length}
|
||||
indeterminate={selected.length > 0 && selected.length < (query.data?.list?.length || 0)}
|
||||
onChange={(e) => setSelected(e.target.checked ? (query.data?.list || []).map((r) => r.id) : [])}
|
||||
/>
|
||||
),
|
||||
width: 42,
|
||||
render: (_: unknown, row: BookingRow) => (
|
||||
<Checkbox
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={(e) => setSelected((prev) => (e.target.checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)))}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '접수일',
|
||||
dataIndex: 'bookingDate',
|
||||
width: 110,
|
||||
sorter: (a: BookingRow, b: BookingRow) => String(a.bookingDate || '').localeCompare(String(b.bookingDate || '')) },
|
||||
{
|
||||
title: '상태',
|
||||
dataIndex: 'status',
|
||||
width: 70,
|
||||
render: (v: string) => <span className={v === '접수' ? 'booking-status-open' : ''}>{v || '-'}</span> },
|
||||
{
|
||||
title: '고객명',
|
||||
dataIndex: 'customerName',
|
||||
width: 90,
|
||||
render: (v: string) => maskName(v) },
|
||||
{
|
||||
title: '연락처',
|
||||
dataIndex: 'phone',
|
||||
width: 130,
|
||||
render: (v: string) => maskPhone(v) },
|
||||
{
|
||||
title: '인터넷상품',
|
||||
dataIndex: 'internetProducts',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '홈렌탈상품',
|
||||
dataIndex: 'rentalProducts',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '접수직원',
|
||||
dataIndex: 'employee',
|
||||
width: 90,
|
||||
render: (_: string, row: BookingRow) => row.receiveEmployee || row.employee || '-' },
|
||||
{
|
||||
title: '관리',
|
||||
width: 64,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: BookingRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="booking-manage-btn"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(row)}
|
||||
/>
|
||||
) },
|
||||
]
|
||||
|
||||
const modalOpen = creating || Boolean(editing)
|
||||
|
||||
return (
|
||||
<div className="stock-page booking-manage-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<LeafOutlined className="product-title-icon" />
|
||||
예약관리
|
||||
</h2>
|
||||
<p>예약하시는 고객의 정보를 등록/관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
loading={deleteMut.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('삭제할 예약을 선택하세요.')
|
||||
return
|
||||
}
|
||||
deleteMut.mutate(selected)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button className="booking-register-btn" type="primary" icon={<PlusSquareOutlined />} onClick={openCreate}>
|
||||
예약 등록
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form form={form} layout="inline" className="balance-filter" initialValues={{ size: 10 }} onFinish={onSearch}>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={() => form.setFieldValue('dates', undefined)} />
|
||||
</div>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="receiveEmployee">
|
||||
<Select allowClear showSearch placeholder="접수직원" className="w-120" options={staffOptions} optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="internetProduct">
|
||||
<Select allowClear placeholder="인터넷상품" className="w-130" options={internetOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="rentalProduct">
|
||||
<Select allowClear placeholder="홈렌탈상품" className="w-130" options={rentalOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phoneTail">
|
||||
<Input placeholder="연락처(뒷4자리)" className="w-140" maxLength={4} allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item name="q">
|
||||
<Input placeholder="검색" className="w-160" allowClear />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((v) => ({ ...v, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="stock-table-actions">
|
||||
<Tabs
|
||||
activeKey={String(filters.tab)}
|
||||
onChange={(tab) => setFilters((v) => ({ ...v, tab, page: 0 }))}
|
||||
items={tabs}
|
||||
/>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((v) => ({ ...v, page: page - 1 })) }}
|
||||
locale={{ emptyText: '등록된 자료가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '예약 수정' : '예약 등록'}
|
||||
open={modalOpen}
|
||||
onCancel={() => { setCreating(false); setEditing(null) }}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={640}
|
||||
className="booking-modal"
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '110px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
onFinish={(values) => save.mutate(values)}
|
||||
className="balance-edit-form"
|
||||
>
|
||||
<Form.Item name="bookingDate" label="접수일" rules={[{ required: true, message: '접수일을 선택하세요' }]}>
|
||||
<DatePicker className="full-width" />
|
||||
</Form.Item>
|
||||
<Form.Item name="receiveEmployee" label="접수직원" rules={[{ required: true, message: '접수직원을 선택하세요' }]}>
|
||||
<Select options={staffOptions} showSearch optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="customerName" label="고객명" rules={[{ required: true, message: '고객명을 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="연락처" required>
|
||||
<Space.Compact>
|
||||
<Form.Item name="phone1" noStyle rules={[{ required: true, message: '연락처를 입력하세요' }]}>
|
||||
<Input style={{ width: 70 }} maxLength={3} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone2" noStyle rules={[{ required: true, message: '연락처를 입력하세요' }]}>
|
||||
<Input style={{ width: 80 }} maxLength={4} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone3" noStyle rules={[{ required: true, message: '연락처를 입력하세요' }]}>
|
||||
<Input style={{ width: 80 }} maxLength={4} />
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="주소">
|
||||
<div className="booking-address-row">
|
||||
<Button onClick={searchAddress}>주소검색</Button>
|
||||
<Form.Item name="zipcode" noStyle>
|
||||
<Input className="booking-zip" placeholder="우편번호" />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" noStyle>
|
||||
<Input className="booking-addr" placeholder="기본주소" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="addressDetail" noStyle>
|
||||
<Input className="booking-addr-detail" placeholder="상세주소" />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
<Form.Item name="internetProducts" label="인터넷상품">
|
||||
<Checkbox.Group options={INTERNET_OPTS} className="booking-check-group" />
|
||||
</Form.Item>
|
||||
<Form.Item name="internetNote" label=" " colon={false}>
|
||||
<Input placeholder="희망하시는 상품명 및 수량이 있으신 경우 입력해주세요." />
|
||||
</Form.Item>
|
||||
<Form.Item name="rentalProducts" label="홈렌탈상품">
|
||||
<Checkbox.Group options={RENTAL_OPTS} className="booking-check-group" />
|
||||
</Form.Item>
|
||||
<Form.Item name="rentalNote" label=" " colon={false}>
|
||||
<Input placeholder="희망하시는 상품명 및 수량이 있으신 경우 입력해주세요." />
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="비고" extra="최대 1,000자">
|
||||
<Input.TextArea rows={4} maxLength={1000} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item label="파일첨부">
|
||||
<Form.Item name="fileName" noStyle>
|
||||
<Input type="hidden" />
|
||||
</Form.Item>
|
||||
<Upload
|
||||
beforeUpload={(file) => {
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
message.error('최대 20M까지 첨부할 수 있습니다.')
|
||||
return Upload.LIST_IGNORE
|
||||
}
|
||||
editForm.setFieldValue('fileName', file.name)
|
||||
message.success(`${file.name} 선택됨 (데모)`)
|
||||
return false
|
||||
}}
|
||||
maxCount={1}
|
||||
showUploadList={false}
|
||||
>
|
||||
<Button icon={<PaperClipOutlined />}>파일 선택</Button>
|
||||
</Upload>
|
||||
<div className="booking-file-hint">* 다량의 파일은 압축해서 올려주십시요. (최대 20M)</div>
|
||||
<Form.Item shouldUpdate={(prev, cur) => prev.fileName !== cur.fileName} noStyle>
|
||||
{() => {
|
||||
const name = editForm.getFieldValue('fileName')
|
||||
return name ? <div className="booking-file-name">{name}</div> : null
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
<div className="booking-modal-footer">
|
||||
<div className="booking-modal-note">* 고객명, 연락처는 고객관리에도 동시등록됩니다.</div>
|
||||
<Space>
|
||||
<Button className="booking-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Card, Typography } from 'antd'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export default function CarePlaceholderPage({
|
||||
title,
|
||||
description = '원본 비즈케어홈즈 메뉴와 동일한 경로로 연결해 두었습니다. 상세 화면은 순차 구현 예정입니다.' }: Props) {
|
||||
return (
|
||||
<Card>
|
||||
<Typography.Title level={3} style={{ marginTop: 0 }}>{title}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">{description}</Typography.Paragraph>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Table, Button, Card, DatePicker, Form, InputNumber, Select, Space, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { RadarChartOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { money, options } from './homesShared'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
type AdjustRow = {
|
||||
id: number
|
||||
kind?: string
|
||||
status?: string
|
||||
contractDate?: string
|
||||
customerName?: string
|
||||
phone?: string
|
||||
products?: string
|
||||
months?: number
|
||||
agency?: string
|
||||
employee?: string
|
||||
policySum?: number
|
||||
settleAmount?: number
|
||||
margin?: number
|
||||
}
|
||||
|
||||
type AdjustResult = {
|
||||
total: number
|
||||
list: AdjustRow[]
|
||||
employees?: string[]
|
||||
products?: string[]
|
||||
}
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
{ value: 'INTERNET', label: '인터넷' },
|
||||
{ value: 'HOME', label: '홈렌탈' },
|
||||
]
|
||||
const CATEGORY_OPTIONS = ['인터넷', 'TV', '전화', '와이파이', '스마트홈', '정수기', '공기청정기', '비데', '매트리스', '가전', '기타']
|
||||
const MONTH_OPTIONS = [
|
||||
{ value: '12', label: '12개월' },
|
||||
{ value: '24', label: '24개월' },
|
||||
{ value: '36', label: '36개월' },
|
||||
{ value: '48', label: '48개월' },
|
||||
{ value: '60', label: '60개월' },
|
||||
]
|
||||
const STATUS_OPTIONS = ['접수', '신청', '완료', '철회']
|
||||
|
||||
export default function ContractAdjustPage() {
|
||||
const [form] = Form.useForm()
|
||||
const [searched, setSearched] = useState(false)
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({})
|
||||
const [rows, setRows] = useState<AdjustRow[]>([])
|
||||
const [batch, setBatch] = useState({
|
||||
policySum: undefined as number | undefined,
|
||||
settleAmount: undefined as number | undefined,
|
||||
margin: undefined as number | undefined })
|
||||
|
||||
const enabled = searched && !!filters.dateFrom && !!filters.dateTo
|
||||
const optionParams = useMemo(() => ({
|
||||
dateFrom: dayjs().startOf('month').format('YYYY-MM-DD'),
|
||||
dateTo: dayjs().format('YYYY-MM-DD') }), [])
|
||||
const optionsQuery = useQuery({
|
||||
queryKey: ['homes-contract-adjust-options', optionParams],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<AdjustResult>>(`${apiPrefix()}/homes/contracts/adjust`, { params: optionParams })
|
||||
return unwrap(data)
|
||||
} })
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-contract-adjust', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<AdjustResult>>(`${apiPrefix()}/homes/contracts/adjust`, { params: filters })
|
||||
return unwrap(data)
|
||||
},
|
||||
enabled })
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.data?.list) return
|
||||
setRows(query.data.list.map((row) => ({
|
||||
...row,
|
||||
policySum: Number(row.policySum ?? 0),
|
||||
settleAmount: Number(row.settleAmount ?? 0),
|
||||
margin: Number(row.margin ?? 0) })))
|
||||
}, [query.data])
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => {
|
||||
if (to.diff(from, 'day') > 30) {
|
||||
message.warning('최대 31일까지 조회할 수 있습니다.')
|
||||
return
|
||||
}
|
||||
form.setFieldsValue({ dates: [from, to] })
|
||||
}
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
if (!dates?.[0] || !dates?.[1]) {
|
||||
message.warning('계약일을 선택하세요.')
|
||||
return
|
||||
}
|
||||
if (dates[1].diff(dates[0], 'day') > 30) {
|
||||
message.warning('최대 31일까지 조회할 수 있습니다.')
|
||||
return
|
||||
}
|
||||
setSearched(true)
|
||||
setFilters({
|
||||
dateFrom: dates[0].format('YYYY-MM-DD'),
|
||||
dateTo: dates[1].format('YYYY-MM-DD'),
|
||||
kind: values.kind || undefined,
|
||||
category: values.category || undefined,
|
||||
product: values.product || undefined,
|
||||
months: values.months || undefined,
|
||||
status: values.status || undefined,
|
||||
employee: values.employee || undefined })
|
||||
}
|
||||
|
||||
const updateRow = (id: number, field: 'policySum' | 'settleAmount' | 'margin', value: number | null) => {
|
||||
setRows((prev) => prev.map((row) => (row.id === id ? { ...row, [field]: value ?? 0 } : row)))
|
||||
}
|
||||
|
||||
const applyBatch = (field: 'policySum' | 'settleAmount' | 'margin') => {
|
||||
const value = batch[field]
|
||||
if (value == null) {
|
||||
message.warning('금액을 입력하세요.')
|
||||
return
|
||||
}
|
||||
setRows((prev) => prev.map((row) => ({ ...row, [field]: value })))
|
||||
message.success('일괄 입력했습니다.')
|
||||
}
|
||||
|
||||
const saveOne = useMutation({
|
||||
mutationFn: async (row: AdjustRow) => {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/contracts/${row.id}`, {
|
||||
policySum: row.policySum,
|
||||
settleAmount: row.settleAmount,
|
||||
margin: row.margin })
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => message.success('변경했습니다.'),
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const saveAll = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/contracts/adjust/batch`, {
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
policySum: row.policySum,
|
||||
settleAmount: row.settleAmount,
|
||||
margin: row.margin })) })
|
||||
return unwrap(data as ApiResponse<{ updated: number }>)
|
||||
},
|
||||
onSuccess: (data) => message.success(`${data.updated}건을 변경했습니다.`),
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const employeeOptions = useMemo(() => {
|
||||
const fromApi = [...(optionsQuery.data?.employees || []), ...(query.data?.employees || [])]
|
||||
const fromRows = rows.map((r) => r.employee).filter(Boolean) as string[]
|
||||
return Array.from(new Set([...fromApi, ...fromRows]))
|
||||
}, [optionsQuery.data?.employees, query.data?.employees, rows])
|
||||
|
||||
const productOptions = useMemo(() => {
|
||||
const fromApi = [...(optionsQuery.data?.products || []), ...(query.data?.products || [])]
|
||||
return Array.from(new Set(fromApi)).map((v) => ({ value: v, label: v }))
|
||||
}, [optionsQuery.data?.products, query.data?.products])
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '종류',
|
||||
dataIndex: 'kind',
|
||||
width: 80,
|
||||
render: (v: string) => (v === 'HOME' ? '홈렌탈' : '인터넷') },
|
||||
{ title: '상태', dataIndex: 'status', width: 70 },
|
||||
{ title: '계약일', dataIndex: 'contractDate', width: 100 },
|
||||
{ title: '고객명', dataIndex: 'customerName', width: 90 },
|
||||
{ title: '가입상품', dataIndex: 'products', width: 180, ellipsis: true },
|
||||
{
|
||||
title: '약정',
|
||||
dataIndex: 'months',
|
||||
width: 80,
|
||||
render: (v: number) => (v ? `${v}개월` : '-') },
|
||||
{ title: '거래처', dataIndex: 'agency', width: 110, ellipsis: true },
|
||||
{ title: '직원', dataIndex: 'employee', width: 90 },
|
||||
{
|
||||
title: '정책합산',
|
||||
dataIndex: 'policySum',
|
||||
width: 120,
|
||||
render: (_: unknown, row: AdjustRow) => (
|
||||
<InputNumber
|
||||
className="adjust-cell-input"
|
||||
controls={false}
|
||||
value={row.policySum}
|
||||
onChange={(v) => updateRow(row.id, 'policySum', v)}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '정산금액',
|
||||
dataIndex: 'settleAmount',
|
||||
width: 120,
|
||||
render: (_: unknown, row: AdjustRow) => (
|
||||
<InputNumber
|
||||
className="adjust-cell-input"
|
||||
controls={false}
|
||||
value={row.settleAmount}
|
||||
onChange={(v) => updateRow(row.id, 'settleAmount', v)}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '판매마진',
|
||||
dataIndex: 'margin',
|
||||
width: 120,
|
||||
render: (_: unknown, row: AdjustRow) => (
|
||||
<InputNumber
|
||||
className="adjust-cell-input"
|
||||
controls={false}
|
||||
value={row.margin}
|
||||
onChange={(v) => updateRow(row.id, 'margin', v)}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '개별',
|
||||
width: 70,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, row: AdjustRow) => (
|
||||
<Button size="small" onClick={() => saveOne.mutate(row)} loading={saveOne.isPending}>변경</Button>
|
||||
) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stock-page adjust-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<RadarChartOutlined className="product-title-icon" />
|
||||
일괄정책변경
|
||||
</h2>
|
||||
<p>등록된 계약정보에 일괄적으로 기본정책금액을 변경하실 수 있습니다. (단일상품의 수량이 2개 이상인 경우 제외됩니다.)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card adjust-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
initialValues={{
|
||||
dates: [dayjs().startOf('month'), dayjs()] }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="adjust-filter-row adjust-date-row">
|
||||
<span className="adjust-label">계약일</span>
|
||||
<Form.Item name="dates" noStyle>
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Typography.Text type="danger" className="adjust-hint">* 최대 31일 조회</Typography.Text>
|
||||
<Button className="adjust-search-btn" type="primary" htmlType="submit" icon={<SearchOutlined />}>
|
||||
계약정보 검색
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="adjust-filter-row">
|
||||
<span className="adjust-label">검색조건</span>
|
||||
<Form.Item name="kind" noStyle>
|
||||
<Select allowClear placeholder="-종류-" className="w-110" options={KIND_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" noStyle>
|
||||
<Select allowClear placeholder="-구분-" className="w-110" options={options(CATEGORY_OPTIONS)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="product" noStyle>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder="-상품-"
|
||||
className="w-150"
|
||||
options={productOptions.length ? productOptions : undefined}
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="months" noStyle>
|
||||
<Select allowClear placeholder="-약정-" className="w-110" options={MONTH_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" noStyle>
|
||||
<Select allowClear placeholder="-상태-" className="w-110" options={options(STATUS_OPTIONS)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="employee" noStyle>
|
||||
<Select allowClear placeholder="-직원-" className="w-120" options={options(employeeOptions)} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{enabled && (
|
||||
<Card className="stock-table-card adjust-result-card" size="small">
|
||||
<div className="adjust-result-head">
|
||||
<Typography.Text>검색된 계약 <b>{query.data?.total ?? rows.length}</b> 건</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="adjust-batch-bar">
|
||||
<span className="adjust-batch-guide">원하시는 금액을 입력 후 엔터키를 눌러주세요.</span>
|
||||
<Space wrap size={12}>
|
||||
<Space.Compact className="adjust-batch-compact">
|
||||
<InputNumber
|
||||
className="adjust-batch-input"
|
||||
controls={false}
|
||||
placeholder="정책합산"
|
||||
value={batch.policySum}
|
||||
onChange={(v) => setBatch((s) => ({ ...s, policySum: v ?? undefined }))}
|
||||
onPressEnter={() => applyBatch('policySum')}
|
||||
/>
|
||||
<Button className="adjust-batch-btn" type="primary" onClick={() => applyBatch('policySum')}>
|
||||
일괄입력
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Space.Compact className="adjust-batch-compact">
|
||||
<InputNumber
|
||||
className="adjust-batch-input"
|
||||
controls={false}
|
||||
placeholder="정산금액"
|
||||
value={batch.settleAmount}
|
||||
onChange={(v) => setBatch((s) => ({ ...s, settleAmount: v ?? undefined }))}
|
||||
onPressEnter={() => applyBatch('settleAmount')}
|
||||
/>
|
||||
<Button className="adjust-batch-btn" type="primary" onClick={() => applyBatch('settleAmount')}>
|
||||
일괄입력
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Space.Compact className="adjust-batch-compact">
|
||||
<InputNumber
|
||||
className="adjust-batch-input"
|
||||
controls={false}
|
||||
placeholder="판매마진"
|
||||
value={batch.margin}
|
||||
onChange={(v) => setBatch((s) => ({ ...s, margin: v ?? undefined }))}
|
||||
onPressEnter={() => applyBatch('margin')}
|
||||
/>
|
||||
<Button className="adjust-batch-btn" type="primary" onClick={() => applyBatch('margin')}>
|
||||
일괄입력
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={rows}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
scroll={{ x: 1400 }}
|
||||
locale={{ emptyText: '검색된 계약이 없습니다.' }}
|
||||
summary={() => (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={8}>합계</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1} align="right">
|
||||
{money(rows.reduce((s, r) => s + Number(r.policySum || 0), 0))}
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={2} align="right">
|
||||
{money(rows.reduce((s, r) => s + Number(r.settleAmount || 0), 0))}
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={3} align="right">
|
||||
{money(rows.reduce((s, r) => s + Number(r.margin || 0), 0))}
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={4} />
|
||||
</Table.Summary.Row>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="adjust-submit">
|
||||
<Button
|
||||
className="adjust-submit-btn"
|
||||
type="primary"
|
||||
size="large"
|
||||
loading={saveAll.isPending}
|
||||
disabled={!rows.length}
|
||||
onClick={() => saveAll.mutate()}
|
||||
>
|
||||
상기 입력된 값으로 모두 변경합니다.
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Tabs,
|
||||
Tooltip,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
CloseOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
MailOutlined,
|
||||
PlusSquareOutlined,
|
||||
RadarChartOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { options, type PageResult } from './homesShared'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
const TABS = [
|
||||
['ALL', '전체'],
|
||||
['정산차감', '정산차감'],
|
||||
['정산추가', '정산추가'],
|
||||
] as const
|
||||
|
||||
type BalanceRow = {
|
||||
id: number
|
||||
balanceType?: string
|
||||
targetType?: string
|
||||
targetName?: string
|
||||
baseDate?: string
|
||||
reason?: string
|
||||
amount?: number
|
||||
memo?: string
|
||||
}
|
||||
|
||||
type BalanceSearch = PageResult & {
|
||||
list: BalanceRow[]
|
||||
counts?: Record<string, number>
|
||||
employees?: string[]
|
||||
agencies?: string[]
|
||||
reasons?: string[]
|
||||
}
|
||||
|
||||
const isDeduct = (gubun?: string) => Boolean(gubun && gubun.includes('차감'))
|
||||
|
||||
const initial = {
|
||||
page: 0,
|
||||
size: 10,
|
||||
tab: 'ALL' } as Record<string, unknown>
|
||||
|
||||
export default function ContractBalancePage() {
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const qc = useQueryClient()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [editing, setEditing] = useState<BalanceRow | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [amountSign, setAmountSign] = useState<'+' | '-'>('-')
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-balances', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<BalanceSearch>>(`${apiPrefix()}/homes/balances`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const members = useQuery({
|
||||
queryKey: ['homes/members-options'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<PageResult>>(`${apiPrefix()}/homes/members`, { params: { page: 0, size: 200 } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const agencies = useQuery({
|
||||
queryKey: ['homes/agencies-options'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<PageResult>>(`${apiPrefix()}/homes/agencies`, { params: { page: 0, size: 200 } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ['homes-balances'] })
|
||||
setSelected([])
|
||||
}
|
||||
|
||||
const employeeOptions = useMemo(() => {
|
||||
const fromApi = (query.data?.employees || []) as string[]
|
||||
const fromMembers = (members.data?.list || []).map((m) => String(m.name || '')).filter(Boolean)
|
||||
return Array.from(new Set([...fromApi, ...fromMembers])).map((v) => ({ value: v, label: v }))
|
||||
}, [query.data?.employees, members.data])
|
||||
|
||||
const agencyOptions = useMemo(() => {
|
||||
const fromApi = (query.data?.agencies || []) as string[]
|
||||
const fromAgencies = (agencies.data?.list || []).map((a) => String(a.name || '')).filter(Boolean)
|
||||
return Array.from(new Set([...fromApi, ...fromAgencies])).map((v) => ({ value: v, label: v }))
|
||||
}, [query.data?.agencies, agencies.data])
|
||||
|
||||
const reasonOptions = useMemo(() => {
|
||||
const fromApi = (query.data?.reasons || []) as string[]
|
||||
return fromApi.map((v) => ({ value: v, label: v }))
|
||||
}, [query.data?.reasons])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const raw = Number(values.amountAbs ?? 0)
|
||||
const signed = amountSign === '-' ? -Math.abs(raw) : Math.abs(raw)
|
||||
const payload: Record<string, unknown> = {
|
||||
targetType: values.targetType,
|
||||
targetName: values.targetName,
|
||||
balanceType: values.balanceType,
|
||||
reason: values.reason,
|
||||
memo: values.memo,
|
||||
amount: signed,
|
||||
baseDate: values.baseDate ? dayjs(values.baseDate as Dayjs).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD') }
|
||||
if (editing) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/balances/${editing.id}`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/balances`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '수정했습니다.' : '후정산을 등록했습니다.')
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message || '저장에 실패했습니다.') })
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/balances/delete`, { ids })
|
||||
return unwrap(data as ApiResponse<{ deleted: number }>)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
message.success(`${data.deleted}건을 삭제했습니다.`)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setFilters({
|
||||
...initial,
|
||||
employee: values.employee || undefined,
|
||||
agency: values.agency || undefined,
|
||||
reason: values.reason || undefined,
|
||||
q: values.q || undefined,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 10,
|
||||
tab: filters.tab })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ size: 10 })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setCreating(true)
|
||||
setAmountSign('-')
|
||||
editForm.setFieldsValue({
|
||||
targetType: '직원',
|
||||
targetName: undefined,
|
||||
balanceType: '정산차감',
|
||||
baseDate: dayjs(),
|
||||
amountAbs: undefined,
|
||||
reason: undefined,
|
||||
memo: undefined })
|
||||
}
|
||||
|
||||
const openEdit = (row: BalanceRow) => {
|
||||
setCreating(false)
|
||||
setEditing(row)
|
||||
const amount = Number(row.amount ?? 0)
|
||||
setAmountSign(amount < 0 || isDeduct(row.balanceType) ? '-' : '+')
|
||||
editForm.setFieldsValue({
|
||||
targetType: row.targetType || '직원',
|
||||
targetName: row.targetName,
|
||||
balanceType: row.balanceType || '정산차감',
|
||||
amountAbs: Math.abs(amount),
|
||||
reason: row.reason,
|
||||
memo: row.memo,
|
||||
baseDate: row.baseDate ? dayjs(row.baseDate) : dayjs() })
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/homes/balances/export`, { params: filters, responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '계약후정산.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const targetType = Form.useWatch('targetType', editForm)
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: (
|
||||
<Checkbox
|
||||
checked={!!query.data?.list?.length && selected.length === query.data.list.length}
|
||||
indeterminate={selected.length > 0 && selected.length < (query.data?.list?.length || 0)}
|
||||
onChange={(e) => setSelected(e.target.checked ? (query.data?.list || []).map((r) => r.id) : [])}
|
||||
/>
|
||||
),
|
||||
width: 42,
|
||||
render: (_: unknown, row: BalanceRow) => (
|
||||
<Checkbox
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={(e) => setSelected((prev) => (e.target.checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)))}
|
||||
/>
|
||||
) },
|
||||
{ title: '후정산일', dataIndex: 'baseDate', width: 110, sorter: (a: BalanceRow, b: BalanceRow) => String(a.baseDate || '').localeCompare(String(b.baseDate || '')) },
|
||||
{ title: '대상', dataIndex: 'targetType', width: 80 },
|
||||
{ title: '대상명', dataIndex: 'targetName', width: 120, ellipsis: true },
|
||||
{
|
||||
title: '구분',
|
||||
dataIndex: 'balanceType',
|
||||
width: 100,
|
||||
render: (v: string) => <span className={isDeduct(v) ? 'balance-deduct' : ''}>{v}</span> },
|
||||
{ title: '사유', dataIndex: 'reason', width: 140, ellipsis: true },
|
||||
{
|
||||
title: '정산금액',
|
||||
dataIndex: 'amount',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (v: unknown, row: BalanceRow) => {
|
||||
if (v == null || v === '') return '-'
|
||||
const n = Number(v)
|
||||
const cls = isDeduct(row.balanceType) || n < 0 ? 'balance-amount-deduct' : 'balance-amount'
|
||||
return <span className={cls}>{n.toLocaleString()}</span>
|
||||
} },
|
||||
{
|
||||
title: '비고',
|
||||
dataIndex: 'memo',
|
||||
width: 54,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => (v ? <Tooltip title={v}><MailOutlined className="memo-icon" /></Tooltip> : '-') },
|
||||
{
|
||||
title: '관리',
|
||||
width: 58,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, row: BalanceRow) => (
|
||||
<Button size="small" type="text" icon={<EditOutlined style={{ color: '#1677ff' }} />} onClick={() => openEdit(row)} />
|
||||
) },
|
||||
]
|
||||
|
||||
const modalOpen = creating || Boolean(editing)
|
||||
|
||||
return (
|
||||
<div className="stock-page balance-page contract-balance-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<RadarChartOutlined className="product-title-icon" />
|
||||
계약후정산
|
||||
</h2>
|
||||
<p>등록된 정산 외 추가로 후정산을 등록 및 관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
loading={remove.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) return
|
||||
remove.mutate(selected)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button className="balance-register-btn" type="primary" icon={<PlusSquareOutlined />} onClick={openCreate}>
|
||||
후정산등록
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="balance-filter"
|
||||
initialValues={{ size: 10 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={() => form.setFieldValue('dates', undefined)} />
|
||||
<Form.Item name="employee">
|
||||
<Select allowClear placeholder="직원" className="w-120" options={employeeOptions} showSearch optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="agency">
|
||||
<Select allowClear placeholder="하부점" className="w-130" options={agencyOptions} showSearch optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="reason">
|
||||
<Select allowClear placeholder="사유" className="w-130" options={reasonOptions} showSearch optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="q">
|
||||
<Input className="w-130" allowClear />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((v) => ({ ...v, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="stock-table-actions">
|
||||
<Tabs
|
||||
activeKey={String(filters.tab)}
|
||||
onChange={(tab) => setFilters((v) => ({ ...v, tab, page: 0 }))}
|
||||
items={TABS.map(([key, label]) => ({
|
||||
key,
|
||||
label: key === 'ALL'
|
||||
? `${label} ${query.data?.counts?.ALL ?? query.data?.total ?? 0}`
|
||||
: `${label} ${query.data?.counts?.[key] ?? 0}` }))}
|
||||
/>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1000 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `총 ${total}건`,
|
||||
onChange: (page) => setFilters((v) => ({ ...v, page: page - 1 })) }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '계약후정산 수정' : '계약후정산 등록'}
|
||||
open={modalOpen}
|
||||
onCancel={() => { setCreating(false); setEditing(null) }}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={520}
|
||||
className="balance-modal"
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '110px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
onFinish={(values) => save.mutate(values)}
|
||||
className="balance-edit-form"
|
||||
>
|
||||
<Form.Item name="targetType" label="후정산대상" rules={[{ required: true }]}>
|
||||
<Radio.Group
|
||||
options={options(['직원', '하부점'])}
|
||||
onChange={() => editForm.setFieldValue('targetName', undefined)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="targetName"
|
||||
label={targetType === '하부점' ? '하부점' : '직원'}
|
||||
rules={[{ required: true, message: '대상을 선택하세요' }]}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="- 선택 -"
|
||||
options={targetType === '하부점' ? agencyOptions : employeeOptions}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="baseDate" label="후정산일" rules={[{ required: true, message: '후정산일을 선택하세요' }]}>
|
||||
<DatePicker className="full-width" />
|
||||
</Form.Item>
|
||||
<Form.Item name="balanceType" label="구분" rules={[{ required: true }]}>
|
||||
<Radio.Group
|
||||
options={options(['정산차감', '정산추가'])}
|
||||
onChange={(e) => setAmountSign(String(e.target.value).includes('차감') ? '-' : '+')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="정산금액" required>
|
||||
<Space.Compact className="balance-amount-input">
|
||||
<Button
|
||||
className={amountSign === '-' ? 'balance-sign-minus' : ''}
|
||||
onClick={() => setAmountSign((s) => (s === '+' ? '-' : '+'))}
|
||||
>
|
||||
{amountSign}
|
||||
</Button>
|
||||
<Form.Item name="amountAbs" noStyle rules={[{ required: true, message: '정산금액을 입력하세요' }]}>
|
||||
<InputNumber controls={false} min={0} className="full-width" />
|
||||
</Form.Item>
|
||||
<Button disabled>원</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="사유" rules={[{ required: true, message: '사유를 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="비고">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="balance-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ContractListPage } from './ContractListShared'
|
||||
|
||||
export default function ContractHomePage() {
|
||||
return (
|
||||
<ContractListPage
|
||||
kind="HOME"
|
||||
title="홈렌탈접수"
|
||||
description="홈렌탈접수과 관련된 업무를 관리하실 수 있습니다."
|
||||
createLabel="홈렌탈접수 등록"
|
||||
writeType="home"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ContractListPage } from './ContractListShared'
|
||||
|
||||
export default function ContractInternetPage() {
|
||||
return (
|
||||
<ContractListPage
|
||||
kind="INTERNET"
|
||||
title="인터넷접수"
|
||||
description="인터넷접수과 관련된 업무를 관리하실 수 있습니다."
|
||||
createLabel="인터넷접수 등록"
|
||||
writeType="internet"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Button, Card, DatePicker, Form, Input, Select, Space } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
CloseOutlined, FileTextOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SolutionOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const options = (values: string[]) => values.map((value) => ({ value, label: value }))
|
||||
const money = (v: unknown) => (v == null || v === '' ? '-' : Number(v).toLocaleString())
|
||||
|
||||
type Contract = Record<string, unknown> & { id: number }
|
||||
type SearchResult = { total: number; list: Contract[] }
|
||||
|
||||
const initial = { page: 0, size: 15 } as Record<string, unknown>
|
||||
|
||||
export default function ContractListPage() {
|
||||
const [form] = Form.useForm()
|
||||
const navigate = useNavigate()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['contracts-search', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<SearchResult>>(`${apiPrefix()}/contracts/search`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setFilters({
|
||||
...initial,
|
||||
...values,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 15 })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ size: 15 })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '계약일', dataIndex: 'contractDate', width: 100, sorter: true },
|
||||
{ title: '상태', dataIndex: 'status', width: 80 },
|
||||
{ title: '고객명', dataIndex: 'customerName', width: 100, sorter: true },
|
||||
{ title: '연락처', dataIndex: 'phone', width: 120 },
|
||||
{ title: '상품', dataIndex: 'productName', width: 140, ellipsis: true },
|
||||
{ title: '통신사', dataIndex: 'telecom', width: 70 },
|
||||
{ title: '계약금액', dataIndex: 'amount', width: 110, align: 'right' as const, render: money },
|
||||
{ title: '월정액', dataIndex: 'monthlyFee', width: 100, align: 'right' as const, render: money },
|
||||
{ title: '담당자', dataIndex: 'employee', width: 90 },
|
||||
{
|
||||
title: '관리', width: 58, fixed: 'right' as const,
|
||||
render: (_: unknown, row: Contract) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<FileTextOutlined style={{ color: '#1677ff' }} />}
|
||||
onClick={() => navigate(`/care/contract/write?id=${row.id}`)}
|
||||
/>
|
||||
) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stock-page home-sale-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><SolutionOutlined style={{ marginRight: 8 }} />계약목록</h2>
|
||||
<p>홈상품·렌탈 등 계약 내역을 조회하고 관리합니다.</p>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/care/contract/write')}>계약 등록</Button>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form form={form} layout="inline" className="home-filter" initialValues={{ size: 15 }} onFinish={onSearch}>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={() => form.setFieldValue('dates', undefined)} />
|
||||
</div>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="status"><Select allowClear placeholder="-상태-" className="w-110" options={options(['대기', '진행', '완료', '해지'])} /></Form.Item>
|
||||
<Form.Item name="telecom"><Select allowClear placeholder="-통신사-" className="w-110" options={options(['SKT', 'KT', 'LGU+'])} /></Form.Item>
|
||||
<Form.Item name="customerName"><Input placeholder="고객명" className="w-130" allowClear /></Form.Item>
|
||||
<Form.Item name="productName"><Input placeholder="상품명" className="w-130" allowClear /></Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => { form.setFieldValue('size', size); setFilters((v) => ({ ...v, size, page: 0 })) }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `총 ${total}건`,
|
||||
onChange: (page) => setFilters((v) => ({ ...v, page: page - 1 })) }}
|
||||
locale={{ emptyText: query.isError ? '데이터를 불러오지 못했습니다.' : '등록된 계약이 없습니다.' }}
|
||||
onChange={(_, __, sorter) => {
|
||||
const order = Array.isArray(sorter) ? sorter[0] : sorter
|
||||
if (order?.field) setFilters((v) => ({ ...v, sort: `${String(order.field)},${order.order === 'ascend' ? 'asc' : 'desc'}` }))
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
Button, Card, Checkbox, DatePicker, Form, Input, Select, Space, Tabs, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
CloseOutlined, DownloadOutlined, EditOutlined, FormOutlined, PlusSquareOutlined,
|
||||
ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { money, type Row } from './homesShared'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
const STATUS_TABS = [
|
||||
{ key: 'ALL', label: '전체' },
|
||||
{ key: '접수', label: '접수' },
|
||||
{ key: '신청', label: '신청' },
|
||||
{ key: '완료', label: '완료' },
|
||||
{ key: '철회', label: '철회' },
|
||||
]
|
||||
const SEARCH_TYPES = ['고객명', '거래처', '직원', '연락처', '가입상품']
|
||||
|
||||
type ContractPage = {
|
||||
total: number
|
||||
list: Row[]
|
||||
counts?: Record<string, number>
|
||||
agencies?: string[]
|
||||
employees?: string[]
|
||||
}
|
||||
|
||||
function maskName(v: unknown) {
|
||||
const s = String(v ?? '')
|
||||
if (s.length <= 1) return s
|
||||
if (s.length === 2) return `${s[0]}*`
|
||||
return `${s[0]}${'*'.repeat(s.length - 2)}${s[s.length - 1]}`
|
||||
}
|
||||
|
||||
function maskPhone(v: unknown) {
|
||||
const digits = String(v ?? '').replace(/\D/g, '')
|
||||
if (digits.length < 7) return String(v ?? '')
|
||||
return `${digits.slice(0, 3)}-****-${digits.slice(-4)}`
|
||||
}
|
||||
|
||||
function moneyMargin(v: unknown) {
|
||||
if (v == null || v === '') return '-'
|
||||
const n = Number(v)
|
||||
return <span className={n < 0 ? 'contract-margin-neg' : 'contract-margin'}>{n.toLocaleString()}</span>
|
||||
}
|
||||
|
||||
function statusClass(v: string) {
|
||||
if (v === '철회') return 'contract-status-cancel'
|
||||
if (v === '신청') return 'contract-status-apply'
|
||||
if (v === '접수') return 'contract-status-recv'
|
||||
return undefined
|
||||
}
|
||||
|
||||
type Props = {
|
||||
kind: 'INTERNET' | 'HOME'
|
||||
title: string
|
||||
description: string
|
||||
createLabel: string
|
||||
writeType: 'internet' | 'home'
|
||||
}
|
||||
|
||||
export function ContractListPage({ kind, title, description, createLabel, writeType }: Props) {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [statusForm] = Form.useForm()
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [statusOpen, setStatusOpen] = useState(false)
|
||||
const [sumOpen, setSumOpen] = useState(false)
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({
|
||||
kind,
|
||||
status: 'ALL',
|
||||
page: 0,
|
||||
size: 10 })
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes/contracts', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<ContractPage>>(`${apiPrefix()}/homes/contracts`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/contracts/delete`, { ids })
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
setSelected([])
|
||||
qc.invalidateQueries({ queryKey: ['homes/contracts'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const changeStatus = useMutation({
|
||||
mutationFn: async (values: { status: string }) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/contracts/status`, { ids: selected, status: values.status })
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('상태를 변경했습니다.')
|
||||
setStatusOpen(false)
|
||||
setSelected([])
|
||||
qc.invalidateQueries({ queryKey: ['homes/contracts'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
const clearDates = () => form.setFieldValue('dates', undefined)
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setSelected([])
|
||||
setFilters({
|
||||
kind,
|
||||
status: filters.status || 'ALL',
|
||||
agency: values.agency || undefined,
|
||||
employee: values.employee || undefined,
|
||||
searchType: values.searchType || '고객명',
|
||||
q: values.q || undefined,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 10 })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ searchType: '고객명', size: 10 })
|
||||
setSelected([])
|
||||
setFilters({ kind, status: 'ALL', page: 0, size: 10 })
|
||||
}
|
||||
|
||||
const download = () => {
|
||||
const rows = query.data?.list || []
|
||||
if (!rows.length) {
|
||||
message.warning('다운로드할 목록이 없습니다.')
|
||||
return
|
||||
}
|
||||
const header = ['상태', '계약일', '고객명', '연락처', '가입상품', '정책합산', '정산금액', '판매마진', '거래처', '직원']
|
||||
const lines = rows.map((r) =>
|
||||
[r.status, r.contractDate, r.customerName, r.phone, r.products, r.policySum, r.settleAmount, r.margin, r.agency, r.employee]
|
||||
.map((v) => `"${String(v ?? '').replace(/"/g, '""')}"`)
|
||||
.join(','),
|
||||
)
|
||||
const blob = new Blob(['\uFEFF' + [header.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${title}_${dayjs().format('YYYYMMDD')}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const list = query.data?.list || []
|
||||
const counts = query.data?.counts
|
||||
const agencyOptions = useMemo(
|
||||
() => (query.data?.agencies || []).map((v) => ({ value: v, label: v })),
|
||||
[query.data?.agencies],
|
||||
)
|
||||
const employeeOptions = useMemo(
|
||||
() => (query.data?.employees || []).map((v) => ({ value: v, label: v })),
|
||||
[query.data?.employees],
|
||||
)
|
||||
|
||||
const selectedSums = useMemo(() => {
|
||||
const rows = selected.length ? list.filter((r) => selected.includes(r.id)) : list
|
||||
return {
|
||||
settle: rows.reduce((s, r) => s + Number(r.settleAmount || 0), 0),
|
||||
margin: rows.reduce((s, r) => s + Number(r.margin || 0), 0),
|
||||
count: rows.length }
|
||||
}, [list, selected])
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: (
|
||||
<Checkbox
|
||||
checked={list.length > 0 && selected.length === list.length}
|
||||
indeterminate={selected.length > 0 && selected.length < list.length}
|
||||
onChange={(e) => setSelected(e.target.checked ? list.map((r) => r.id) : [])}
|
||||
/>
|
||||
),
|
||||
width: 48,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<Checkbox
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={(e) => {
|
||||
setSelected((prev) => (e.target.checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)))
|
||||
}}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '상태',
|
||||
dataIndex: 'status',
|
||||
width: 70,
|
||||
render: (v: string) => <span className={statusClass(v)}>{v}</span> },
|
||||
{ title: '계약일', dataIndex: 'contractDate', width: 100 },
|
||||
{ title: '고객명', dataIndex: 'customerName', width: 90, render: maskName },
|
||||
{ title: '연락처', dataIndex: 'phone', width: 130, render: maskPhone },
|
||||
{ title: '가입상품', dataIndex: 'products', width: 180, ellipsis: true },
|
||||
{ title: '정책합산', dataIndex: 'policySum', width: 110, align: 'right' as const, render: money },
|
||||
{ title: '정산금액', dataIndex: 'settleAmount', width: 110, align: 'right' as const, render: money },
|
||||
{ title: '판매마진', dataIndex: 'margin', width: 110, align: 'right' as const, render: moneyMargin },
|
||||
{ title: '거래처', dataIndex: 'agency', width: 120, ellipsis: true },
|
||||
{ title: '직원(하부점)', dataIndex: 'employee', width: 110 },
|
||||
{
|
||||
title: '관리',
|
||||
width: 64,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
className="product-manage-btn"
|
||||
icon={<FormOutlined />}
|
||||
onClick={() => navigate(`/care/contract/write?type=${writeType}&id=${row.id}`)}
|
||||
/>
|
||||
) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stock-page contract-internet-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<EditOutlined className="product-title-icon" />
|
||||
{title}
|
||||
</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('계약을 선택하세요.')
|
||||
return
|
||||
}
|
||||
statusForm.setFieldsValue({ status: '완료' })
|
||||
setStatusOpen(true)
|
||||
}}
|
||||
>
|
||||
상태
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
loading={remove.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('삭제할 계약을 선택하세요.')
|
||||
return
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '계약 삭제',
|
||||
content: `선택한 ${selected.length}건을 삭제하시겠습니까?`,
|
||||
onOk: () => remove.mutateAsync(selected) })
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button icon={<SearchOutlined />} onClick={() => setSumOpen(true)}>정산/마진합계</Button>
|
||||
<Button type="primary" icon={<PlusSquareOutlined />} onClick={() => navigate(`/care/contract/write?type=${writeType}`)}>
|
||||
{createLabel}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form form={form} layout="inline" className="home-filter" initialValues={{ searchType: '고객명', size: 10 }} onFinish={onSearch}>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={clearDates} />
|
||||
</div>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="agency">
|
||||
<Select allowClear placeholder="거래처" className="w-130" options={agencyOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="employee">
|
||||
<Select allowClear placeholder="직원" className="w-130" options={employeeOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-110" options={SEARCH_TYPES.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="q">
|
||||
<Input allowClear placeholder="검색어" className="w-150" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((prev) => ({ ...prev, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card product-internet-card" size="small">
|
||||
<div className="product-internet-tabs">
|
||||
<Tabs
|
||||
activeKey={String(filters.status || 'ALL')}
|
||||
onChange={(key) => {
|
||||
setSelected([])
|
||||
setFilters((prev) => ({ ...prev, status: key, page: 0 }))
|
||||
}}
|
||||
items={STATUS_TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: counts?.[t.key] != null ? `${t.label} ${counts[t.key]}` : t.label }))}
|
||||
/>
|
||||
<div className="product-internet-settings">
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1300 }}
|
||||
pagination={{
|
||||
current: Number(filters.page || 0) + 1,
|
||||
pageSize: Number(filters.size || 10),
|
||||
total: query.data?.total || 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => {
|
||||
setSelected([])
|
||||
setFilters((prev) => ({ ...prev, page: page - 1 }))
|
||||
} }}
|
||||
locale={{ emptyText: '등록되어 있는 자료가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal title="상태 변경" open={statusOpen} onCancel={() => setStatusOpen(false)} footer={null} destroyOnHidden width={360}>
|
||||
<Form form={statusForm} layout="vertical" onFinish={(v) => changeStatus.mutate(v)}>
|
||||
<Form.Item name="status" label="상태" rules={[{ required: true }]}>
|
||||
<Select options={['접수', '신청', '완료', '철회'].map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<div className="product-modal-footer-right">
|
||||
<Button type="primary" htmlType="submit" loading={changeStatus.isPending}>변경합니다</Button>
|
||||
<Button onClick={() => setStatusOpen(false)}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="정산/마진합계" open={sumOpen} onCancel={() => setSumOpen(false)} footer={null} destroyOnHidden width={400}>
|
||||
<Typography.Paragraph type="secondary">
|
||||
{selected.length ? `선택 ${selectedSums.count}건` : `현재 목록 ${selectedSums.count}건`} 기준입니다.
|
||||
</Typography.Paragraph>
|
||||
<div className="contract-sum-box">
|
||||
<div><span>정산금액 합계</span><b>{selectedSums.settle.toLocaleString()}원</b></div>
|
||||
<div>
|
||||
<span>판매마진 합계</span>
|
||||
<b className={selectedSums.margin < 0 ? 'contract-margin-neg' : 'contract-margin'}>
|
||||
{selectedSums.margin.toLocaleString()}원
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
<div className="product-modal-footer-right" style={{ marginTop: 16 }}>
|
||||
<Button onClick={() => setSumOpen(false)}>닫기</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { HomesLogListPage } from './HomesLogListPage'
|
||||
|
||||
const ACTIONS = [
|
||||
'인터넷접수-등록',
|
||||
'인터넷접수-수정',
|
||||
'인터넷접수-삭제',
|
||||
'인터넷접수-상태',
|
||||
'홈렌탈접수-등록',
|
||||
'홈렌탈접수-수정',
|
||||
'홈렌탈접수-삭제',
|
||||
'홈렌탈접수-상태',
|
||||
]
|
||||
|
||||
export default function ContractLogPage() {
|
||||
return (
|
||||
<HomesLogListPage
|
||||
domain="CONTRACT"
|
||||
title="계약이력"
|
||||
description="계약관리의 변동이력을 확인하실 수 있습니다."
|
||||
defaultActions={ACTIONS}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
Button, Card, Checkbox, Col, DatePicker, Form, Input, InputNumber, Radio, Row, Select, Space, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { EditOutlined, PlusOutlined, SettingOutlined, UnorderedListOutlined } from '@ant-design/icons'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { money, options, type PageResult, type Row as DataRow } from './homesShared'
|
||||
|
||||
type ProductLine = {
|
||||
key: string
|
||||
productId?: number
|
||||
name: string
|
||||
months?: number
|
||||
policyAmount?: number
|
||||
}
|
||||
|
||||
function splitPhone(phone?: string) {
|
||||
const d = String(phone ?? '').replace(/\D/g, '')
|
||||
return {
|
||||
phone1: d.slice(0, 3) || '010',
|
||||
phone2: d.slice(3, 7),
|
||||
phone3: d.slice(7, 11) }
|
||||
}
|
||||
|
||||
function joinPhone(v: Record<string, unknown>, prefix = 'phone') {
|
||||
const a = String(v[`${prefix}1`] ?? '').trim()
|
||||
const b = String(v[`${prefix}2`] ?? '').trim()
|
||||
const c = String(v[`${prefix}3`] ?? '').trim()
|
||||
if (!a && !b && !c) return ''
|
||||
return `${a}-${b}-${c}`
|
||||
}
|
||||
|
||||
export default function ContractWritePage() {
|
||||
const [params] = useSearchParams()
|
||||
const type = params.get('type') === 'home' ? 'HOME' : 'INTERNET'
|
||||
const id = params.get('id')
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [productForm] = Form.useForm()
|
||||
const [lines, setLines] = useState<ProductLine[]>([])
|
||||
const [productOpen, setProductOpen] = useState(false)
|
||||
const back = type === 'HOME' ? '/care/contract/home' : '/care/contract/internet'
|
||||
const title = type === 'HOME' ? '홈렌탈 접수등록' : '인터넷 접수등록'
|
||||
const guide = type === 'HOME'
|
||||
? '홈렌탈상품의 계약정보를 아래의 양식 순서대로 입력해주세요. (필수 표시는 필수항목입니다.)'
|
||||
: '인터넷상품의 계약정보를 아래의 양식 순서대로 입력해주세요. (필수 표시는 필수항목입니다.)'
|
||||
const sameContact = Form.useWatch('sameContact', form)
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: ['homes-contract', id],
|
||||
enabled: !!id,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>>>(`${apiPrefix()}/homes/contracts/${id}`)
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const agencies = useQuery({
|
||||
queryKey: ['homes/agencies-options'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<PageResult>>(`${apiPrefix()}/homes/agencies`, { params: { page: 0, size: 200 } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const products = useQuery({
|
||||
queryKey: ['homes/products-for-contract', type],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<PageResult>>(`${apiPrefix()}/homes/products`, {
|
||||
params: { kind: type, category: 'ALL', page: 0, size: 200 } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail.data) return
|
||||
const d = detail.data
|
||||
const phone = splitPhone(String(d.phone ?? ''))
|
||||
const installPhone = splitPhone(String(d.installPhone ?? d.phone ?? ''))
|
||||
const birth = String(d.birthDate ?? '').split('-')
|
||||
form.setFieldsValue({
|
||||
...d,
|
||||
...phone,
|
||||
installPhone1: installPhone.phone1,
|
||||
installPhone2: installPhone.phone2,
|
||||
installPhone3: installPhone.phone3,
|
||||
birthYear: birth[0] || undefined,
|
||||
birthMonth: birth[1] || undefined,
|
||||
birthDay: birth[2] || undefined,
|
||||
contractDate: d.contractDate ? dayjs(String(d.contractDate)) : dayjs(),
|
||||
installDate: d.installDate ? dayjs(String(d.installDate)) : undefined,
|
||||
customerGrade: d.customerGrade || '분류없음',
|
||||
sameContact: d.sameContact ?? false })
|
||||
const names = String(d.products ?? '').split(',').map((v) => v.trim()).filter(Boolean)
|
||||
setLines(names.map((name, i) => ({
|
||||
key: `e-${i}`,
|
||||
name,
|
||||
months: Number(d.months || 0) || undefined,
|
||||
policyAmount: names.length ? Number(d.policySum || 0) / names.length : undefined })))
|
||||
}, [detail.data, form])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sameContact) return
|
||||
const values = form.getFieldsValue()
|
||||
form.setFieldsValue({
|
||||
installPhone1: values.phone1,
|
||||
installPhone2: values.phone2,
|
||||
installPhone3: values.phone3,
|
||||
installZip: values.zipcode,
|
||||
installAddress: values.address,
|
||||
installAddressDetail: values.addressDetail })
|
||||
}, [sameContact, form])
|
||||
|
||||
const policySum = useMemo(
|
||||
() => lines.reduce((s, l) => s + Number(l.policyAmount || 0), 0),
|
||||
[lines],
|
||||
)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
if (!lines.length) throw new Error('계약 상품을 추가하세요.')
|
||||
const birthParts = [values.birthYear, values.birthMonth, values.birthDay].filter(Boolean)
|
||||
const payload: Record<string, unknown> = {
|
||||
...values,
|
||||
kind: type,
|
||||
status: values.status || (id ? detail.data?.status : '접수') || '접수',
|
||||
phone: joinPhone(values, 'phone'),
|
||||
installPhone: joinPhone(values, 'installPhone'),
|
||||
birthDate: birthParts.length === 3
|
||||
? `${values.birthYear}-${String(values.birthMonth).padStart(2, '0')}-${String(values.birthDay).padStart(2, '0')}`
|
||||
: null,
|
||||
contractDate: values.contractDate ? dayjs(values.contractDate as string).format('YYYY-MM-DD') : null,
|
||||
installDate: values.installDate ? dayjs(values.installDate as string).format('YYYY-MM-DD') : null,
|
||||
products: lines.map((l) => l.name).join(', '),
|
||||
months: lines[0]?.months ?? null,
|
||||
policySum,
|
||||
settleAmount: values.settleAmount ?? policySum,
|
||||
margin: values.margin ?? 0 }
|
||||
;['phone1', 'phone2', 'phone3', 'installPhone1', 'installPhone2', 'installPhone3', 'birthYear', 'birthMonth', 'birthDay'].forEach((k) => {
|
||||
delete payload[k]
|
||||
})
|
||||
if (id) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/contracts/${id}`, payload)
|
||||
return unwrap(data)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/contracts`, payload)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(id ? '수정했습니다.' : '등록했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes/contracts'] })
|
||||
navigate(back)
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const agencyOptions = useMemo(
|
||||
() => (agencies.data?.list || []).map((a) => ({ value: String(a.name), label: String(a.name) })),
|
||||
[agencies.data],
|
||||
)
|
||||
|
||||
const productOptions = useMemo(
|
||||
() => (products.data?.list || []).map((p: DataRow) => ({
|
||||
value: p.id,
|
||||
label: `${p.company ? `[${p.company}] ` : ''}${p.name}`,
|
||||
raw: p })),
|
||||
[products.data],
|
||||
)
|
||||
|
||||
const addProduct = (values: { productId: number }) => {
|
||||
const found = productOptions.find((o) => o.value === values.productId)
|
||||
if (!found) return
|
||||
const p = found.raw
|
||||
setLines((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: `p-${p.id}-${Date.now()}`,
|
||||
productId: Number(p.id),
|
||||
name: String(p.name),
|
||||
months: Number(p.months || 0) || undefined,
|
||||
policyAmount: Number(p.policyAmount || 0) },
|
||||
])
|
||||
setProductOpen(false)
|
||||
productForm.resetFields()
|
||||
}
|
||||
|
||||
const searchAddress = (target: 'customer' | 'install') => {
|
||||
message.info('주소검색(데모) — 직접 입력해 주세요.')
|
||||
if (target === 'customer') {
|
||||
form.setFieldsValue({ zipcode: form.getFieldValue('zipcode') || '06236', address: form.getFieldValue('address') || '서울특별시 강남구' })
|
||||
} else {
|
||||
form.setFieldsValue({ installZip: form.getFieldValue('installZip') || '06236', installAddress: form.getFieldValue('installAddress') || '서울특별시 강남구' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stock-page contract-write-page">
|
||||
<div className="contract-write-head">
|
||||
<div className="stock-title contract-write-title">
|
||||
<h2>
|
||||
<EditOutlined className="product-title-icon" />
|
||||
{title}
|
||||
</h2>
|
||||
<div className="contract-write-title-sub">
|
||||
<p>{guide}</p>
|
||||
<Button icon={<UnorderedListOutlined />} onClick={() => navigate(back)}>
|
||||
목록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="contract-write-head-spacer" aria-hidden />
|
||||
</div>
|
||||
|
||||
<div className="contract-write-layout">
|
||||
<Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '110px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
colon={false}
|
||||
className="contract-write-form"
|
||||
initialValues={{
|
||||
customerType: '개인',
|
||||
employee: '홍길동',
|
||||
customerGrade: '분류없음',
|
||||
phone1: '010',
|
||||
installPhone1: '010',
|
||||
payMethod: '자동이체',
|
||||
contractDate: dayjs(),
|
||||
sameContact: false,
|
||||
settleAmount: 0,
|
||||
margin: 0,
|
||||
nationality: '내국인' }}
|
||||
onFinish={(v) => save.mutate(v)}
|
||||
>
|
||||
<Card className="open-section" size="small" title="가입정보">
|
||||
<Form.Item name="customerType" label="고객구분" rules={[{ required: true }]}>
|
||||
<Radio.Group options={[{ value: '개인', label: '개인' }, { value: '사업자', label: '사업자' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="employee" label="접수직원">
|
||||
<Select options={options(['홍길동', '홍총괄', '김대표', 'demo'])} />
|
||||
</Form.Item>
|
||||
<Form.Item label="고객명" required>
|
||||
<Space.Compact className="full-width">
|
||||
<Form.Item name="customerName" noStyle rules={[{ required: true, message: '고객명을 입력하세요' }]}>
|
||||
<Input style={{ width: '50%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="customerGrade" noStyle>
|
||||
<Select style={{ width: '35%' }} options={options(['분류없음', '단골', '단골1', '단골2', '일반'])} />
|
||||
</Form.Item>
|
||||
<Button icon={<SettingOutlined />} onClick={() => message.info('고객분류 설정(데모)')} />
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="고객연락처" required>
|
||||
<Space.Compact>
|
||||
<Form.Item name="phone1" noStyle rules={[{ required: true }]}>
|
||||
<Select className="w-100" options={options(['010', '011', '016', '017', '018', '019'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone2" noStyle rules={[{ required: true, message: '연락처 필수' }]}>
|
||||
<Input className="w-100" maxLength={4} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone3" noStyle rules={[{ required: true, message: '연락처 필수' }]}>
|
||||
<Input className="w-100" maxLength={4} />
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="법정생일">
|
||||
<Space.Compact>
|
||||
<Form.Item name="birthYear" noStyle><Input className="w-100" placeholder="년" maxLength={4} /></Form.Item>
|
||||
<Form.Item name="birthMonth" noStyle><Input className="w-100" placeholder="월" maxLength={2} /></Form.Item>
|
||||
<Form.Item name="birthDay" noStyle><Input className="w-100" placeholder="일" maxLength={2} /></Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="성별/국적">
|
||||
<Space>
|
||||
<Form.Item name="gender" noStyle>
|
||||
<Select allowClear placeholder="- 성별 -" className="w-130" options={options(['남', '여'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="nationality" noStyle>
|
||||
<Select allowClear placeholder="- 국적 -" className="w-130" options={options(['내국인', '외국인'])} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label="이메일"><Input /></Form.Item>
|
||||
<Form.Item label="고객주소">
|
||||
<Space direction="vertical" className="full-width" size={8}>
|
||||
<Space.Compact className="full-width">
|
||||
<Button onClick={() => searchAddress('customer')}>주소검색</Button>
|
||||
<Form.Item name="zipcode" noStyle><Input style={{ width: 100 }} placeholder="우편번호" /></Form.Item>
|
||||
<Form.Item name="address" noStyle><Input placeholder="주소" /></Form.Item>
|
||||
</Space.Compact>
|
||||
<Form.Item name="addressDetail" noStyle><Input placeholder="상세주소" /></Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="sameContact" valuePropName="checked" label=" ">
|
||||
<Checkbox>상기 고객연락처와 주소가 동일합니다.</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item label="설치연락처">
|
||||
<Space.Compact>
|
||||
<Form.Item name="installPhone1" noStyle>
|
||||
<Select className="w-100" options={options(['010', '011', '016', '017', '018', '019'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="installPhone2" noStyle><Input className="w-100" maxLength={4} /></Form.Item>
|
||||
<Form.Item name="installPhone3" noStyle><Input className="w-100" maxLength={4} /></Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="설치주소">
|
||||
<Space direction="vertical" className="full-width" size={8}>
|
||||
<Space.Compact className="full-width">
|
||||
<Button onClick={() => searchAddress('install')}>주소검색</Button>
|
||||
<Form.Item name="installZip" noStyle><Input style={{ width: 100 }} placeholder="우편번호" /></Form.Item>
|
||||
<Form.Item name="installAddress" noStyle><Input placeholder="주소" /></Form.Item>
|
||||
</Space.Compact>
|
||||
<Form.Item name="installAddressDetail" noStyle><Input placeholder="상세주소" /></Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Card className="open-section" size="small" title="계약정보">
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="contractDate" label="계약일" rules={[{ required: true, message: '계약일을 선택하세요' }]}>
|
||||
<DatePicker className="full-width" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="installDate" label="설치완료일">
|
||||
<DatePicker className="full-width" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="agency" label="거래처">
|
||||
<Select allowClear placeholder="- 선택 -" options={agencyOptions.length ? agencyOptions : options(
|
||||
type === 'HOME'
|
||||
? ['(주)코웨이', '청호나이스', 'SK매직', 'LG전자', '늘푸른통신']
|
||||
: ['늘푸른통신', '대박통신', '스마트홈', 'SK브로드밴드', '(주)코웨이'],
|
||||
)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="payMethod" label="결제방법">
|
||||
<Radio.Group options={[
|
||||
{ value: '자동이체', label: '자동이체' },
|
||||
{ value: '카드', label: '카드' },
|
||||
{ value: '현금', label: '현금' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="feeReduction" label="요금감면">
|
||||
<Select allowClear placeholder="- 선택 -" options={options(['해당없음', '기초생활수급자', '장애인', '국가유공자', '차상위계층'])} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="invoiceType" label="청구서">
|
||||
<Select allowClear placeholder="- 선택 -" options={options(['이메일', '문자', '우편', '앱'])} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="open-section"
|
||||
size="small"
|
||||
title="상품정보"
|
||||
extra={<Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => setProductOpen(true)}>상품추가</Button>}
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<p className="contract-product-empty">아래 상품추가 버튼을 클릭하여 계약 상품을 추가하세요.</p>
|
||||
) : (
|
||||
<CareTable
|
||||
rowKey="key"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={lines}
|
||||
columns={[
|
||||
{ title: '상품명', dataIndex: 'name' },
|
||||
{
|
||||
title: '약정',
|
||||
dataIndex: 'months',
|
||||
width: 100,
|
||||
render: (v: number) => (v ? `${v}개월` : '-') },
|
||||
{
|
||||
title: '정책금액',
|
||||
dataIndex: 'policyAmount',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: money },
|
||||
{
|
||||
title: '관리',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: ProductLine) => (
|
||||
<Button type="link" danger size="small" onClick={() => setLines((prev) => prev.filter((l) => l.key !== row.key))}>
|
||||
삭제
|
||||
</Button>
|
||||
) },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<div className="contract-product-sum">
|
||||
<span>정책합산</span>
|
||||
<b>{policySum.toLocaleString()}원</b>
|
||||
</div>
|
||||
<Row gutter={16} style={{ marginTop: 12 }}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="settleAmount" label="정산금액">
|
||||
<InputNumber className="full-width" controls={false} addonAfter="원" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="margin" label="판매마진">
|
||||
<InputNumber className="full-width" controls={false} addonAfter="원" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="memo" label="비고">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<div className="open-actions">
|
||||
<Button onClick={() => navigate(back)}>목록</Button>
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>{id ? '수정합니다' : '등록'}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<aside className="contract-pending-panel">
|
||||
<div className="contract-pending-head">
|
||||
<span>일정관리 등록</span>
|
||||
<Button size="small" className="contract-pending-btn" onClick={() => message.info('일정등록(데모)')}>일정등록</Button>
|
||||
</div>
|
||||
<div className="contract-pending-body">
|
||||
등록하실 일정관리가 없습니다.<br />(미수금, 할인, 미비서류, 정책약속 등)
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="상품추가"
|
||||
open={productOpen}
|
||||
onCancel={() => setProductOpen(false)}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={420}
|
||||
>
|
||||
<Form form={productForm} layout="vertical" onFinish={addProduct}>
|
||||
<Form.Item name="productId" label="상품" rules={[{ required: true, message: '상품을 선택하세요' }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="- 선택 -"
|
||||
options={productOptions.map((o) => ({ value: o.value, label: o.label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div className="product-modal-footer-right">
|
||||
<Button type="primary" htmlType="submit">추가</Button>
|
||||
<Button onClick={() => setProductOpen(false)}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { EditOutlined, QuestionCircleOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type InquiryRow = {
|
||||
id: number
|
||||
no?: number
|
||||
title?: string
|
||||
answer?: string
|
||||
status?: string
|
||||
authorName?: string
|
||||
createdDate?: string
|
||||
content?: string
|
||||
reply?: string
|
||||
}
|
||||
|
||||
type InquiryData = {
|
||||
total: number
|
||||
list: InquiryRow[]
|
||||
}
|
||||
|
||||
export default function CsInquiryPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [detail, setDetail] = useState<InquiryRow | null>(null)
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({
|
||||
searchType: '제목',
|
||||
page: 0,
|
||||
size: 15,
|
||||
sort: 'createdAt,desc' })
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['cs-inquiries', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<InquiryData>>(`${apiPrefix()}/cs/search`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['cs-inquiries'] })
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/cs`, values)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('사용문의를 등록했습니다.')
|
||||
setOpen(false)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const openDetail = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.get<ApiResponse<InquiryRow>>(`${apiPrefix()}/cs/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (row) => setDetail(row),
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
searchType: values.searchType || '제목',
|
||||
keyword: values.keyword,
|
||||
page: 0,
|
||||
size: Number(values.size ?? prev.size ?? 15) }))
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.setFieldsValue({ searchType: '제목', keyword: undefined, size: 15 })
|
||||
setFilters({ searchType: '제목', page: 0, size: 15, sort: 'createdAt,desc' })
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '번호',
|
||||
dataIndex: 'no',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '제목',
|
||||
dataIndex: 'title',
|
||||
ellipsis: true,
|
||||
render: (v: string, row: InquiryRow) => (
|
||||
<Button type="link" className="cs-notice-title" onClick={() => openDetail.mutate(row.id)}>
|
||||
{v}
|
||||
</Button>
|
||||
) },
|
||||
{
|
||||
title: '답변',
|
||||
dataIndex: 'answer',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '작성자',
|
||||
dataIndex: 'authorName',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '등록일',
|
||||
dataIndex: 'createdDate',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
], [])
|
||||
|
||||
return (
|
||||
<div className="stock-page cs-notice-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><QuestionCircleOutlined style={{ marginRight: 8 }} />사용문의</h2>
|
||||
<p>궁금하신 사항이나 사용상 문제가 있으시면 언제든지 내용을 남겨주세요. 담당자 확인 후 답변드리겠습니다.</p>
|
||||
</div>
|
||||
<Button type="primary" icon={<EditOutlined />} onClick={() => { editForm.resetFields(); setOpen(true) }}>
|
||||
사용문의 등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="farebox-filter"
|
||||
initialValues={{ searchType: '제목', size: 15 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="farebox-filter-row">
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-110" options={['제목', '작성자', '내용', '답변'].map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-230" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button onClick={onReset}>초기화</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="size" className="farebox-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => setFilters((prev) => ({ ...prev, size, page: 0 }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="farebox-table-toolbar">
|
||||
<Typography.Text>목록 {query.data?.total ?? 0}</Typography.Text>
|
||||
</div>
|
||||
<CareTable
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
rowKey="id"
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
current: Number(filters.page ?? 0) + 1,
|
||||
pageSize: Number(filters.size ?? 15),
|
||||
total: query.data?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
onChange={(_p, _f, sorter) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter
|
||||
if (!s?.field) return
|
||||
const fieldMap: Record<string, string> = {
|
||||
no: 'id',
|
||||
answer: 'answer',
|
||||
authorName: 'authorName',
|
||||
createdDate: 'createdAt',
|
||||
title: 'title' }
|
||||
const field = fieldMap[String(s.field)] || String(s.field)
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort: `${field},${s.order === 'ascend' ? 'asc' : 'desc'}`,
|
||||
page: 0 }))
|
||||
}}
|
||||
locale={{ emptyText: '등록된 사용문의가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="사용문의 등록"
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
width={640}
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => createMut.mutate(values)}
|
||||
>
|
||||
<Form.Item name="title" label="제목" rules={[{ required: true, message: '제목을 입력하세요.' }]}>
|
||||
<Input maxLength={200} />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="내용" rules={[{ required: true, message: '내용을 입력하세요.' }]}>
|
||||
<Input.TextArea rows={8} />
|
||||
</Form.Item>
|
||||
<div className="bbs-write-actions">
|
||||
<Button type="primary" htmlType="submit" loading={createMut.isPending}>등록</Button>
|
||||
<Button onClick={() => setOpen(false)}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={detail?.title || '사용문의'}
|
||||
open={!!detail}
|
||||
onCancel={() => setDetail(null)}
|
||||
footer={<Button onClick={() => setDetail(null)}>닫기</Button>}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="cs-notice-meta">
|
||||
<span>작성자 {detail?.authorName || '-'}</span>
|
||||
<span>등록일 {detail?.createdDate || '-'}</span>
|
||||
<span>{detail?.answer || detail?.status || '-'}</span>
|
||||
</div>
|
||||
<div className="cs-inquiry-section">
|
||||
<Typography.Text strong>문의내용</Typography.Text>
|
||||
<div className="cs-notice-body" style={{ whiteSpace: 'pre-wrap' }}>{detail?.content || '-'}</div>
|
||||
</div>
|
||||
<div className="cs-inquiry-section">
|
||||
<Typography.Text strong>답변</Typography.Text>
|
||||
<div className="cs-notice-body" style={{ whiteSpace: 'pre-wrap' }}>{detail?.reply || '답변 대기 중입니다.'}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
QuestionCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type NoticeRow = {
|
||||
id: number
|
||||
no?: number
|
||||
title?: string
|
||||
views?: number
|
||||
createdDate?: string
|
||||
authorName?: string
|
||||
contents?: string
|
||||
}
|
||||
|
||||
type NoticeResult = {
|
||||
total: number
|
||||
list: NoticeRow[]
|
||||
}
|
||||
|
||||
const initial = { page: 0, size: 10, searchType: '제목', sort: 'id,desc' } as Record<string, unknown>
|
||||
|
||||
export default function CsNoticePage() {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
const [detail, setDetail] = useState<NoticeRow | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-notices', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<NoticeResult>>(`${apiPrefix()}/homes/notices`, {
|
||||
params: {
|
||||
...filters,
|
||||
q: filters.keyword || undefined } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const openDetail = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.get<ApiResponse<NoticeRow>>(`${apiPrefix()}/homes/notices/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (row) => {
|
||||
setDetail(row)
|
||||
qc.invalidateQueries({ queryKey: ['homes-notices'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
setFilters({
|
||||
...initial,
|
||||
searchType: values.searchType || '제목',
|
||||
keyword: values.keyword || undefined,
|
||||
page: 0,
|
||||
size: Number(values.size ?? filters.size ?? 10),
|
||||
sort: filters.sort })
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ searchType: '제목', size: 10 })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '번호',
|
||||
dataIndex: 'no',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '제목',
|
||||
dataIndex: 'title',
|
||||
ellipsis: true,
|
||||
render: (v: string, row: NoticeRow) => (
|
||||
<Button type="link" className="cs-notice-title" onClick={() => openDetail.mutate(row.id)}>
|
||||
{v}
|
||||
</Button>
|
||||
) },
|
||||
{
|
||||
title: '조회',
|
||||
dataIndex: 'views',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
sorter: true,
|
||||
render: (v?: number) => (v == null ? 0 : Number(v).toLocaleString()) },
|
||||
{
|
||||
title: '등록일',
|
||||
dataIndex: 'createdDate',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="stock-page cs-notice-page homes-cs-notice-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<QuestionCircleOutlined className="product-title-icon" />
|
||||
공지사항
|
||||
</h2>
|
||||
<p>프로그램 사용에 대한 공지사항이나 업데이트를 알려드립니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="farebox-filter"
|
||||
initialValues={{ searchType: '제목', size: 10 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="farebox-filter-row">
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-110" options={['제목', '내용', '작성자'].map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-230" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
|
||||
검색
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={onReset}>
|
||||
초기화
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="size" className="farebox-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((prev) => ({ ...prev, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="farebox-table-toolbar">
|
||||
<Typography.Text>
|
||||
목록 <b>{query.data?.total ?? 0}</b>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<CareTable
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
rowKey="id"
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
bordered
|
||||
pagination={{
|
||||
current: Number(filters.page ?? 0) + 1,
|
||||
pageSize: Number(filters.size ?? 10),
|
||||
total: query.data?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
onChange={(_p, _f, sorter) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter
|
||||
if (!s?.field || !s.order) return
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort: `${String(s.field)},${s.order === 'ascend' ? 'asc' : 'desc'}` }))
|
||||
}}
|
||||
locale={{ emptyText: '등록된 공지사항이 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="공지사항"
|
||||
open={Boolean(detail)}
|
||||
onCancel={() => setDetail(null)}
|
||||
footer={<Button onClick={() => setDetail(null)}>닫기</Button>}
|
||||
width={640}
|
||||
destroyOnHidden
|
||||
centered
|
||||
>
|
||||
{detail ? (
|
||||
<div>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
{detail.title}
|
||||
</Typography.Title>
|
||||
<div className="cs-notice-meta">
|
||||
<span>조회 {detail.views ?? 0}</span>
|
||||
<span>등록일 {detail.createdDate || '-'}</span>
|
||||
</div>
|
||||
<div className="cs-notice-body">
|
||||
{(detail.contents || '')
|
||||
.split('\n')
|
||||
.map((line, idx) => (
|
||||
<p key={idx}>{line || '\u00A0'}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
EditOutlined,
|
||||
PlusSquareOutlined,
|
||||
QuestionCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type QuestionRow = {
|
||||
id: number
|
||||
no?: number
|
||||
title?: string
|
||||
answer?: string
|
||||
status?: string
|
||||
authorName?: string
|
||||
createdDate?: string
|
||||
content?: string
|
||||
reply?: string
|
||||
}
|
||||
|
||||
type QuestionResult = {
|
||||
total: number
|
||||
list: QuestionRow[]
|
||||
}
|
||||
|
||||
const initial = { page: 0, size: 10, searchType: '제목', sort: 'id,desc' } as Record<string, unknown>
|
||||
|
||||
export default function CsQuestionPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [detail, setDetail] = useState<QuestionRow | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-questions', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<QuestionResult>>(`${apiPrefix()}/homes/questions`, {
|
||||
params: {
|
||||
...filters,
|
||||
q: filters.keyword || undefined } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-questions'] })
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/questions`, {
|
||||
title: values.title,
|
||||
content: values.content,
|
||||
status: '접수' })
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('사용문의를 등록했습니다.')
|
||||
setOpen(false)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const openDetail = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.get<ApiResponse<QuestionRow>>(`${apiPrefix()}/homes/questions/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (row) => setDetail(row),
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
setFilters({
|
||||
...initial,
|
||||
searchType: values.searchType || '제목',
|
||||
keyword: values.keyword || undefined,
|
||||
page: 0,
|
||||
size: Number(values.size ?? filters.size ?? 10),
|
||||
sort: filters.sort })
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ searchType: '제목', size: 10 })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '번호',
|
||||
dataIndex: 'no',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '제목',
|
||||
dataIndex: 'title',
|
||||
ellipsis: true,
|
||||
render: (v: string, row: QuestionRow) => (
|
||||
<Button type="link" className="cs-notice-title" onClick={() => openDetail.mutate(row.id)}>
|
||||
{v}
|
||||
</Button>
|
||||
) },
|
||||
{
|
||||
title: '답변',
|
||||
dataIndex: 'answer',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
sorter: true,
|
||||
render: (v?: string) => v || '' },
|
||||
{
|
||||
title: '작성자',
|
||||
dataIndex: 'authorName',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '등록일',
|
||||
dataIndex: 'createdDate',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="stock-page cs-notice-page homes-cs-question-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<QuestionCircleOutlined className="product-title-icon" />
|
||||
사용문의
|
||||
</h2>
|
||||
<p>궁금하신 사항이나 사용상 문제가 있으시면 언제든지 내용을 남겨주세요. 담당자 확인 후 답변드리겠습니다.</p>
|
||||
</div>
|
||||
<Button
|
||||
className="cs-question-register-btn"
|
||||
type="primary"
|
||||
icon={<PlusSquareOutlined />}
|
||||
onClick={() => {
|
||||
editForm.resetFields()
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
사용문의 등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="farebox-filter"
|
||||
initialValues={{ searchType: '제목', size: 10 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="farebox-filter-row">
|
||||
<Form.Item name="searchType">
|
||||
<Select
|
||||
className="w-110"
|
||||
options={['제목', '작성자', '내용', '답변'].map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-230" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
|
||||
검색
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={onReset}>
|
||||
초기화
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="size" className="farebox-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((prev) => ({ ...prev, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="farebox-table-toolbar">
|
||||
<Typography.Text>
|
||||
목록 <b>{query.data?.total ?? 0}</b>
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<CareTable
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
rowKey="id"
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
bordered
|
||||
pagination={{
|
||||
current: Number(filters.page ?? 0) + 1,
|
||||
pageSize: Number(filters.size ?? 10),
|
||||
total: query.data?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
onChange={(_p, _f, sorter) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter
|
||||
if (!s?.field || !s.order) return
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort: `${String(s.field)},${s.order === 'ascend' ? 'asc' : 'desc'}`,
|
||||
page: 0 }))
|
||||
}}
|
||||
locale={{ emptyText: '등록되어 있는 사용문의가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="사용문의 등록"
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
footer={null}
|
||||
width={560}
|
||||
destroyOnHidden
|
||||
centered
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => createMut.mutate(values)}
|
||||
>
|
||||
<Form.Item name="title" label="제목" rules={[{ required: true, message: '제목을 입력하세요.' }]}>
|
||||
<Input placeholder="제목을 입력하세요" />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="내용" rules={[{ required: true, message: '내용을 입력하세요.' }]}>
|
||||
<Input.TextArea rows={8} placeholder="문의 내용을 입력하세요" />
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button
|
||||
className="cs-question-register-btn"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={createMut.isPending}
|
||||
icon={<EditOutlined />}
|
||||
>
|
||||
등록
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="사용문의"
|
||||
open={Boolean(detail)}
|
||||
onCancel={() => setDetail(null)}
|
||||
footer={<Button onClick={() => setDetail(null)}>닫기</Button>}
|
||||
width={640}
|
||||
destroyOnHidden
|
||||
centered
|
||||
>
|
||||
{detail ? (
|
||||
<div>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
{detail.title}
|
||||
</Typography.Title>
|
||||
<div className="cs-notice-meta">
|
||||
<span>작성자 {detail.authorName || '-'}</span>
|
||||
<span>등록일 {detail.createdDate || '-'}</span>
|
||||
<span>{detail.answer || '미답변'}</span>
|
||||
</div>
|
||||
<div className="cs-inquiry-section">
|
||||
<Typography.Text strong>문의내용</Typography.Text>
|
||||
<div className="cs-notice-body">
|
||||
{(detail.content || '')
|
||||
.split('\n')
|
||||
.map((line, idx) => (
|
||||
<p key={idx}>{line || '\u00A0'}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{detail.reply ? (
|
||||
<div className="cs-inquiry-section">
|
||||
<Typography.Text strong>답변</Typography.Text>
|
||||
<div className="cs-notice-body">
|
||||
{detail.reply
|
||||
.split('\n')
|
||||
.map((line, idx) => (
|
||||
<p key={idx}>{line || '\u00A0'}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Button, Card, Form, Input, Select } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { FileTextOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
const options = (values: string[]) => values.map((value) => ({ value, label: value }))
|
||||
|
||||
type Customer = Record<string, unknown> & { id: number }
|
||||
type SearchResult = { total: number; list: Customer[] }
|
||||
|
||||
const initial = { page: 0, size: 15 } as Record<string, unknown>
|
||||
|
||||
export default function CustomerListPage() {
|
||||
const [form] = Form.useForm()
|
||||
const navigate = useNavigate()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['customers-list', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<SearchResult>>(`${apiPrefix()}/customers`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
setFilters({ ...initial, ...values, page: 0, size: values.size ?? filters.size ?? 15 })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ size: 15 })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '고객명', dataIndex: 'name', width: 100, sorter: true },
|
||||
{ title: '연락처', dataIndex: 'phone', width: 120 },
|
||||
{ title: '통신사', dataIndex: 'telecom', width: 70 },
|
||||
{ title: '주소', dataIndex: 'address', width: 200, ellipsis: true },
|
||||
{ title: '상태', dataIndex: 'status', width: 80 },
|
||||
{ title: '담당자', dataIndex: 'employee', width: 90 },
|
||||
{ title: '등록일', dataIndex: 'createdDate', width: 100 },
|
||||
{
|
||||
title: '관리', width: 58, fixed: 'right' as const,
|
||||
render: (_: unknown, row: Customer) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<FileTextOutlined style={{ color: '#1677ff' }} />}
|
||||
onClick={() => navigate(`/care/customer/write?id=${row.id}`)}
|
||||
/>
|
||||
) },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stock-page home-sale-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><UserOutlined style={{ marginRight: 8 }} />고객목록</h2>
|
||||
<p>홈상품·계약 고객 정보를 조회하고 관리합니다.</p>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => navigate('/care/customer/write')}>고객 등록</Button>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form form={form} layout="inline" className="home-filter" initialValues={{ size: 15 }} onFinish={onSearch}>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="status"><Select allowClear placeholder="-상태-" className="w-110" options={options(['정상', '휴면', '해지'])} /></Form.Item>
|
||||
<Form.Item name="telecom"><Select allowClear placeholder="-통신사-" className="w-110" options={options(['SKT', 'KT', 'LGU+'])} /></Form.Item>
|
||||
<Form.Item name="name"><Input placeholder="고객명" className="w-130" allowClear /></Form.Item>
|
||||
<Form.Item name="phone"><Input placeholder="연락처" className="w-130" allowClear /></Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => { form.setFieldValue('size', size); setFilters((v) => ({ ...v, size, page: 0 })) }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `총 ${total}건`,
|
||||
onChange: (page) => setFilters((v) => ({ ...v, page: page - 1 })) }}
|
||||
locale={{ emptyText: query.isError ? '데이터를 불러오지 못했습니다.' : '등록된 고객이 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { TeamOutlined } from '@ant-design/icons'
|
||||
import { HomesLogListPage } from './HomesLogListPage'
|
||||
|
||||
const ACTIONS = [
|
||||
'고객-등록',
|
||||
'고객-수정',
|
||||
'고객-삭제',
|
||||
'고객-분류',
|
||||
]
|
||||
|
||||
export default function CustomerLogPage() {
|
||||
return (
|
||||
<HomesLogListPage
|
||||
domain="CUSTOMER"
|
||||
title="고객이력"
|
||||
description="고객관리와 관련된 이력을 확인하실 수 있습니다."
|
||||
defaultActions={ACTIONS}
|
||||
titleIcon={<TeamOutlined className="product-title-icon" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Tabs,
|
||||
Tooltip,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
CloseOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
MailOutlined,
|
||||
PlusSquareOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { options } from './homesShared'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
type CustomerRow = {
|
||||
id: number
|
||||
grade?: string
|
||||
customerType?: string
|
||||
name?: string
|
||||
phone?: string
|
||||
phoneAlt?: string
|
||||
zipcode?: string
|
||||
address?: string
|
||||
addressDetail?: string
|
||||
employee?: string
|
||||
memo?: string
|
||||
email?: string
|
||||
gender?: string
|
||||
nationality?: string
|
||||
birthYear?: string
|
||||
birthMonth?: string
|
||||
birthDay?: string
|
||||
registeredDate?: string
|
||||
contractCount?: number
|
||||
bookingCount?: number
|
||||
}
|
||||
|
||||
type CustomerResult = {
|
||||
total: number
|
||||
list: CustomerRow[]
|
||||
counts?: Record<string, number>
|
||||
grades?: string[]
|
||||
employees?: string[]
|
||||
}
|
||||
|
||||
const initial = { page: 0, size: 10, tab: 'ALL' } as Record<string, unknown>
|
||||
|
||||
const maskName = (name?: string) => {
|
||||
if (!name) return '-'
|
||||
if (name.length <= 1) return name
|
||||
if (name.length === 2) return `${name[0]}*`
|
||||
return `${name[0]}*${name.slice(2)}`
|
||||
}
|
||||
|
||||
const maskPhone = (phone?: string) => {
|
||||
if (!phone) return '-'
|
||||
const digits = phone.replace(/\D/g, '')
|
||||
if (digits.length < 7) return phone
|
||||
if (digits.length === 10) return `${digits.slice(0, 3)}-****-${digits.slice(6)}`
|
||||
return `${digits.slice(0, 3)}-****-${digits.slice(-4)}`
|
||||
}
|
||||
|
||||
const splitPhone = (phone?: string, fallbackFirst = '010') => {
|
||||
const digits = (phone || '').replace(/\D/g, '')
|
||||
if (digits.length >= 10) {
|
||||
return {
|
||||
p1: digits.slice(0, 3),
|
||||
p2: digits.slice(3, digits.length - 4),
|
||||
p3: digits.slice(-4) }
|
||||
}
|
||||
return { p1: fallbackFirst, p2: '', p3: '' }
|
||||
}
|
||||
|
||||
const fullAddress = (row: CustomerRow) => {
|
||||
const parts = [row.zipcode ? `[${row.zipcode}]` : '', row.address, row.addressDetail].filter(Boolean)
|
||||
return parts.join(' ') || ''
|
||||
}
|
||||
|
||||
export default function CustomerPage() {
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [gradeForm] = Form.useForm()
|
||||
const [changeGradeForm] = Form.useForm()
|
||||
const qc = useQueryClient()
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>(initial)
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [editing, setEditing] = useState<CustomerRow | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [gradeOpen, setGradeOpen] = useState(false)
|
||||
const [changeGradeOpen, setChangeGradeOpen] = useState(false)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-customers', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<CustomerResult>>(`${apiPrefix()}/homes/customers`, {
|
||||
params: {
|
||||
...filters,
|
||||
grade: filters.tab === 'ALL' ? undefined : filters.tab } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const members = useQuery({
|
||||
queryKey: ['homes/members-options'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ list: { id: number; name?: string }[] }>>(`${apiPrefix()}/homes/members`, {
|
||||
params: { page: 0, size: 200 } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ['homes-customers'] })
|
||||
setSelected([])
|
||||
}
|
||||
|
||||
const staffOptions = useMemo(() => {
|
||||
const fromApi = query.data?.employees || []
|
||||
const fromMembers = (members.data?.list || []).map((m) => String(m.name || '')).filter(Boolean)
|
||||
return Array.from(new Set([...fromApi, ...fromMembers, '홍길동'])).map((v) => ({ value: v, label: v }))
|
||||
}, [query.data, members.data])
|
||||
|
||||
const gradeList = useMemo(
|
||||
() => query.data?.grades || ['단골', '단골1', '단골2', '단골3', '단골4'],
|
||||
[query.data?.grades],
|
||||
)
|
||||
|
||||
const gradeOptions = useMemo(
|
||||
() => [{ value: '분류없음', label: '분류없음' }, ...gradeList.map((g) => ({ value: g, label: g }))],
|
||||
[gradeList],
|
||||
)
|
||||
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{ key: 'ALL', label: `전체 ${query.data?.counts?.ALL ?? query.data?.total ?? 0}` },
|
||||
...gradeList.map((g) => ({ key: g, label: `${g} ${query.data?.counts?.[g] ?? 0}` })),
|
||||
],
|
||||
[query.data, gradeList],
|
||||
)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
employee: values.employee,
|
||||
customerType: values.customerType || '개인',
|
||||
name: values.name,
|
||||
grade: values.grade || '분류없음',
|
||||
phone1: values.phone1,
|
||||
phone2: values.phone2,
|
||||
phone3: values.phone3,
|
||||
altPhone1: values.altPhone1,
|
||||
altPhone2: values.altPhone2,
|
||||
altPhone3: values.altPhone3,
|
||||
birthYear: values.birthYear,
|
||||
birthMonth: values.birthMonth,
|
||||
birthDay: values.birthDay,
|
||||
gender: values.gender,
|
||||
nationality: values.nationality,
|
||||
email: values.email,
|
||||
zipcode: values.zipcode,
|
||||
address: values.address,
|
||||
addressDetail: values.addressDetail,
|
||||
memo: values.memo,
|
||||
registeredDate: values.registeredDate
|
||||
? dayjs(values.registeredDate as Dayjs).format('YYYY-MM-DD')
|
||||
: dayjs().format('YYYY-MM-DD') }
|
||||
if (editing) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/customers/${editing.id}`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/customers`, payload)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '수정했습니다.' : '고객을 등록했습니다.')
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/customers/delete`, { ids })
|
||||
return unwrap(data as ApiResponse<{ deleted: number }>)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
message.success(`${data.deleted}건을 삭제했습니다.`)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const changeGradeMut = useMutation({
|
||||
mutationFn: async (payload: { ids: number[]; grade: string }) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/customers/grade`, payload)
|
||||
return unwrap(data as ApiResponse<{ updated: number }>)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
message.success(`${data.updated}건의 분류를 변경했습니다.`)
|
||||
setChangeGradeOpen(false)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const saveGrades = useMutation({
|
||||
mutationFn: async (grades: string[]) => {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/customers/grades`, { grades })
|
||||
return unwrap(data as ApiResponse<{ grades: string[] }>)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('분류를 저장했습니다.')
|
||||
setGradeOpen(false)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
const searchType = String(values.searchType || '연락처(뒷4자리)')
|
||||
const keyword = String(values.keyword || '').trim()
|
||||
setFilters({
|
||||
...initial,
|
||||
employee: values.employee || undefined,
|
||||
customerType: values.customerType || undefined,
|
||||
history: values.history || undefined,
|
||||
searchType,
|
||||
phoneTail: searchType.includes('뒷4') && keyword ? keyword : undefined,
|
||||
q: searchType.includes('뒷4') ? undefined : keyword || undefined,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 10,
|
||||
tab: filters.tab })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ size: 10, searchType: '연락처(뒷4자리)' })
|
||||
setFilters(initial)
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setCreating(true)
|
||||
editForm.setFieldsValue({
|
||||
employee: staffOptions[0]?.value || '홍길동',
|
||||
customerType: '개인',
|
||||
name: undefined,
|
||||
grade: '분류없음',
|
||||
phone1: '010',
|
||||
phone2: '',
|
||||
phone3: '',
|
||||
altPhone1: '010',
|
||||
altPhone2: '',
|
||||
altPhone3: '',
|
||||
birthYear: undefined,
|
||||
birthMonth: undefined,
|
||||
birthDay: undefined,
|
||||
gender: undefined,
|
||||
nationality: undefined,
|
||||
email: undefined,
|
||||
zipcode: undefined,
|
||||
address: undefined,
|
||||
addressDetail: undefined,
|
||||
memo: undefined,
|
||||
registeredDate: dayjs() })
|
||||
}
|
||||
|
||||
const openEdit = (row: CustomerRow) => {
|
||||
setCreating(false)
|
||||
setEditing(row)
|
||||
const phone = splitPhone(row.phone)
|
||||
const alt = splitPhone(row.phoneAlt, '010')
|
||||
editForm.setFieldsValue({
|
||||
employee: row.employee,
|
||||
customerType: row.customerType || '개인',
|
||||
name: row.name,
|
||||
grade: row.grade || '분류없음',
|
||||
phone1: phone.p1,
|
||||
phone2: phone.p2,
|
||||
phone3: phone.p3,
|
||||
altPhone1: alt.p1,
|
||||
altPhone2: alt.p2,
|
||||
altPhone3: alt.p3,
|
||||
birthYear: row.birthYear,
|
||||
birthMonth: row.birthMonth,
|
||||
birthDay: row.birthDay,
|
||||
gender: row.gender,
|
||||
nationality: row.nationality,
|
||||
email: row.email,
|
||||
zipcode: row.zipcode,
|
||||
address: row.address,
|
||||
addressDetail: row.addressDetail,
|
||||
memo: row.memo,
|
||||
registeredDate: row.registeredDate ? dayjs(row.registeredDate) : dayjs() })
|
||||
}
|
||||
|
||||
const searchAddress = () => {
|
||||
message.info('주소검색(데모) — 직접 입력해 주세요.')
|
||||
editForm.setFieldsValue({
|
||||
zipcode: editForm.getFieldValue('zipcode') || '06236',
|
||||
address: editForm.getFieldValue('address') || '서울특별시 강남구' })
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/homes/customers/export`, {
|
||||
params: { ...filters, grade: filters.tab === 'ALL' ? undefined : filters.tab },
|
||||
responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '고객관리.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const openGradeSettings = () => {
|
||||
gradeForm.setFieldsValue({ gradesText: gradeList.join('\n') })
|
||||
setGradeOpen(true)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: (
|
||||
<Checkbox
|
||||
checked={!!query.data?.list?.length && selected.length === query.data.list.length}
|
||||
indeterminate={selected.length > 0 && selected.length < (query.data?.list?.length || 0)}
|
||||
onChange={(e) => setSelected(e.target.checked ? (query.data?.list || []).map((r) => r.id) : [])}
|
||||
/>
|
||||
),
|
||||
width: 42,
|
||||
render: (_: unknown, row: CustomerRow) => (
|
||||
<Checkbox
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={(e) => setSelected((prev) => (e.target.checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)))}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '등록일',
|
||||
dataIndex: 'registeredDate',
|
||||
width: 110,
|
||||
sorter: (a: CustomerRow, b: CustomerRow) => String(a.registeredDate || '').localeCompare(String(b.registeredDate || '')) },
|
||||
{
|
||||
title: '분류',
|
||||
dataIndex: 'grade',
|
||||
width: 80,
|
||||
render: (v: string) => (v && v !== '분류없음' ? v : '-') },
|
||||
{ title: '구분', dataIndex: 'customerType', width: 70 },
|
||||
{
|
||||
title: '고객명',
|
||||
dataIndex: 'name',
|
||||
width: 90,
|
||||
render: (v: string) => maskName(v) },
|
||||
{
|
||||
title: '연락처',
|
||||
dataIndex: 'phone',
|
||||
width: 130,
|
||||
render: (v: string) => maskPhone(v) },
|
||||
{
|
||||
title: '보조연락처',
|
||||
dataIndex: 'phoneAlt',
|
||||
width: 130,
|
||||
render: (v: string) => (v ? maskPhone(v) : '-') },
|
||||
{
|
||||
title: '주소',
|
||||
dataIndex: 'address',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (_: string, row: CustomerRow) => {
|
||||
const addr = fullAddress(row)
|
||||
return addr ? (
|
||||
<Tooltip title={addr}>
|
||||
<MailOutlined className="memo-icon" />
|
||||
</Tooltip>
|
||||
) : '-'
|
||||
} },
|
||||
{
|
||||
title: '메모',
|
||||
dataIndex: 'memo',
|
||||
width: 60,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => (v ? (
|
||||
<Tooltip title={v}>
|
||||
<MailOutlined className="memo-icon" />
|
||||
</Tooltip>
|
||||
) : '-') },
|
||||
{
|
||||
title: '계약',
|
||||
dataIndex: 'contractCount',
|
||||
width: 60,
|
||||
align: 'center' as const,
|
||||
render: (v: number) => (v ? v : '-') },
|
||||
{
|
||||
title: '예약',
|
||||
dataIndex: 'bookingCount',
|
||||
width: 60,
|
||||
align: 'center' as const,
|
||||
render: (v: number) => (v ? v : '-') },
|
||||
{ title: '등록직원', dataIndex: 'employee', width: 90 },
|
||||
{
|
||||
title: '관리',
|
||||
width: 64,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: CustomerRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="customer-manage-btn"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(row)}
|
||||
/>
|
||||
) },
|
||||
]
|
||||
|
||||
const modalOpen = creating || Boolean(editing)
|
||||
|
||||
return (
|
||||
<div className="stock-page customer-manage-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<TeamOutlined className="product-title-icon" />
|
||||
고객관리
|
||||
</h2>
|
||||
<p>등록되어 있는 고객의 정보를 관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('분류를 변경할 고객을 선택하세요.')
|
||||
return
|
||||
}
|
||||
changeGradeForm.setFieldsValue({ grade: '단골' })
|
||||
setChangeGradeOpen(true)
|
||||
}}
|
||||
>
|
||||
분류변경
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
loading={deleteMut.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('삭제할 고객을 선택하세요.')
|
||||
return
|
||||
}
|
||||
deleteMut.mutate(selected)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button className="customer-register-btn" type="primary" icon={<PlusSquareOutlined />} onClick={openCreate}>
|
||||
고객 등록
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="balance-filter"
|
||||
initialValues={{ size: 10, searchType: '연락처(뒷4자리)' }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={() => form.setFieldValue('dates', undefined)} />
|
||||
</div>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="employee">
|
||||
<Select allowClear showSearch placeholder="등록직원" className="w-120" options={staffOptions} optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="customerType">
|
||||
<Select allowClear placeholder="구분" className="w-110" options={options(['개인', '사업자'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="history">
|
||||
<Select allowClear placeholder="이력" className="w-110" options={options(['계약', '예약', '없음'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="searchType">
|
||||
<Select
|
||||
className="w-140"
|
||||
options={options(['연락처(뒷4자리)', '고객명', '연락처', '주소'])}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input placeholder="검색어" className="w-140" allowClear />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((v) => ({ ...v, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="stock-table-actions">
|
||||
<Tabs
|
||||
activeKey={String(filters.tab)}
|
||||
onChange={(tab) => setFilters((v) => ({ ...v, tab, page: 0 }))}
|
||||
items={tabs}
|
||||
/>
|
||||
<Space>
|
||||
<Button type="link" icon={<SettingOutlined />} onClick={openGradeSettings}>분류설정</Button>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: Number(filters.page) + 1,
|
||||
pageSize: Number(filters.size),
|
||||
total: query.data?.total,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((v) => ({ ...v, page: page - 1 })) }}
|
||||
locale={{ emptyText: '등록된 자료가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '고객 수정' : '고객 등록'}
|
||||
open={modalOpen}
|
||||
onCancel={() => { setCreating(false); setEditing(null) }}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={620}
|
||||
className="customer-modal"
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '110px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
onFinish={(values) => save.mutate(values)}
|
||||
className="balance-edit-form"
|
||||
>
|
||||
<Form.Item name="employee" label="등록직원" rules={[{ required: true, message: '등록직원을 선택하세요' }]}>
|
||||
<Select options={staffOptions} showSearch optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item name="customerType" label="고객구분" rules={[{ required: true, message: '고객구분을 선택하세요' }]}>
|
||||
<Radio.Group options={[{ value: '개인', label: '개인' }, { value: '사업자', label: '사업자' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="고객명" rules={[{ required: true, message: '고객명을 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="분류">
|
||||
<Space.Compact className="full-width">
|
||||
<Form.Item name="grade" noStyle>
|
||||
<Select options={gradeOptions} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Button icon={<SettingOutlined />} onClick={openGradeSettings}>설정</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="연락처" required>
|
||||
<Space.Compact>
|
||||
<Form.Item name="phone1" noStyle rules={[{ required: true, message: '연락처를 입력하세요' }]}>
|
||||
<Input style={{ width: 70 }} maxLength={3} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone2" noStyle rules={[{ required: true, message: '연락처를 입력하세요' }]}>
|
||||
<Input style={{ width: 80 }} maxLength={4} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone3" noStyle rules={[{ required: true, message: '연락처를 입력하세요' }]}>
|
||||
<Input style={{ width: 80 }} maxLength={4} />
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="보조연락처">
|
||||
<Space.Compact>
|
||||
<Form.Item name="altPhone1" noStyle><Input style={{ width: 70 }} maxLength={3} /></Form.Item>
|
||||
<Form.Item name="altPhone2" noStyle><Input style={{ width: 80 }} maxLength={4} /></Form.Item>
|
||||
<Form.Item name="altPhone3" noStyle><Input style={{ width: 80 }} maxLength={4} /></Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="생년월일">
|
||||
<Space.Compact>
|
||||
<Form.Item name="birthYear" noStyle><Input style={{ width: 80 }} placeholder="생년" maxLength={4} /></Form.Item>
|
||||
<Form.Item name="birthMonth" noStyle><Input style={{ width: 70 }} placeholder="월" maxLength={2} /></Form.Item>
|
||||
<Form.Item name="birthDay" noStyle><Input style={{ width: 70 }} placeholder="일" maxLength={2} /></Form.Item>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label="성별/국적">
|
||||
<Space>
|
||||
<Form.Item name="gender" noStyle>
|
||||
<Select allowClear placeholder="- 성별 -" className="w-120" options={options(['남', '여'])} />
|
||||
</Form.Item>
|
||||
<Form.Item name="nationality" noStyle>
|
||||
<Select allowClear placeholder="- 국적 -" className="w-120" options={options(['내국인', '외국인'])} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label="이메일">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="주소">
|
||||
<div className="booking-address-row">
|
||||
<Button onClick={searchAddress}>주소검색</Button>
|
||||
<Form.Item name="zipcode" noStyle>
|
||||
<Input className="booking-zip" placeholder="우편번호" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="address" noStyle>
|
||||
<Input className="booking-addr-detail" placeholder="기본주소" style={{ marginBottom: 8 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="addressDetail" noStyle>
|
||||
<Input className="booking-addr-detail" placeholder="상세주소" />
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="메모">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="customer-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="분류설정"
|
||||
open={gradeOpen}
|
||||
onCancel={() => setGradeOpen(false)}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={420}
|
||||
>
|
||||
<Form
|
||||
form={gradeForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => {
|
||||
const grades = String(values.gradesText || '')
|
||||
.split(/\n|,/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
saveGrades.mutate(grades)
|
||||
}}
|
||||
>
|
||||
<Form.Item name="gradesText" label="분류 (줄바꿈으로 구분)" rules={[{ required: true, message: '분류를 입력하세요' }]}>
|
||||
<Input.TextArea rows={6} placeholder={'단골\n단골1\n단골2\n단골3\n단골4'} />
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="customer-submit-btn" type="primary" htmlType="submit" loading={saveGrades.isPending}>저장</Button>
|
||||
<Button onClick={() => setGradeOpen(false)}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="분류변경"
|
||||
open={changeGradeOpen}
|
||||
onCancel={() => setChangeGradeOpen(false)}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
width={400}
|
||||
>
|
||||
<Form
|
||||
form={changeGradeForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => changeGradeMut.mutate({ ids: selected, grade: String(values.grade) })}
|
||||
>
|
||||
<Form.Item name="grade" label="변경할 분류" rules={[{ required: true, message: '분류를 선택하세요' }]}>
|
||||
<Select options={gradeOptions} />
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="customer-submit-btn" type="primary" htmlType="submit" loading={changeGradeMut.isPending}>변경</Button>
|
||||
<Button onClick={() => setChangeGradeOpen(false)}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Button, Card, Col, Form, Input, Row, Select, message } from 'antd'
|
||||
import { UnorderedListOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
const options = (values: string[]) => values.map((value) => ({ value, label: value }))
|
||||
|
||||
export default function CustomerWritePage() {
|
||||
const navigate = useNavigate()
|
||||
const [search] = useSearchParams()
|
||||
const editId = search.get('id') ? Number(search.get('id')) : null
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: ['customer-detail', editId],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>>>(`${apiPrefix()}/customers/${editId}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
enabled: Boolean(editId) })
|
||||
|
||||
useEffect(() => {
|
||||
if (detail.data) form.setFieldsValue(detail.data)
|
||||
}, [detail.data, form])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
if (editId) {
|
||||
const { data } = await client.put(`${apiPrefix()}/customers/${editId}`, values)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/customers`, values)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editId ? '고객을 수정했습니다.' : '고객을 등록했습니다.')
|
||||
navigate('/care/customer/list')
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
return (
|
||||
<div className="home-write-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><UserOutlined style={{ marginRight: 8 }} />고객 {editId ? '수정' : '등록'}</h2>
|
||||
<p>고객 기본정보를 입력해주세요. ('필수' 표시는 필수항목입니다.)</p>
|
||||
</div>
|
||||
<Button icon={<UnorderedListOutlined />} onClick={() => navigate('/care/customer/list')}>
|
||||
목록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
labelCol={{ flex: '100px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
initialValues={{ status: '정상', telecom: 'KT' }}
|
||||
onFinish={(v) => save.mutate(v)}
|
||||
>
|
||||
<Card size="small" title="고객정보" style={{ marginBottom: 12 }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="name" label="고객명" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="phone" label="연락처" rules={[{ required: true }]}>
|
||||
<Input placeholder="010-0000-0000" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="telecom" label="통신사">
|
||||
<Select options={options(['SKT', 'KT', 'LGU+'])} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="status" label="상태">
|
||||
<Select options={options(['정상', '휴면', '해지'])} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="employee" label="담당자">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="email" label="이메일">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="address" label="주소">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="memo" label="메모">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>저장</Button>
|
||||
<Button onClick={() => navigate('/care/customer/list')}>목록</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
EditOutlined, FileTextOutlined, PlusOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type DeliveryRow = {
|
||||
id: number
|
||||
status?: string
|
||||
active?: boolean
|
||||
name?: string
|
||||
phone?: string
|
||||
memo?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
type DeliveryData = {
|
||||
total: number
|
||||
list: DeliveryRow[]
|
||||
}
|
||||
|
||||
export default function DeliveryPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<DeliveryRow | null>(null)
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({
|
||||
searchType: '업체명',
|
||||
page: 0,
|
||||
size: 15,
|
||||
sort: 'name,asc' })
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['deliveries', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<DeliveryData>>(`${apiPrefix()}/deliveries`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => {
|
||||
setSelected([])
|
||||
qc.invalidateQueries({ queryKey: ['deliveries'] })
|
||||
}
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
if (editing?.id) {
|
||||
const { data } = await client.put(`${apiPrefix()}/deliveries/${editing.id}`, values)
|
||||
return unwrap(data)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/deliveries`, values)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '배송처를 수정했습니다.' : '배송처를 등록했습니다.')
|
||||
setOpen(false)
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const statusMut = useMutation({
|
||||
mutationFn: async (status: '사용' | '미사용') => {
|
||||
const { data } = await client.post(`${apiPrefix()}/deliveries/status`, { ids: selected, status })
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (_d, status) => {
|
||||
message.success(`${status} 처리했습니다.`)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
searchType: values.searchType || '업체명',
|
||||
keyword: values.keyword,
|
||||
page: 0,
|
||||
size: Number(values.size ?? prev.size ?? 15) }))
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.setFieldsValue({ searchType: '업체명', keyword: undefined, size: 15 })
|
||||
setFilters({ searchType: '업체명', page: 0, size: 15, sort: 'name,asc' })
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
editForm.setFieldsValue({ name: undefined, phone: undefined, memo: undefined, status: '사용' })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (row: DeliveryRow) => {
|
||||
setEditing(row)
|
||||
editForm.setFieldsValue({
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
memo: row.memo,
|
||||
status: row.status || '사용' })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
const { data } = await client.get(`${apiPrefix()}/deliveries/export`, {
|
||||
params: filters,
|
||||
responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '배송처관리.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '상태',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => <span className={v === '미사용' ? 'farebox-cancelled' : ''}>{v}</span> },
|
||||
{ title: '업체명', dataIndex: 'name', sorter: true },
|
||||
{ title: '연락처', dataIndex: 'phone', width: 160, align: 'center' as const },
|
||||
{ title: '비고', dataIndex: 'memo', width: 200, ellipsis: true },
|
||||
{ title: '등록일', dataIndex: 'createdAt', width: 120, align: 'center' as const, sorter: true },
|
||||
{
|
||||
title: '관리',
|
||||
key: 'manage',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: DeliveryRow) => (
|
||||
<Button type="primary" size="small" className="staff-manage-btn" icon={<FileTextOutlined />} onClick={() => openEdit(row)} />
|
||||
) },
|
||||
], [])
|
||||
|
||||
return (
|
||||
<div className="stock-page delivery-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><SettingOutlined style={{ marginRight: 8 }} />배송처관리</h2>
|
||||
<p>거래하고 있는 배송처를 등록 혹은 관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap>
|
||||
<Button
|
||||
className="farebox-gray-btn"
|
||||
disabled={!selected.length}
|
||||
loading={statusMut.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) { message.warning('항목을 선택하세요.'); return }
|
||||
statusMut.mutate('사용')
|
||||
}}
|
||||
>
|
||||
사용
|
||||
</Button>
|
||||
<Button
|
||||
className="farebox-gray-btn"
|
||||
disabled={!selected.length}
|
||||
loading={statusMut.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) { message.warning('항목을 선택하세요.'); return }
|
||||
statusMut.mutate('미사용')
|
||||
}}
|
||||
>
|
||||
미사용
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>배송처 등록</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="farebox-filter"
|
||||
initialValues={{ searchType: '업체명', size: 15 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="farebox-filter-row">
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-120" options={['업체명', '연락처', '비고'].map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-180" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button onClick={onReset}>초기화</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="size" className="farebox-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => setFilters((prev) => ({ ...prev, size, page: 0 }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="farebox-table-toolbar">
|
||||
<Typography.Text>목록 {query.data?.total ?? 0}</Typography.Text>
|
||||
<Button type="link" icon={<FileTextOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
rowKey="id"
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
bordered
|
||||
rowSelection={{
|
||||
selectedRowKeys: selected,
|
||||
onChange: (keys) => setSelected(keys.map(Number)) }}
|
||||
pagination={{
|
||||
current: Number(filters.page ?? 0) + 1,
|
||||
pageSize: Number(filters.size ?? 15),
|
||||
total: query.data?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
onChange={(_p, _f, sorter) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter
|
||||
if (!s?.field) return
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort: `${String(s.field)},${s.order === 'ascend' ? 'asc' : 'desc'}` }))
|
||||
}}
|
||||
locale={{ emptyText: '배송처가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '배송처 수정' : '배송처 등록'}
|
||||
open={open}
|
||||
onCancel={() => { setOpen(false); setEditing(null) }}
|
||||
footer={null}
|
||||
width={460}
|
||||
destroyOnHidden
|
||||
centered
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
labelCol={{ flex: '88px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
labelAlign="left"
|
||||
colon={false}
|
||||
onFinish={(values) => saveMut.mutate(values)}
|
||||
>
|
||||
<Form.Item name="name" label="업체명" rules={[{ required: true, message: '업체명을 입력하세요.' }]}>
|
||||
<Input placeholder="업체명을 입력하세요" />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="연락처">
|
||||
<Input placeholder="1588-0000" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="상태">
|
||||
<Select options={['사용', '미사용'].map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="비고">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<EditOutlined />}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { Table, Button, Card, Empty } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { BarChartOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { money } from './homesShared'
|
||||
|
||||
type StatRow = {
|
||||
key?: number
|
||||
label?: string
|
||||
internetCount?: number
|
||||
homeCount?: number
|
||||
count?: number
|
||||
settleAmount?: number
|
||||
margin?: number
|
||||
policySum?: number
|
||||
}
|
||||
|
||||
type GroupStatData = {
|
||||
year?: number
|
||||
month?: number
|
||||
title?: string
|
||||
rows?: StatRow[]
|
||||
totals?: StatRow
|
||||
}
|
||||
|
||||
const num = (v?: number) => Number(v ?? 0)
|
||||
const cell = (v?: number) => (num(v) === 0 ? '' : money(v))
|
||||
const countCell = (v?: number) => (num(v) === 0 ? '' : String(v))
|
||||
|
||||
type Props = {
|
||||
apiKind: 'member' | 'agency'
|
||||
title: string
|
||||
description: string
|
||||
labelTitle: string
|
||||
}
|
||||
|
||||
export function GroupStatReportPage({ apiKind, title, description, labelTitle }: Props) {
|
||||
const [cursor, setCursor] = useState<Dayjs>(dayjs())
|
||||
|
||||
const params = useMemo(() => ({
|
||||
year: cursor.year(),
|
||||
month: cursor.month() + 1 }), [cursor])
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [`homes-report-${apiKind}`, params],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<GroupStatData>>(`${apiPrefix()}/homes/reports/${apiKind}`, {
|
||||
params })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const rows = query.data?.rows || []
|
||||
const totals = query.data?.totals
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: labelTitle,
|
||||
dataIndex: 'label',
|
||||
width: 140 },
|
||||
{
|
||||
title: '인터넷접수',
|
||||
dataIndex: 'internetCount',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: countCell },
|
||||
{
|
||||
title: '홈렌탈접수',
|
||||
dataIndex: 'homeCount',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: countCell },
|
||||
{
|
||||
title: '계약합계',
|
||||
dataIndex: 'count',
|
||||
width: 100,
|
||||
align: 'right' as const,
|
||||
render: countCell },
|
||||
{
|
||||
title: '정산합계',
|
||||
dataIndex: 'settleAmount',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: cell },
|
||||
{
|
||||
title: '마진합계',
|
||||
dataIndex: 'margin',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: cell },
|
||||
], [labelTitle])
|
||||
|
||||
return (
|
||||
<div className="stock-page kind-stat-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<BarChartOutlined className="product-title-icon kind-stat-icon" />
|
||||
{title}
|
||||
</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="kind-stat-period-card" size="small">
|
||||
<div className="kind-stat-period-nav">
|
||||
<Button type="text" icon={<LeftOutlined />} onClick={() => setCursor((d) => d.subtract(1, 'month'))} />
|
||||
<span className="kind-stat-period-label">{cursor.format('YYYY년 M월')}</span>
|
||||
<Button type="text" icon={<RightOutlined />} onClick={() => setCursor((d) => d.add(1, 'month'))} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card kind-stat-table-card" size="small" loading={query.isLoading}>
|
||||
{rows.length ? (
|
||||
<CareTable
|
||||
rowKey={(r) => String(r.key ?? r.label)}
|
||||
size="small"
|
||||
dataSource={rows}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
scroll={{ x: 700 }}
|
||||
summary={() => (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0}><b>합계</b></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1} align="right"><b>{countCell(totals?.internetCount)}</b></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={2} align="right"><b>{countCell(totals?.homeCount)}</b></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={3} align="right"><b>{countCell(totals?.count)}</b></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={4} align="right"><b>{cell(totals?.settleAmount)}</b></Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={5} align="right"><b>{cell(totals?.margin)}</b></Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty className="kind-stat-empty" image={Empty.PRESENTED_IMAGE_SIMPLE} description="자료가 없습니다." />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Area, Column } from '@ant-design/plots'
|
||||
import { Card, Form, Input, Modal, Spin, Tabs, message } from 'antd'
|
||||
import {
|
||||
BookOutlined,
|
||||
CalendarOutlined,
|
||||
CustomerServiceOutlined,
|
||||
EditOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
ScheduleOutlined,
|
||||
TeamOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { homeAccentColors } from '../../theme'
|
||||
import { useTheme } from '../../themeContext'
|
||||
import { money } from './homesShared'
|
||||
|
||||
type SparkBlock = {
|
||||
open?: number
|
||||
today?: number
|
||||
total?: number
|
||||
monthNew?: number
|
||||
monthAmount?: number
|
||||
settleMonth?: number
|
||||
spark?: number[]
|
||||
}
|
||||
|
||||
type ChartPanel = {
|
||||
title?: string
|
||||
label?: string
|
||||
offset?: number
|
||||
apply?: number
|
||||
accept?: number
|
||||
done?: number
|
||||
cancel?: number
|
||||
settleAmount?: number
|
||||
count?: number
|
||||
daily?: { day: number; value: number }[]
|
||||
}
|
||||
|
||||
type StatusBlock = {
|
||||
total?: number
|
||||
items?: Record<string, number>
|
||||
}
|
||||
|
||||
type Postit = {
|
||||
slot: number
|
||||
title?: string
|
||||
contents?: string
|
||||
authorName?: string
|
||||
noteDate?: string
|
||||
}
|
||||
|
||||
type Dash = {
|
||||
pending?: SparkBlock
|
||||
booking?: SparkBlock
|
||||
customer?: SparkBlock
|
||||
abook?: SparkBlock
|
||||
contractTotal?: number
|
||||
customerTotal?: number
|
||||
postits?: Postit[]
|
||||
internet?: ChartPanel
|
||||
home?: ChartPanel
|
||||
internetStatus?: StatusBlock
|
||||
homeStatus?: StatusBlock
|
||||
notices?: { id: number; title?: string; createdDate?: string }[]
|
||||
questions?: { id: number; title?: string; createdDate?: string }[]
|
||||
csPhone?: string
|
||||
}
|
||||
|
||||
function Wave({ color }: { color: string }) {
|
||||
return (
|
||||
<svg className="home-wave" viewBox="0 0 200 42" preserveAspectRatio="none" aria-hidden>
|
||||
<path
|
||||
d="M0 28 C 30 8, 50 38, 80 22 S 130 4, 160 24 S 190 36, 200 18 L200 42 L0 42 Z"
|
||||
fill={color}
|
||||
opacity="0.35"
|
||||
/>
|
||||
<path
|
||||
d="M0 32 C 40 18, 70 36, 100 26 S 150 12, 180 28 S 195 34, 200 26 L200 42 L0 42 Z"
|
||||
fill={color}
|
||||
opacity="0.55"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniBars({ values, color }: { values: number[]; color: string }) {
|
||||
const max = Math.max(1, ...values)
|
||||
return (
|
||||
<div className="home-mini-bars" aria-hidden>
|
||||
{values.map((v, i) => (
|
||||
<span key={i} style={{ height: `${Math.max(12, (v / max) * 100)}%`, background: color }} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HomeDashboardPage() {
|
||||
const { theme } = useTheme()
|
||||
const accents = useMemo(() => homeAccentColors(theme), [theme])
|
||||
const qc = useQueryClient()
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [period, setPeriod] = useState('day')
|
||||
const [editSlot, setEditSlot] = useState<Postit | null>(null)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-dashboard', offset, period],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Dash>>(`${apiPrefix()}/homes/dashboard`, {
|
||||
params: { offset, period } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const savePostit = useMutation({
|
||||
mutationFn: async (values: { contents: string }) => {
|
||||
if (!editSlot) return
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/dashboard/postits/${editSlot.slot}`, {
|
||||
contents: values.contents,
|
||||
authorName: localStorage.getItem('userName') || '홍길동' })
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('저장했습니다.')
|
||||
setEditSlot(null)
|
||||
qc.invalidateQueries({ queryKey: ['homes-dashboard'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="home-loading">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const d = query.data || {}
|
||||
const pending = d.pending || {}
|
||||
const booking = d.booking || {}
|
||||
const customer = d.customer || {}
|
||||
const abook = d.abook || {}
|
||||
const internet = d.internet || {}
|
||||
const home = d.home || {}
|
||||
const internetStatus = d.internetStatus || {}
|
||||
const homeStatus = d.homeStatus || {}
|
||||
|
||||
const columnCfg = (daily: { day: number; value: number }[] | undefined, fill: string) => ({
|
||||
data: daily || [],
|
||||
xField: 'day',
|
||||
yField: 'value',
|
||||
height: 180,
|
||||
autoFit: true,
|
||||
color: fill,
|
||||
columnStyle: { radius: [3, 3, 0, 0] },
|
||||
xAxis: { label: { style: { fill: accents.plot.axisLabelFill, fontSize: 10 } }, line: null, tickLine: null },
|
||||
yAxis: {
|
||||
label: { style: { fill: accents.plot.axisLabelFill, fontSize: 10 } },
|
||||
grid: { line: { style: { stroke: accents.plot.gridStroke } } } },
|
||||
tooltip: { title: (v: number) => `${v}일` } })
|
||||
|
||||
const areaCfg = (daily: { day: number; value: number }[] | undefined, stroke: string, fill: string) => ({
|
||||
data: daily || [],
|
||||
xField: 'day',
|
||||
yField: 'value',
|
||||
height: 180,
|
||||
autoFit: true,
|
||||
smooth: true,
|
||||
color: stroke,
|
||||
areaStyle: { fill },
|
||||
line: { color: stroke, size: 2 },
|
||||
xAxis: { label: { style: { fill: accents.plot.axisLabelFill, fontSize: 10 } }, line: null, tickLine: null },
|
||||
yAxis: {
|
||||
label: { style: { fill: accents.plot.axisLabelFill, fontSize: 10 } },
|
||||
grid: { line: { style: { stroke: accents.plot.gridStroke } } } } })
|
||||
|
||||
return (
|
||||
<div className="home-dashboard">
|
||||
<div className="home-top-row">
|
||||
<Card className="home-card" size="small">
|
||||
<div className="home-stock-split">
|
||||
<Link to="/care/pending/pending" className="home-stock-block">
|
||||
<div className="home-stock-head">
|
||||
<div>
|
||||
<div className="home-card-title">
|
||||
<CalendarOutlined /> 일정관리
|
||||
</div>
|
||||
<div className="home-stock-total">
|
||||
{pending.open ?? 0} <span className="home-stock-hint">/ {pending.total ?? 0}</span>
|
||||
</div>
|
||||
<div className="home-stock-hint">오늘 약속 {pending.today ?? 0}건</div>
|
||||
</div>
|
||||
<MiniBars values={pending.spark || [2, 3, 4, 3, 5, 4]} color={accents.sparkPhone} />
|
||||
</div>
|
||||
<div className="home-stock-meta">
|
||||
<span>
|
||||
미결 <b>{pending.open ?? 0}</b>
|
||||
</span>
|
||||
<span>
|
||||
오늘 <b>{pending.today ?? 0}</b>
|
||||
</span>
|
||||
</div>
|
||||
<Wave color={accents.sparkPhone} />
|
||||
</Link>
|
||||
<Link to="/care/booking/booking" className="home-stock-block">
|
||||
<div className="home-stock-head">
|
||||
<div>
|
||||
<div className="home-card-title">
|
||||
<ScheduleOutlined /> 예약관리
|
||||
</div>
|
||||
<div className="home-stock-total">
|
||||
{booking.open ?? 0} <span className="home-stock-hint">/ {booking.total ?? 0}</span>
|
||||
</div>
|
||||
<div className="home-stock-hint">접수 대기 현황</div>
|
||||
</div>
|
||||
<MiniBars values={booking.spark || [1, 2, 2, 3, 2, 1]} color={accents.sparkUsim} />
|
||||
</div>
|
||||
<div className="home-stock-meta">
|
||||
<span>
|
||||
접수 <b>{booking.open ?? 0}</b>
|
||||
</span>
|
||||
<span>
|
||||
전체 <b>{booking.total ?? 0}</b>
|
||||
</span>
|
||||
</div>
|
||||
<Wave color={accents.sparkUsim} />
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="home-card" size="small">
|
||||
<div className="home-status-split">
|
||||
<Link to="/care/customer/customer" className="home-status-block">
|
||||
<div className="home-status-top">
|
||||
<div>
|
||||
<div className="home-card-title">등록고객</div>
|
||||
<div className="home-status-value">
|
||||
{customer.total ?? d.customerTotal ?? 0}명
|
||||
</div>
|
||||
<div className="home-stock-hint">이달 신규 {customer.monthNew ?? 0}</div>
|
||||
</div>
|
||||
<TeamOutlined className="home-status-icon teal" />
|
||||
</div>
|
||||
<Wave color={accents.waveOut} />
|
||||
</Link>
|
||||
<Link to="/care/abooks/abooks-mon" className="home-status-block">
|
||||
<div className="home-status-top">
|
||||
<div>
|
||||
<div className="home-card-title">이달장부</div>
|
||||
<div className="home-status-value">{money(abook.monthAmount)}</div>
|
||||
<div className="home-stock-hint">정산 {money(abook.settleMonth)}</div>
|
||||
</div>
|
||||
<BookOutlined className="home-status-icon purple" />
|
||||
</div>
|
||||
<Wave color={accents.waveFare} />
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="home-postit-row">
|
||||
{(d.postits || []).map((p) => (
|
||||
<div key={p.slot} className="home-postit">
|
||||
<div className="home-postit-head">
|
||||
<span>{p.title || `포스트잇 #${p.slot}`}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="수정"
|
||||
onClick={() => {
|
||||
setEditSlot(p)
|
||||
form.setFieldsValue({ contents: p.contents })
|
||||
}}
|
||||
>
|
||||
<EditOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<div className="home-postit-body">{p.contents || ''}</div>
|
||||
<div className="home-postit-foot">
|
||||
<span>by {p.authorName || '-'}</span>
|
||||
<span>{p.noteDate || ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="home-chart-row">
|
||||
<Card
|
||||
className="home-card"
|
||||
size="small"
|
||||
title={
|
||||
<div className="home-chart-head">
|
||||
<span>
|
||||
<button type="button" className="home-month-nav" onClick={() => setOffset((o) => o + 1)}>
|
||||
<LeftOutlined />
|
||||
</button>
|
||||
인터넷접수 (영업기준)
|
||||
<button
|
||||
type="button"
|
||||
className="home-month-nav"
|
||||
onClick={() => setOffset((o) => Math.max(0, o - 1))}
|
||||
>
|
||||
<RightOutlined />
|
||||
</button>
|
||||
</span>
|
||||
<Tabs
|
||||
size="small"
|
||||
activeKey={period}
|
||||
onChange={setPeriod}
|
||||
items={[
|
||||
{ key: 'day', label: '매일' },
|
||||
{ key: 'month', label: '매달' },
|
||||
{ key: 'total', label: '전체' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="home-chart-sub">
|
||||
<span>{internet.label}</span>
|
||||
<span className="muted">계약 {internet.count ?? 0}건</span>
|
||||
</div>
|
||||
<div className="home-chart-stats four">
|
||||
<div>
|
||||
<em>신청</em>
|
||||
<b>{internet.apply ?? 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>접수</em>
|
||||
<b>{internet.accept ?? 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>완료</em>
|
||||
<b>{internet.done ?? 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>정산금액</em>
|
||||
<b>{money(internet.settleAmount)}</b>
|
||||
</div>
|
||||
</div>
|
||||
<div className="home-chart-plot">
|
||||
<Column {...columnCfg(internet.daily, accents.columnFill)} />
|
||||
</div>
|
||||
<div className="home-cat-block">
|
||||
<div className="home-cat-total">인터넷현황 {internetStatus.total ?? 0}</div>
|
||||
<div className="home-cat-grid">
|
||||
{Object.entries(internetStatus.items || {}).map(([k, v]) => (
|
||||
<div key={k} className="home-cat-item">
|
||||
<MiniBars values={[2, 3, 4, 3, Number(v) || 1, 2]} color={accents.sparkPhone} />
|
||||
<span>{k}</span>
|
||||
<b>{v}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="home-card"
|
||||
size="small"
|
||||
title={
|
||||
<div className="home-chart-head">
|
||||
<span>홈렌탈접수 (영업기준)</span>
|
||||
<Tabs
|
||||
size="small"
|
||||
activeKey={period}
|
||||
onChange={setPeriod}
|
||||
items={[
|
||||
{ key: 'day', label: '매일' },
|
||||
{ key: 'month', label: '매달' },
|
||||
{ key: 'total', label: '전체' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="home-chart-sub">
|
||||
<span>{home.label}</span>
|
||||
<span className="muted">계약 {home.count ?? 0}건</span>
|
||||
</div>
|
||||
<div className="home-chart-stats four">
|
||||
<div>
|
||||
<em>신청</em>
|
||||
<b>{home.apply ?? 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>접수</em>
|
||||
<b>{home.accept ?? 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>완료</em>
|
||||
<b>{home.done ?? 0}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>정산금액</em>
|
||||
<b>{money(home.settleAmount)}</b>
|
||||
</div>
|
||||
</div>
|
||||
<div className="home-chart-plot">
|
||||
<Area
|
||||
{...areaCfg(
|
||||
home.daily,
|
||||
accents.columnFill,
|
||||
theme === 'light' ? 'rgba(37,99,235,0.22)' : 'rgba(122,162,247,0.28)',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="home-cat-block">
|
||||
<div className="home-cat-total">홈렌탈현황 {homeStatus.total ?? 0}</div>
|
||||
<div className="home-cat-grid">
|
||||
{Object.entries(homeStatus.items || {}).map(([k, v]) => (
|
||||
<div key={k} className="home-cat-item">
|
||||
<MiniBars values={[1, 2, 3, Number(v) || 1, 2, 3]} color={accents.areaStroke} />
|
||||
<span>{k}</span>
|
||||
<b>{v}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="home-bottom-row">
|
||||
<Card
|
||||
className="home-card home-list-card"
|
||||
size="small"
|
||||
title={
|
||||
<Link to="/care/cs/notice">
|
||||
공지사항
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<ul className="home-list notice">
|
||||
{(d.notices || []).length === 0 ? (
|
||||
<li className="empty">등록된 공지가 없습니다.</li>
|
||||
) : (
|
||||
(d.notices || []).map((n) => (
|
||||
<li key={n.id}>
|
||||
<Link to="/care/cs/notice">{n.title}</Link>
|
||||
<span>{n.createdDate}</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</Card>
|
||||
<Card
|
||||
className="home-card home-list-card"
|
||||
size="small"
|
||||
title={<Link to="/care/cs/question">사용문의</Link>}
|
||||
>
|
||||
<ul className="home-list notice">
|
||||
{(d.questions || []).length === 0 ? (
|
||||
<li className="empty">등록된 문의가 없습니다.</li>
|
||||
) : (
|
||||
(d.questions || []).map((q) => (
|
||||
<li key={q.id}>
|
||||
<Link to="/care/cs/question">{q.title}</Link>
|
||||
<span>{q.createdDate}</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</Card>
|
||||
<Card
|
||||
className="home-card home-cs-card"
|
||||
size="small"
|
||||
title={
|
||||
<span>
|
||||
<CustomerServiceOutlined /> 고객센터
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="home-cs-phone">{d.csPhone || '0000-0000'}</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={editSlot ? `포스트잇 #${editSlot.slot}` : '포스트잇'}
|
||||
open={Boolean(editSlot)}
|
||||
onCancel={() => setEditSlot(null)}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={savePostit.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => savePostit.mutate(v)}>
|
||||
<Form.Item name="contents" label="내용" rules={[{ required: true, message: '내용을 입력하세요' }]}>
|
||||
<Input.TextArea rows={4} maxLength={200} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
Button, Card, Checkbox, DatePicker, Form, Select, Space, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
CloseOutlined, DownloadOutlined, EditOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import type { Row } from './homesShared'
|
||||
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
type LogPageResult = {
|
||||
total: number
|
||||
list: Row[]
|
||||
actions?: string[]
|
||||
staffs?: string[]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
domain: string
|
||||
title: string
|
||||
description: string
|
||||
defaultActions?: string[]
|
||||
titleIcon?: ReactNode
|
||||
}
|
||||
|
||||
export function HomesLogListPage({ domain, title, description, defaultActions = [], titleIcon }: Props) {
|
||||
const [form] = Form.useForm()
|
||||
const qc = useQueryClient()
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({
|
||||
domain,
|
||||
page: 0,
|
||||
size: 10 })
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes/logs', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<LogPageResult>>(`${apiPrefix()}/homes/logs`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/logs/delete`, { ids })
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
setSelected([])
|
||||
qc.invalidateQueries({ queryKey: ['homes/logs'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] })
|
||||
const clearDates = () => form.setFieldValue('dates', undefined)
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
const dates = values.dates as [Dayjs, Dayjs] | undefined
|
||||
setSelected([])
|
||||
setFilters({
|
||||
domain,
|
||||
action: values.action || undefined,
|
||||
staff: values.staff || undefined,
|
||||
dateFrom: dates?.[0]?.format('YYYY-MM-DD'),
|
||||
dateTo: dates?.[1]?.format('YYYY-MM-DD'),
|
||||
page: 0,
|
||||
size: values.size ?? filters.size ?? 10 })
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ size: 10 })
|
||||
setSelected([])
|
||||
setFilters({ domain, page: 0, size: 10 })
|
||||
}
|
||||
|
||||
const download = () => {
|
||||
const rows = query.data?.list || []
|
||||
if (!rows.length) {
|
||||
message.warning('다운로드할 목록이 없습니다.')
|
||||
return
|
||||
}
|
||||
const header = ['구분', '내용', '직원', '처리일']
|
||||
const lines = rows.map((r) =>
|
||||
[r.action, r.content, r.staff, r.processedAt]
|
||||
.map((v) => `"${String(v ?? '').replace(/"/g, '""')}"`)
|
||||
.join(','),
|
||||
)
|
||||
const blob = new Blob(['\uFEFF' + [header.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${title}_${dayjs().format('YYYYMMDD')}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const actionOptions = useMemo(() => {
|
||||
const fromApi = query.data?.actions || []
|
||||
return Array.from(new Set([...defaultActions, ...fromApi])).map((v) => ({ value: v, label: v }))
|
||||
}, [query.data?.actions, defaultActions])
|
||||
|
||||
const staffOptions = useMemo(
|
||||
() => (query.data?.staffs || []).map((v) => ({ value: v, label: v })),
|
||||
[query.data?.staffs],
|
||||
)
|
||||
|
||||
const list = query.data?.list || []
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: (
|
||||
<Checkbox
|
||||
checked={list.length > 0 && selected.length === list.length}
|
||||
indeterminate={selected.length > 0 && selected.length < list.length}
|
||||
onChange={(e) => setSelected(e.target.checked ? list.map((r) => r.id) : [])}
|
||||
/>
|
||||
),
|
||||
width: 48,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<Checkbox
|
||||
checked={selected.includes(row.id)}
|
||||
onChange={(e) => {
|
||||
setSelected((prev) => (e.target.checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)))
|
||||
}}
|
||||
/>
|
||||
) },
|
||||
{ title: '구분', dataIndex: 'action', width: 180 },
|
||||
{ title: '내용', dataIndex: 'content', ellipsis: true },
|
||||
{ title: '직원', dataIndex: 'staff', width: 100 },
|
||||
{ title: '처리일', dataIndex: 'processedAt', width: 160 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={`stock-page product-log-page${domain === 'PENDING' ? ' pending-log-page' : ''}`}>
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
{titleIcon ?? <EditOutlined className="product-title-icon" />}
|
||||
{title}
|
||||
</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap>
|
||||
<Button
|
||||
disabled={!selected.length}
|
||||
loading={remove.isPending}
|
||||
onClick={() => {
|
||||
if (!selected.length) {
|
||||
message.warning('삭제할 이력을 선택하세요.')
|
||||
return
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '이력 삭제',
|
||||
content: `선택한 ${selected.length}건의 이력을 삭제하시겠습니까?`,
|
||||
onOk: () => remove.mutateAsync(selected) })
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form form={form} layout="inline" className="home-filter" initialValues={{ size: 10 }} onFinish={onSearch}>
|
||||
<div className="stock-filter-row">
|
||||
<Form.Item name="action">
|
||||
<Select allowClear placeholder="구분" className="w-170" options={actionOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="staff">
|
||||
<Select allowClear placeholder="직원" className="w-130" options={staffOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="dates">
|
||||
<RangePicker className="w-230" placeholder={['YYYY-MM-DD', 'YYYY-MM-DD']} />
|
||||
</Form.Item>
|
||||
<Space.Compact className="sheet-presets">
|
||||
<Button onClick={() => setPreset(dayjs(), dayjs())}>오늘</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(6, 'day'), dayjs())}>1주일</Button>
|
||||
<Button onClick={() => setPreset(dayjs().startOf('month'), dayjs().endOf('month'))}>당월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(1, 'month').startOf('month'), dayjs().subtract(1, 'month').endOf('month'))}>전월</Button>
|
||||
<Button onClick={() => setPreset(dayjs().subtract(2, 'month').startOf('month'), dayjs().subtract(2, 'month').endOf('month'))}>전전월</Button>
|
||||
</Space.Compact>
|
||||
<Button className="sheet-clear" icon={<CloseOutlined />} onClick={clearDates} />
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>초기화</Button>
|
||||
<Form.Item name="size" className="sheet-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[10, 15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => {
|
||||
form.setFieldValue('size', size)
|
||||
setFilters((prev) => ({ ...prev, size, page: 0 }))
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="recovery-table-head">
|
||||
<Typography.Text>목록 <b>{query.data?.total ?? 0}</b></Typography.Text>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={list}
|
||||
columns={columns}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: Number(filters.page || 0) + 1,
|
||||
pageSize: Number(filters.size || 10),
|
||||
total: query.data?.total || 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => {
|
||||
setSelected([])
|
||||
setFilters((prev) => ({ ...prev, page: page - 1 }))
|
||||
} }}
|
||||
locale={{ emptyText: '등록되어 있는 자료가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
EditOutlined, FileTextOutlined, PlusOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type InCompanyRow = {
|
||||
id: number
|
||||
status?: string
|
||||
active?: boolean
|
||||
name?: string
|
||||
telecom?: string
|
||||
phone?: string
|
||||
fax?: string
|
||||
managerName?: string
|
||||
mobile?: string
|
||||
businessName?: string
|
||||
businessNumber?: string
|
||||
createdAt?: string
|
||||
memo?: string
|
||||
}
|
||||
|
||||
type InCompanyData = {
|
||||
total: number
|
||||
list: InCompanyRow[]
|
||||
}
|
||||
|
||||
const telecomOptions = ['SKT', 'KT', 'LGU+', 'LGT', 'SK텔링크', 'KT엠모바일', 'LG헬로비전 (KT)', 'mKT']
|
||||
|
||||
export default function InCompanyPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<InCompanyRow | null>(null)
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({
|
||||
searchType: '업체명',
|
||||
page: 0,
|
||||
size: 15,
|
||||
sort: 'name,asc' })
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['incompanies', filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<InCompanyData>>(`${apiPrefix()}/incompanies`, { params: filters })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => {
|
||||
setSelected([])
|
||||
qc.invalidateQueries({ queryKey: ['incompanies'] })
|
||||
}
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
if (editing?.id) {
|
||||
const { data } = await client.put(`${apiPrefix()}/incompanies/${editing.id}`, values)
|
||||
return unwrap(data)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/incompanies`, values)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '입고처를 수정했습니다.' : '입고처를 등록했습니다.')
|
||||
setOpen(false)
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const statusMut = useMutation({
|
||||
mutationFn: async (status: '사용' | '미사용') => {
|
||||
const { data } = await client.post(`${apiPrefix()}/incompanies/status`, { ids: selected, status })
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (_d, status) => {
|
||||
message.success(`${status} 처리했습니다.`)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
searchType: values.searchType || '업체명',
|
||||
keyword: values.keyword,
|
||||
page: 0,
|
||||
size: Number(values.size ?? prev.size ?? 15) }))
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.setFieldsValue({ searchType: '업체명', keyword: undefined, size: 15 })
|
||||
setFilters({ searchType: '업체명', page: 0, size: 15, sort: 'name,asc' })
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
editForm.setFieldsValue({
|
||||
name: undefined,
|
||||
telecom: undefined,
|
||||
phone: undefined,
|
||||
fax: undefined,
|
||||
managerName: undefined,
|
||||
mobile: undefined,
|
||||
businessName: undefined,
|
||||
businessNumber: undefined,
|
||||
memo: undefined,
|
||||
status: '사용' })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (row: InCompanyRow) => {
|
||||
setEditing(row)
|
||||
editForm.setFieldsValue({
|
||||
name: row.name,
|
||||
telecom: row.telecom,
|
||||
phone: row.phone,
|
||||
fax: row.fax,
|
||||
managerName: row.managerName,
|
||||
mobile: row.mobile,
|
||||
businessName: row.businessName,
|
||||
businessNumber: row.businessNumber,
|
||||
memo: row.memo,
|
||||
status: row.status || '사용' })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
const { data } = await client.get(`${apiPrefix()}/incompanies/export`, {
|
||||
params: filters,
|
||||
responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '입고처관리.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const requireSelected = (status: '사용' | '미사용') => {
|
||||
if (!selected.length) {
|
||||
message.warning('항목을 선택하세요.')
|
||||
return
|
||||
}
|
||||
statusMut.mutate(status)
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '상태',
|
||||
dataIndex: 'status',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => <span className={v === '미사용' ? 'farebox-cancelled' : ''}>{v}</span> },
|
||||
{ title: '업체명', dataIndex: 'name', width: 140, ellipsis: true, sorter: true },
|
||||
{ title: '통신사', dataIndex: 'telecom', width: 130, ellipsis: true, sorter: true },
|
||||
{ title: '전화', dataIndex: 'phone', width: 120, align: 'center' as const },
|
||||
{ title: '팩스', dataIndex: 'fax', width: 120, align: 'center' as const },
|
||||
{ title: '담당자', dataIndex: 'managerName', width: 90, align: 'center' as const },
|
||||
{ title: '휴대폰', dataIndex: 'mobile', width: 120, align: 'center' as const },
|
||||
{ title: '상호', dataIndex: 'businessName', width: 110, ellipsis: true },
|
||||
{ title: '사업자번호', dataIndex: 'businessNumber', width: 120, align: 'center' as const },
|
||||
{ title: '등록일', dataIndex: 'createdAt', width: 110, align: 'center' as const, sorter: true },
|
||||
{ title: '비고', dataIndex: 'memo', width: 120, ellipsis: true },
|
||||
{
|
||||
title: '관리',
|
||||
key: 'manage',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, row: InCompanyRow) => (
|
||||
<Button type="primary" size="small" className="staff-manage-btn" icon={<FileTextOutlined />} onClick={() => openEdit(row)} />
|
||||
) },
|
||||
], [])
|
||||
|
||||
return (
|
||||
<div className="stock-page incompany-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><SettingOutlined style={{ marginRight: 8 }} />입고처관리</h2>
|
||||
<p>거래하고 있는 입고처를 등록 혹은 관리하실 수 있습니다.</p>
|
||||
</div>
|
||||
<Space className="stock-title-actions" wrap>
|
||||
<Button
|
||||
className="farebox-gray-btn"
|
||||
disabled={!selected.length}
|
||||
loading={statusMut.isPending}
|
||||
onClick={() => requireSelected('사용')}
|
||||
>
|
||||
사용
|
||||
</Button>
|
||||
<Button
|
||||
className="farebox-gray-btn"
|
||||
disabled={!selected.length}
|
||||
loading={statusMut.isPending}
|
||||
onClick={() => requireSelected('미사용')}
|
||||
>
|
||||
미사용
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>입고처 등록</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="farebox-filter"
|
||||
initialValues={{ searchType: '업체명', size: 15 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="farebox-filter-row">
|
||||
<Form.Item name="searchType">
|
||||
<Select
|
||||
className="w-120"
|
||||
options={['업체명', '통신사', '담당자', '상호', '사업자번호'].map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-180" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button onClick={onReset}>초기화</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="size" className="farebox-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => setFilters((prev) => ({ ...prev, size, page: 0 }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="farebox-table-toolbar">
|
||||
<Typography.Text>목록 {query.data?.total ?? 0}</Typography.Text>
|
||||
<Button type="link" icon={<FileTextOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
<CareTable
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
rowKey="id"
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
bordered
|
||||
rowSelection={{
|
||||
selectedRowKeys: selected,
|
||||
onChange: (keys) => setSelected(keys.map(Number)) }}
|
||||
pagination={{
|
||||
current: Number(filters.page ?? 0) + 1,
|
||||
pageSize: Number(filters.size ?? 15),
|
||||
total: query.data?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
onChange={(_p, _f, sorter) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter
|
||||
if (!s?.field) return
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort: `${String(s.field)},${s.order === 'ascend' ? 'asc' : 'desc'}` }))
|
||||
}}
|
||||
scroll={{ x: 1400 }}
|
||||
locale={{ emptyText: '입고처가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '입고처 수정' : '입고처 등록'}
|
||||
open={open}
|
||||
onCancel={() => { setOpen(false); setEditing(null) }}
|
||||
footer={null}
|
||||
width={560}
|
||||
destroyOnHidden
|
||||
centered
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
labelCol={{ flex: '100px' }}
|
||||
wrapperCol={{ flex: 1 }}
|
||||
labelAlign="left"
|
||||
colon={false}
|
||||
onFinish={(values) => saveMut.mutate(values)}
|
||||
>
|
||||
<Form.Item name="name" label="업체명" rules={[{ required: true, message: '업체명을 입력하세요.' }]}>
|
||||
<Input placeholder="업체명을 입력하세요" />
|
||||
</Form.Item>
|
||||
<Form.Item name="telecom" label="통신사">
|
||||
<Select allowClear placeholder="-통신사-" options={telecomOptions.map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="전화">
|
||||
<Input placeholder="02-0000-0000" />
|
||||
</Form.Item>
|
||||
<Form.Item name="fax" label="팩스">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="managerName" label="담당자">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="mobile" label="휴대폰">
|
||||
<Input placeholder="010-0000-0000" />
|
||||
</Form.Item>
|
||||
<Form.Item name="businessName" label="상호">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="businessNumber" label="사업자번호">
|
||||
<Input placeholder="000-00-00000" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="상태">
|
||||
<Select options={['사용', '미사용'].map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="비고">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<EditOutlined />}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Modal from '../../components/DraggableModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Typography,
|
||||
message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { EditOutlined, SearchOutlined, UnorderedListOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type BoardRow = {
|
||||
id: number
|
||||
no?: number
|
||||
title?: string
|
||||
authorName?: string
|
||||
views?: number
|
||||
commentCount?: number
|
||||
createdDate?: string
|
||||
contents?: string
|
||||
}
|
||||
|
||||
type BoardData = {
|
||||
total: number
|
||||
list: BoardRow[]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
code: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export default function InternalBoardPage({ code, title }: Props) {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<BoardRow | null>(null)
|
||||
const [detail, setDetail] = useState<BoardRow | null>(null)
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({
|
||||
code,
|
||||
searchType: '제목',
|
||||
page: 0,
|
||||
size: 15,
|
||||
sort: 'createdAt,desc' })
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['internal-board', code, filters],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<BoardData>>(`${apiPrefix()}/bbs/search`, {
|
||||
params: { ...filters, code } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['internal-board', code] })
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const body = { ...values, code }
|
||||
if (editing?.id) {
|
||||
const { data } = await client.put(`${apiPrefix()}/bbs/${editing.id}`, body)
|
||||
return unwrap(data)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/bbs`, body)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '게시물을 수정했습니다.' : '게시물을 등록했습니다.')
|
||||
setOpen(false)
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const openDetail = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.get<ApiResponse<BoardRow>>(`${apiPrefix()}/bbs/${id}`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: (row) => {
|
||||
setDetail(row)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message) })
|
||||
|
||||
const onSearch = (values: Record<string, unknown>) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
code,
|
||||
searchType: values.searchType || '제목',
|
||||
keyword: values.keyword,
|
||||
page: 0,
|
||||
size: Number(values.size ?? prev.size ?? 15) }))
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.setFieldsValue({ searchType: '제목', keyword: undefined, size: 15 })
|
||||
setFilters({ code, searchType: '제목', page: 0, size: 15, sort: 'createdAt,desc' })
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
editForm.resetFields()
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (row: BoardRow) => {
|
||||
setEditing(row)
|
||||
editForm.setFieldsValue({ title: row.title, contents: row.contents })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '번호',
|
||||
dataIndex: 'no',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '제목',
|
||||
dataIndex: 'title',
|
||||
ellipsis: true,
|
||||
render: (v: string, row: BoardRow) => (
|
||||
<Button type="link" className="cs-notice-title" onClick={() => openDetail.mutate(row.id)}>
|
||||
{v}
|
||||
{row.commentCount && row.commentCount > 0 ? (
|
||||
<span className="bbs-comment-badge">{row.commentCount}</span>
|
||||
) : null}
|
||||
</Button>
|
||||
) },
|
||||
{
|
||||
title: '직원',
|
||||
dataIndex: 'authorName',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '조회',
|
||||
dataIndex: 'views',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
{
|
||||
title: '등록일',
|
||||
dataIndex: 'createdDate',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
sorter: true },
|
||||
], [])
|
||||
|
||||
return (
|
||||
<div className="stock-page cs-notice-page internal-board-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2><UnorderedListOutlined style={{ marginRight: 8 }} />{title}</h2>
|
||||
</div>
|
||||
<Button type="primary" icon={<EditOutlined />} onClick={openCreate}>게시물 등록</Button>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card" size="small">
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
className="farebox-filter"
|
||||
initialValues={{ searchType: '제목', size: 15 }}
|
||||
onFinish={onSearch}
|
||||
>
|
||||
<div className="farebox-filter-row">
|
||||
<Form.Item name="searchType">
|
||||
<Select className="w-110" options={['제목', '직원', '내용'].map((v) => ({ value: v, label: v }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword">
|
||||
<Input className="w-230" allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>검색</Button>
|
||||
<Button onClick={onReset}>초기화</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="size" className="farebox-size">
|
||||
<Select
|
||||
className="w-150"
|
||||
options={[15, 30, 50, 100].map((n) => ({ value: n, label: `목록 ${n}개씩 보기` }))}
|
||||
onChange={(size) => setFilters((prev) => ({ ...prev, size, page: 0 }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<div className="farebox-table-toolbar">
|
||||
<Typography.Text>목록 {query.data?.total ?? 0}</Typography.Text>
|
||||
</div>
|
||||
<CareTable
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
rowKey="id"
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
current: Number(filters.page ?? 0) + 1,
|
||||
pageSize: Number(filters.size ?? 15),
|
||||
total: query.data?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => setFilters((prev) => ({ ...prev, page: page - 1 })) }}
|
||||
onChange={(_p, _f, sorter) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter
|
||||
if (!s?.field) return
|
||||
const fieldMap: Record<string, string> = {
|
||||
no: 'id',
|
||||
authorName: 'authorName',
|
||||
views: 'views',
|
||||
createdDate: 'createdAt',
|
||||
title: 'title' }
|
||||
const field = fieldMap[String(s.field)] || String(s.field)
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort: `${field},${s.order === 'ascend' ? 'asc' : 'desc'}`,
|
||||
page: 0 }))
|
||||
}}
|
||||
locale={{ emptyText: '등록되어 있는 게시물이 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? '게시물 수정' : '게시물 등록'}
|
||||
open={open}
|
||||
onCancel={() => { setOpen(false); setEditing(null) }}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
width={720}
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => saveMut.mutate(values)}
|
||||
>
|
||||
<Form.Item name="title" label="제목" rules={[{ required: true, message: '제목을 입력하세요.' }]}>
|
||||
<Input maxLength={200} />
|
||||
</Form.Item>
|
||||
<Form.Item name="contents" label="내용" rules={[{ required: true, message: '내용을 입력하세요.' }]}>
|
||||
<Input.TextArea rows={10} />
|
||||
</Form.Item>
|
||||
<div className="bbs-write-actions">
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={detail?.title || title}
|
||||
open={!!detail}
|
||||
onCancel={() => setDetail(null)}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={() => { if (detail) openEdit(detail); setDetail(null) }}>수정</Button>
|
||||
<Button onClick={() => setDetail(null)}>닫기</Button>
|
||||
</Space>
|
||||
}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="cs-notice-meta">
|
||||
<span>직원 {detail?.authorName || '-'}</span>
|
||||
<span>등록일 {detail?.createdDate || '-'}</span>
|
||||
<span>조회 {detail?.views ?? 0}</span>
|
||||
</div>
|
||||
<div
|
||||
className="cs-notice-body"
|
||||
style={{ whiteSpace: 'pre-wrap' }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: detail?.contents?.includes('<') ? (detail.contents || '') : (detail?.contents || '-').replace(/\n/g, '<br/>') }}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CompanyBoardPage() {
|
||||
return <InternalBoardPage code="company" title="사내게시판" />
|
||||
}
|
||||
|
||||
export function DataBoardPage() {
|
||||
return <InternalBoardPage code="grouppds" title="자료게시판" />
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { HomesLogListPage } from './HomesLogListPage'
|
||||
import { FileTextOutlined } from '@ant-design/icons'
|
||||
|
||||
const ACTIONS = [
|
||||
'정산서-발행',
|
||||
'정산서-재발행',
|
||||
'정산서-삭제',
|
||||
'정산서-등록',
|
||||
'정산서-수정',
|
||||
]
|
||||
|
||||
export default function InvoiceLogPage() {
|
||||
return (
|
||||
<HomesLogListPage
|
||||
domain="INVOICE"
|
||||
title="정산서이력"
|
||||
description="정산서업무의 변동이력을 확인하실 수 있습니다."
|
||||
defaultActions={ACTIONS}
|
||||
titleIcon={<FileTextOutlined className="product-title-icon" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { Button, Card, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import { CalculatorOutlined, FileExcelOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { money } from './homesShared'
|
||||
|
||||
type MyInvoiceRow = {
|
||||
id: number
|
||||
yearMonth?: string
|
||||
employee?: string
|
||||
internetCount?: number
|
||||
internetAmount?: number
|
||||
homeCount?: number
|
||||
homeAmount?: number
|
||||
balanceCount?: number
|
||||
balanceAmount?: number
|
||||
settleAmount?: number
|
||||
amount?: number
|
||||
status?: string
|
||||
}
|
||||
|
||||
type MyInvoiceResult = {
|
||||
total: number
|
||||
employee?: string
|
||||
list: MyInvoiceRow[]
|
||||
}
|
||||
|
||||
const num = (v: unknown) => {
|
||||
const n = Number(v ?? 0)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
const cell = (v: unknown) => (num(v) === 0 ? '' : num(v).toLocaleString())
|
||||
const moneyCell = (v: unknown) => (num(v) === 0 ? '' : money(v))
|
||||
|
||||
const formatYm = (ym?: string) => {
|
||||
if (!ym) return '-'
|
||||
const d = dayjs(`${ym}-01`)
|
||||
return d.isValid() ? d.format('YYYY년 M월') : ym
|
||||
}
|
||||
|
||||
export default function InvoiceMyPage() {
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-invoice-my'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<MyInvoiceResult>>(`${apiPrefix()}/homes/invoices/my`)
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const download = async (row: MyInvoiceRow) => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/homes/invoices/${row.id}/export`, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `나의정산서_${row.yearMonth || row.id}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '기준월',
|
||||
dataIndex: 'yearMonth',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => formatYm(v) },
|
||||
{ title: '직원', dataIndex: 'employee', width: 100, align: 'center' as const },
|
||||
{
|
||||
title: '인터넷',
|
||||
dataIndex: 'internetCount',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: cell },
|
||||
{
|
||||
title: '인터넷금액',
|
||||
dataIndex: 'internetAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: moneyCell },
|
||||
{
|
||||
title: '홈렌탈',
|
||||
dataIndex: 'homeCount',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: cell },
|
||||
{
|
||||
title: '홈렌탈금액',
|
||||
dataIndex: 'homeAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: moneyCell },
|
||||
{
|
||||
title: '후정산',
|
||||
dataIndex: 'balanceCount',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: cell },
|
||||
{
|
||||
title: '후정산금액',
|
||||
dataIndex: 'balanceAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: moneyCell },
|
||||
{
|
||||
title: '정산금액',
|
||||
dataIndex: 'settleAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: MyInvoiceRow) => moneyCell(row.settleAmount ?? row.amount) },
|
||||
{
|
||||
title: '엑셀',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: MyInvoiceRow) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
className="invoice-excel-link"
|
||||
icon={<FileExcelOutlined />}
|
||||
onClick={() => download(row)}
|
||||
/>
|
||||
) },
|
||||
{
|
||||
title: '상태',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => v || '-' },
|
||||
], [])
|
||||
|
||||
return (
|
||||
<div className="stock-page invoice-my-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<CalculatorOutlined className="product-title-icon" />
|
||||
나의정산서
|
||||
</h2>
|
||||
<p>최근 12개월 이내 발행된 정산서를 확인하실 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
scroll={{ x: 1100 }}
|
||||
locale={{ emptyText: '등록된 자료가 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { Button, Card, Typography, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import {
|
||||
CalculatorOutlined,
|
||||
DownloadOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { money } from './homesShared'
|
||||
|
||||
type PublishRow = {
|
||||
id?: number | null
|
||||
employee: string
|
||||
yearMonth: string
|
||||
internetCount?: number
|
||||
internetAmount?: number
|
||||
homeCount?: number
|
||||
homeAmount?: number
|
||||
balanceCount?: number
|
||||
balanceAmount?: number
|
||||
settleAmount?: number
|
||||
issued?: boolean
|
||||
status?: string
|
||||
consent?: string
|
||||
taxInvoice?: string
|
||||
withdrawStatus?: string
|
||||
mismatched?: boolean
|
||||
}
|
||||
|
||||
type PublishResult = {
|
||||
yearMonth: string
|
||||
total: number
|
||||
list: PublishRow[]
|
||||
}
|
||||
|
||||
const num = (v: unknown) => {
|
||||
const n = Number(v ?? 0)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
const cell = (v: unknown) => (num(v) === 0 ? '' : num(v).toLocaleString())
|
||||
const moneyCell = (v: unknown, mismatched?: boolean) => {
|
||||
if (num(v) === 0) return ''
|
||||
return <span className={mismatched ? 'invoice-mismatch' : undefined}>{money(v)}</span>
|
||||
}
|
||||
|
||||
export default function InvoicePublishPage() {
|
||||
const qc = useQueryClient()
|
||||
const [cursor, setCursor] = useState(() => dayjs().startOf('month'))
|
||||
const yearMonth = cursor.format('YYYY-MM')
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-invoice-publish', yearMonth],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<PublishResult>>(`${apiPrefix()}/homes/invoices/publish`, {
|
||||
params: { yearMonth } })
|
||||
return unwrap(data)
|
||||
} })
|
||||
|
||||
const issue = useMutation({
|
||||
mutationFn: async (row: PublishRow) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/invoices/publish/issue`, {
|
||||
yearMonth: row.yearMonth || yearMonth,
|
||||
employee: row.employee })
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('정산서를 발행했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes-invoice-publish'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message || '발행에 실패했습니다.') })
|
||||
|
||||
const download = async () => {
|
||||
try {
|
||||
const { data } = await client.get(`${apiPrefix()}/homes/invoices/publish/export`, {
|
||||
params: { yearMonth },
|
||||
responseType: 'blob' })
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `정산서발행_${yearMonth}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
message.error('엑셀 다운로드에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '직원',
|
||||
dataIndex: 'employee',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
render: (v: string, row: PublishRow) => (
|
||||
<span className={row.mismatched ? 'invoice-mismatch' : undefined}>{v}</span>
|
||||
) },
|
||||
{
|
||||
title: '인터넷',
|
||||
dataIndex: 'internetCount',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => (
|
||||
<span className={row.mismatched ? 'invoice-mismatch' : undefined}>{cell(v)}</span>
|
||||
) },
|
||||
{
|
||||
title: '인터넷금액',
|
||||
dataIndex: 'internetAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => moneyCell(v, row.mismatched) },
|
||||
{
|
||||
title: '홈렌탈',
|
||||
dataIndex: 'homeCount',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => (
|
||||
<span className={row.mismatched ? 'invoice-mismatch' : undefined}>{cell(v)}</span>
|
||||
) },
|
||||
{
|
||||
title: '홈렌탈금액',
|
||||
dataIndex: 'homeAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => moneyCell(v, row.mismatched) },
|
||||
{
|
||||
title: '후정산',
|
||||
dataIndex: 'balanceCount',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => (
|
||||
<span className={row.mismatched ? 'invoice-mismatch' : undefined}>{cell(v)}</span>
|
||||
) },
|
||||
{
|
||||
title: '후정산금액',
|
||||
dataIndex: 'balanceAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => moneyCell(v, row.mismatched) },
|
||||
{
|
||||
title: '정산금액',
|
||||
dataIndex: 'settleAmount',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
render: (v: unknown, row: PublishRow) => moneyCell(v, row.mismatched) },
|
||||
{
|
||||
title: '정산서발행',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: PublishRow) => (
|
||||
row.issued ? (
|
||||
<Button type="link" size="small" className="invoice-issued-link" onClick={() => issue.mutate(row)}>
|
||||
재발행
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
className="invoice-issue-link"
|
||||
loading={issue.isPending}
|
||||
onClick={() => issue.mutate(row)}
|
||||
>
|
||||
발행
|
||||
</Button>
|
||||
)
|
||||
) },
|
||||
{
|
||||
title: '동의',
|
||||
dataIndex: 'consent',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '계산서',
|
||||
dataIndex: 'taxInvoice',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '출금상태',
|
||||
dataIndex: 'withdrawStatus',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (v: string) => v || '-' },
|
||||
], [issue])
|
||||
|
||||
return (
|
||||
<div className="stock-page invoice-publish-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<CalculatorOutlined className="product-title-icon" />
|
||||
정산서발행
|
||||
</h2>
|
||||
<p>
|
||||
직원 및 하부점에 정산서를 발행 및 관리하실 수 있습니다. (계약 진행상태가 완료인 자료만 정산서를 발행하실 수 있습니다.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="stock-filter-card invoice-month-card" size="small">
|
||||
<div className="invoice-month-bar">
|
||||
<div className="invoice-month-nav">
|
||||
<button type="button" aria-label="이전 달" onClick={() => setCursor((c) => c.subtract(1, 'month'))}>
|
||||
<LeftOutlined />
|
||||
</button>
|
||||
<span>{cursor.format('YYYY년 M월')}</span>
|
||||
<button type="button" aria-label="다음 달" onClick={() => setCursor((c) => c.add(1, 'month'))}>
|
||||
<RightOutlined />
|
||||
</button>
|
||||
</div>
|
||||
<Button type="link" icon={<DownloadOutlined />} onClick={download}>엑셀다운로드</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<CareTable
|
||||
rowKey={(row) => `${row.employee}-${row.yearMonth}`}
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data?.list}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
scroll={{ x: 1200 }}
|
||||
locale={{ emptyText: '등록된 자료가 없습니다.' }}
|
||||
rowClassName={(row) => (row.mismatched ? 'invoice-row-mismatch' : '')}
|
||||
/>
|
||||
<div className="invoice-notes">
|
||||
<Typography.Text type="secondary">
|
||||
* 정산서발행 시 자료의 양에 따라 처리시간이 오래 걸릴 수 있습니다.
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
* 붉은색 표기는 추가/수정으로 인해 발행정산서와 일치하지 않는 내용입니다. (필요한 경우 재발행하시면 해결됩니다.)
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||