commit 03cd93e1f2bacf01ad3adb839ea253a2d21537bf Author: Macbook Date: Sun Jul 12 05:05:17 2026 +0900 BillCare 초기 소스 등록 비즈케어 홈즈 홈상품 운용관리(BillCare) 프론트/백엔드/Docker 구성을 exdev git에 등록합니다. Co-authored-by: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a79a1cd --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ec34f5 --- /dev/null +++ b/README.md @@ -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명을 분리했습니다. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..b3b41ae --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,6 @@ +target/ +.mvn/wrapper/maven-wrapper.jar +.git +.idea +*.iml +.DS_Store diff --git a/backend/.gitattributes b/backend/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/backend/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..667aaef --- /dev/null +++ b/backend/.gitignore @@ -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/ diff --git a/backend/.mvn/wrapper/maven-wrapper.properties b/backend/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..216df05 --- /dev/null +++ b/backend/.mvn/wrapper/maven-wrapper.properties @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..76f1dcc --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/mvnw b/backend/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/backend/mvnw @@ -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-,maven-mvnd--}/ +[ -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 "$@" diff --git a/backend/mvnw.cmd b/backend/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/backend/mvnw.cmd @@ -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-,maven-mvnd--}/ +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" diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..fbac453 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,142 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.0 + + + com.bizcare + agent + 0.0.1-SNAPSHOT + agent + + + + + + + + + + + + + + + + 21 + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-web + + + + com.mysql + mysql-connector-j + runtime + + + org.projectlombok + lombok + true + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + compile + + compile + + + + + org.projectlombok + lombok + + + + + + default-testCompile + test-compile + + testCompile + + + + + org.projectlombok + lombok + + + + + + + + + + diff --git a/backend/src/main/java/com/bizcare/agent/AgentApplication.java b/backend/src/main/java/com/bizcare/agent/AgentApplication.java new file mode 100644 index 0000000..ca8b947 --- /dev/null +++ b/backend/src/main/java/com/bizcare/agent/AgentApplication.java @@ -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); + } + +} diff --git a/backend/src/main/java/com/bizcare/agent/ApiControllers.java b/backend/src/main/java/com/bizcare/agent/ApiControllers.java new file mode 100644 index 0000000..01cf7bc --- /dev/null +++ b/backend/src/main/java/com/bizcare/agent/ApiControllers.java @@ -0,0 +1,4738 @@ +package com.bizcare.agent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.*; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.*; +import org.springframework.http.*; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +import jakarta.servlet.http.HttpServletRequest; +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; + +record ApiResponse(boolean result, String msg, Object data) { static ApiResponse ok(Object d){return new ApiResponse(true,"성공",d);} static ApiResponse fail(String m){return new ApiResponse(false,m,null);} } +record PageResponse(long total, List list) {} +record LoginRequest(@NotBlank(message="아이디를 입력하세요") String userid, @NotBlank(message="비밀번호를 입력하세요") String password) {} + +@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor +class AuthController { + private final EntityManager em; private final PasswordEncoder encoder; private final JwtService jwt; + @PostMapping("/login") @Transactional ApiResponse login(@Valid @RequestBody LoginRequest request) { + List users=em.createQuery("select m from Member m where m.userid=:id",Member.class).setParameter("id",request.userid()).getResultList(); + if(users.isEmpty() || !users.getFirst().isActive() || !encoder.matches(request.password(),users.getFirst().getPassword())) return ApiResponse.fail("아이디 또는 비밀번호가 올바르지 않습니다."); + Member m=users.getFirst(); + m.setLoginCount((m.getLoginCount()==null?0:m.getLoginCount())+1); + m.setLastLoginAt(LocalDateTime.now()); + Map data=new LinkedHashMap<>(); + data.put("token",jwt.create(m)); + data.put("role",m.getRole()); + data.put("userid",m.getUserid()); + data.put("name",m.getName()); + data.put("groupId",m.getGroupId()); + data.put("companyId",m.getCompanyId()); + data.put("menuFlags",m.getMenuFlags()); + String perm = m.getStaffPermission(); + if (perm == null || perm.isBlank()) { + perm = "demo".equals(m.getUserid()) ? "대표" : "사원"; + } + data.put("staffPermission", perm); + return ApiResponse.ok(data); + } + @GetMapping("/me") ApiResponse me(@AuthenticationPrincipal JwtUser u) { + List users = em.createQuery("select m from Member m where m.userid=:id", Member.class) + .setParameter("id", u.getUserid()).getResultList(); + Map data=new LinkedHashMap<>(); + data.put("userid",u.getUserid()); + data.put("role",u.getRole()); + data.put("groupId",u.getGroupId()); + data.put("companyId",u.getCompanyId()); + if (!users.isEmpty()) { + Member m = users.getFirst(); + data.put("name", m.getName()); + String perm = m.getStaffPermission(); + if (perm == null || perm.isBlank()) { + perm = "demo".equals(m.getUserid()) ? "대표" : "사원"; + } + data.put("staffPermission", perm); + data.put("menuFlags", m.getMenuFlags()); + } + return ApiResponse.ok(data); + } +} + +@Service @RequiredArgsConstructor +class CrudService { + private final EntityManager em; private final ObjectMapper mapper; + @Transactional T create(Class type, Map data, JwtUser user) { + T value=mapper.convertValue(data,type); value.setId(null); value.setGroupId(user.getGroupId()); em.persist(value); return value; + } + @Transactional T update(Class type, Long id, Map data, JwtUser user) { + T value=get(type,id,user); + try { mapper.readerForUpdating(value).readValue(mapper.writeValueAsBytes(data)); } + catch (Exception e) { throw new IllegalArgumentException("입력 형식이 올바르지 않습니다."); } + value.setId(id); value.setGroupId(user.getGroupId()); return value; + } + @Transactional void delete(Class type, Long id, JwtUser user) { em.remove(get(type,id,user)); } + T get(Class type, Long id, JwtUser user) { + T v=em.find(type,id); if(v==null || !Objects.equals(v.getGroupId(),user.getGroupId())) throw new NoSuchElementException("데이터를 찾을 수 없습니다."); return v; + } + PageResponse list(Class type, JwtUser user, Map filters, int page, int size) { + String entity=type.getSimpleName(); StringBuilder where=new StringBuilder(" where e.groupId=:group"); Map params=new HashMap<>(); params.put("group",user.getGroupId()); + Set ignore=Set.of("page","size","keyword","startDate","endDate","q"); + for(var e:filters.entrySet()) if(e.getValue()!=null && !e.getValue().isBlank() && !ignore.contains(e.getKey()) && field(type,e.getKey())) { where.append(" and e.").append(e.getKey()).append("=:").append(e.getKey()); params.put(e.getKey(), convert(e.getValue(), type,e.getKey())); } + TypedQuery q=em.createQuery("select e from "+entity+" e"+where+" order by e.id desc",type); TypedQuery c=em.createQuery("select count(e) from "+entity+" e"+where,Long.class); + params.forEach((k,v)->{q.setParameter(k,v);c.setParameter(k,v);}); long total=c.getSingleResult(); return new PageResponse(total,q.setFirstResult(Math.max(0,page)*size).setMaxResults(Math.min(Math.max(size,1),200)).getResultList()); + } + private boolean field(Class t,String name){ try { while(t!=null){for(Field f:t.getDeclaredFields())if(f.getName().equals(name))return true;t=t.getSuperclass();} return false;}catch(Exception e){return false;} } + private Object convert(String v,Class t,String f){try{Field x=null;for(Class c=t;c!=null;c=c.getSuperclass())try{x=c.getDeclaredField(f);break;}catch(NoSuchFieldException ignored){} if(x!=null&&x.getType()==Long.class)return Long.valueOf(v); if(x!=null&&x.getType()==LocalDate.class)return LocalDate.parse(v);}catch(Exception ignored){}return v;} +} + +@RestController @RequestMapping("/api/agent") @RequiredArgsConstructor +class AgentController { + private final CrudService crud; private final EntityManager em; private final PasswordEncoder encoder; + private ApiResponse list(Class c, JwtUser u, Map f,int p,int s){return ApiResponse.ok(crud.list(c,u,f,p,s));} + private ApiResponse post(Class c,Mapd,JwtUser u){return ApiResponse.ok(crud.create(c,d,u));} + private ApiResponse get(Class c,Long id,JwtUser u){return ApiResponse.ok(crud.get(c,id,u));} + private ApiResponse put(Class c,Long id,Mapd,JwtUser u){return ApiResponse.ok(crud.update(c,id,d,u));} + private ApiResponse del(Class c,Long id,JwtUser u){crud.delete(c,id,u);return ApiResponse.ok(null);} + @GetMapping("/stocks") ApiResponse stocks(@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="30")int size){return list(Stock.class,u,f,page,size);} + @PostMapping({"/stocks","/stocks/in"}) @Transactional ApiResponse stockIn(@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){ + d.putIfAbsent("state","IN"); + Stock s=crud.create(Stock.class,d,u); + if(s.getIndate()==null) s.setIndate(LocalDate.now()); + if(s.getProcessDate()==null) s.setProcessDate(LocalDate.now()); + s.setProcessor(memberName(u)); + s.setUserid(u.getUserid()); + s.setProcid(u.getUserid()); + if(s.getUid()==null||s.getUid().isBlank()) s.setUid("stk-"+UUID.randomUUID()); + writeStockHistory(s,u); + return ApiResponse.ok(stockDetail(s,u)); + } + @GetMapping("/stocks/{id}") ApiResponse stock(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){ + Stock s=em.find(Stock.class,id); + if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) return ApiResponse.fail("재고를 찾을 수 없습니다."); + return ApiResponse.ok(stockDetail(s,u)); + } + @GetMapping("/stocks/{id}/detail") ApiResponse stockDetailApi(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){ + return stock(id,u); + } + @PutMapping("/stocks/{id}") @Transactional ApiResponse stockPut(@PathVariable Long id,@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){ + Stock s=em.find(Stock.class,id); + if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) return ApiResponse.fail("재고를 찾을 수 없습니다."); + put(Stock.class,id,d,u); + s=em.find(Stock.class,id); + if(s!=null){ + if(d.get("processor")==null || String.valueOf(d.get("processor")).isBlank()) s.setProcessor(memberName(u)); + writeStockHistory(s,u); + } + return ApiResponse.ok(stockDetail(s,u)); + } + @DeleteMapping("/stocks/{id}") @Transactional ApiResponse stockDel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){ + Stock s=em.find(Stock.class,id); + if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) return ApiResponse.fail("재고를 찾을 수 없습니다."); + em.createQuery("delete from StockHistory h where h.groupId=:g and h.stockId=:id").setParameter("g",u.getGroupId()).setParameter("id",id).executeUpdate(); + em.createQuery("delete from StockNote n where n.groupId=:g and n.stockId=:id").setParameter("g",u.getGroupId()).setParameter("id",id).executeUpdate(); + em.remove(s); + return ApiResponse.ok(null); + } + @PostMapping({"/stocks/out","/stocks/move","/stocks/return"}) @Transactional ApiResponse stockState(@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){ + Long id=Long.valueOf(d.get("id").toString()); + return stockAction(id,d,u); + } + @PostMapping("/stocks/{id}/action") @Transactional ApiResponse stockActionApi(@PathVariable Long id,@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){ + return stockAction(id,d,u); + } + @PostMapping("/stocks/{id}/notes") @Transactional ApiResponse stockNoteCreate(@PathVariable Long id,@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){ + Stock s=em.find(Stock.class,id); + if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) return ApiResponse.fail("재고를 찾을 수 없습니다."); + String contents=d.get("contents")==null?"":String.valueOf(d.get("contents")).trim(); + if(contents.isBlank()) return ApiResponse.fail("메모 내용을 입력하세요."); + StockNote n=new StockNote(); + n.setGroupId(u.getGroupId()); + n.setStockId(id); + n.setContents(contents); + n.setAuthorName(d.get("authorName")==null||String.valueOf(d.get("authorName")).isBlank()?memberName(u):String.valueOf(d.get("authorName"))); + n.setNoteDate(d.get("noteDate")==null||String.valueOf(d.get("noteDate")).isBlank()?LocalDate.now():LocalDate.parse(String.valueOf(d.get("noteDate")))); + em.persist(n); + return ApiResponse.ok(stockNoteMap(n)); + } + @DeleteMapping("/stocks/{id}/notes/{noteId}") @Transactional ApiResponse stockNoteDelete(@PathVariable Long id,@PathVariable Long noteId,@AuthenticationPrincipal JwtUser u){ + StockNote n=em.find(StockNote.class,noteId); + if(n==null||!Objects.equals(n.getGroupId(),u.getGroupId())||!Objects.equals(n.getStockId(),id)) return ApiResponse.fail("메모를 찾을 수 없습니다."); + em.remove(n); + return ApiResponse.ok(null); + } + private ApiResponse stockAction(Long id, Map d, JwtUser u){ + Stock s=em.find(Stock.class,id); + if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) return ApiResponse.fail("재고를 찾을 수 없습니다."); + String state=String.valueOf(d.getOrDefault("state", d.containsKey("state")?d.get("state"):"OUT")); + if(!List.of("IN","OUT","LOSS","OPEN","SELL","RECOVERY","RETURN").contains(state)) return ApiResponse.fail("처리할 상태를 확인하세요."); + if("OUT".equals(state)){ + if(d.get("outcompanyId")!=null && !String.valueOf(d.get("outcompanyId")).isBlank()){ + s.setOutcompanyId(Long.valueOf(d.get("outcompanyId").toString())); + } + if(d.get("outCategory")!=null) s.setOutCategory(String.valueOf(d.get("outCategory"))); + if(s.getOutcompanyId()==null) return ApiResponse.fail("출고처를 선택하세요."); + s.setOutdate(LocalDate.now()); + s.setOutcode("21"); + } + if("IN".equals(state) || "RECOVERY".equals(state)){ + s.setRecalldate(LocalDate.now()); + s.setOutcode("10"); + if("IN".equals(state)) { /* 회수 후 입고 상태 */ } + } + if("LOSS".equals(state)) s.setLossdate(LocalDate.now()); + if("SELL".equals(state)) s.setSelldate(LocalDate.now()); + s.setState(state); + s.setProcessDate(LocalDate.now()); + s.setProcessor(memberName(u)); + if(d.get("memo")!=null) s.setMemo(String.valueOf(d.get("memo"))); + writeStockHistory(s,u); + return ApiResponse.ok(stockDetail(s,u)); + } + private Map stockDetail(Stock s, JwtUser u){ + Map companyMap=new HashMap<>(); + for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companyMap.put(c.getId(),c); + Map names=new HashMap<>(); + companyMap.forEach((k,v)->names.put(k,v.getName())); + Map stock=stockMap(s,names); + stock.put("pay", s.getPay()); + stock.put("telcompany", s.getTelcompany()); + String telecomLabel=s.getTelcompany(); + if(telecomLabel==null||telecomLabel.isBlank()) telecomLabel=s.getTelecom(); + else if(s.getTelecom()!=null && !telecomLabel.contains(s.getTelecom())) telecomLabel=telecomLabel+" ("+s.getTelecom()+")"; + stock.put("telecomLabel", telecomLabel); + List histories=em.createQuery("select h from StockHistory h where h.groupId=:g and h.stockId=:id order by h.processDate desc, h.id desc",StockHistory.class) + .setParameter("g",u.getGroupId()).setParameter("id",s.getId()).getResultList(); + List> historyList=histories.stream().map(h->{ + Map m=new LinkedHashMap<>(); + m.put("id", h.getId()); + m.put("processDate", h.getProcessDate()); + m.put("statusLabel", h.getStatusLabel()!=null?h.getStatusLabel():statusLabel(h.getState())); + String label=h.getCompanyLabel(); + if(label==null||label.isBlank()){ + if("OUT".equals(h.getState())||"출고".equals(h.getStatusLabel())){ + Company oc=companyMap.get(h.getOutcompanyId()); + if(oc!=null){ + label=oc.getName(); + if(oc.getCategory()!=null && !oc.getCategory().isBlank()) label=label+" ("+oc.getCategory()+")"; + else if(s.getOutCategory()!=null && !s.getOutCategory().isBlank()) label=label+" ("+s.getOutCategory()+")"; + } + } + } + // 입고 이력은 원본처럼 거래처 칸을 비움 + if("IN".equals(h.getState())||"입고".equals(h.getStatusLabel())) label=""; + m.put("companyLabel", label==null?"":label); + m.put("processor", resolveProcessorName(u.getGroupId(), h.getProcessor())); + return m; + }).toList(); + List> notes=em.createQuery("select n from StockNote n where n.groupId=:g and n.stockId=:id order by n.noteDate desc, n.id desc",StockNote.class) + .setParameter("g",u.getGroupId()).setParameter("id",s.getId()).getResultList().stream().map(this::stockNoteMap).toList(); + Map r=new LinkedHashMap<>(); + r.put("stock", stock); + r.put("histories", historyList); + r.put("notes", notes); + return r; + } + private Map stockNoteMap(StockNote n){ + Map m=new LinkedHashMap<>(); + m.put("id", n.getId()); m.put("stockId", n.getStockId()); m.put("contents", n.getContents()); + m.put("authorName", n.getAuthorName()); m.put("noteDate", n.getNoteDate()); + return m; + } + private String memberName(JwtUser u){ + List rows=em.createQuery("select m from Member m where m.groupId=:g and m.userid=:uid",Member.class) + .setParameter("g",u.getGroupId()).setParameter("uid",u.getUserid()).getResultList(); + if(!rows.isEmpty() && rows.getFirst().getName()!=null && !rows.getFirst().getName().isBlank()) return rows.getFirst().getName(); + return u.getUserid(); + } + private String resolveProcessorName(String groupId, String processor){ + if(processor==null||processor.isBlank()) return "-"; + List rows=em.createQuery("select m from Member m where m.groupId=:g and (m.userid=:p or m.name=:p)",Member.class) + .setParameter("g",groupId).setParameter("p",processor).getResultList(); + if(!rows.isEmpty() && rows.getFirst().getName()!=null && !rows.getFirst().getName().isBlank()) return rows.getFirst().getName(); + return processor; + } + @GetMapping("/stocks/barcode/{code}") ApiResponse barcode(@PathVariable String code,@AuthenticationPrincipal JwtUser u){return list(Stock.class,u,Map.of("barcode",code),0,10);} + @GetMapping("/stocks/search") ApiResponse stockSearch(@AuthenticationPrincipal JwtUser u, @RequestParam Map f, + @RequestParam(defaultValue="0") int page, @RequestParam(defaultValue="15") int size) { + return ApiResponse.ok(stockSearchData(u, f, page, size)); + } + @GetMapping("/stocks/sheet") ApiResponse stockSheet(@AuthenticationPrincipal JwtUser u, @RequestParam Map f, + @RequestParam(defaultValue="0") int page, @RequestParam(defaultValue="15") int size) { + if(blank(f.get("dateFrom")) || blank(f.get("dateTo"))) return ApiResponse.fail("날짜를 선택하세요."); + Map filters=new HashMap<>(f); + filters.put("dateBasis","처리일기준"); + filters.putIfAbsent("tab","ALL"); + return ApiResponse.ok(stockSearchData(u, filters, page, size)); + } + @GetMapping(value="/stocks/sheet/export", produces="text/csv;charset=UTF-8") ResponseEntity stockSheetExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + if(blank(f.get("dateFrom")) || blank(f.get("dateTo"))) return ResponseEntity.badRequest().body("날짜를 선택하세요."); + Map filters=new HashMap<>(f); + filters.put("dateBasis","처리일기준"); + filters.putIfAbsent("tab","ALL"); + @SuppressWarnings("unchecked") List> rows=(List>) stockSearchData(u,filters,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF처리일,현황,상태,종류,일련번호,모델명,색상,입고가,입고처,출고처,처리자,비고\n"); + for(Map r:rows) csv.append(csv(r.get("processDate"))).append(',').append(csv(r.get("statusLabel"))).append(',') + .append(csv(r.get("cond"))).append(',').append(csv(r.get("gubun"))).append(',').append(csv(r.get("serial"))).append(',') + .append(csv(r.get("model"))).append(',').append(csv(r.get("color"))).append(',').append(csv(r.get("inprice"))).append(',') + .append(csv(r.get("incompanyName"))).append(',').append(csv(r.get("outcompanyName"))).append(',') + .append(csv(r.get("processor"))).append(',').append(csv(r.get("memo"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=sheet.csv").body(csv.toString()); + } + @GetMapping(value="/stocks/export", produces="text/csv;charset=UTF-8") ResponseEntity stockExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + @SuppressWarnings("unchecked") List> rows=(List>) stockSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF현황,입고일,상태,종류,일련번호,모델명,색상,입고가,입고처,출고처,처리일,처리자,비고\n"); + for(Map r:rows) csv.append(csv(r.get("statusLabel"))).append(',').append(csv(r.get("indate"))).append(',') + .append(csv(r.get("cond"))).append(',').append(csv(r.get("gubun"))).append(',').append(csv(r.get("serial"))).append(',') + .append(csv(r.get("model"))).append(',').append(csv(r.get("color"))).append(',').append(csv(r.get("inprice"))).append(',') + .append(csv(r.get("incompanyName"))).append(',').append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("processDate"))).append(',') + .append(csv(r.get("processor"))).append(',').append(csv(r.get("memo"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=stocks.csv").body(csv.toString()); + } + @PostMapping("/stocks/bulk-action") @Transactional ApiResponse stockBulkAction(@AuthenticationPrincipal JwtUser u,@RequestBody Map d) { + Object raw=d.get("ids"); String state=String.valueOf(d.getOrDefault("state","")); + if(!(raw instanceof List ids) || ids.isEmpty()) return ApiResponse.fail("선택된 재고가 없습니다."); + if(!List.of("IN","OUT","LOSS","OPEN","SELL","RECOVERY","RETURN").contains(state)) return ApiResponse.fail("처리할 상태를 확인하세요."); + int updated=0; + for(Object idObj:ids) { + Long id=Long.valueOf(idObj.toString()); + Stock s=em.find(Stock.class,id); + if(s==null || !Objects.equals(s.getGroupId(),u.getGroupId())) continue; + s.setState(state); + s.setProcessDate(LocalDate.now()); + s.setProcessor(u.getUserid()); + if("IN".equals(state)) { s.setRecalldate(LocalDate.now()); s.setOutcode("10"); } + if("OUT".equals(state)) { s.setOutdate(LocalDate.now()); s.setOutcode("21"); } + if("LOSS".equals(state)) s.setLossdate(LocalDate.now()); + if("SELL".equals(state)) s.setSelldate(LocalDate.now()); + writeStockHistory(s, u); + updated++; + } + return ApiResponse.ok(Map.of("updated",updated)); + } + @PostMapping("/stocks/bulk-in") @Transactional ApiResponse stockBulkIn(@AuthenticationPrincipal JwtUser u,@RequestBody Map d) { + Object raw=d.get("serials"); if(!(raw instanceof List serials) || serials.isEmpty()) return ApiResponse.fail("일련번호를 입력하세요."); + int made=0; + for(Object value:serials) { String serial=String.valueOf(value).trim(); if(serial.isBlank()) continue; + Stock s=new Stock(); s.setGroupId(u.getGroupId()); s.setGubun(String.valueOf(d.getOrDefault("gubun","휴대폰"))); + s.setTelecom(String.valueOf(d.getOrDefault("telecom","SKT"))); s.setTelcompany(s.getTelecom()); s.setModel(String.valueOf(d.getOrDefault("model",""))); + s.setColor(String.valueOf(d.getOrDefault("color",""))); s.setInprice(d.get("inprice")==null?BigDecimal.ZERO:new BigDecimal(d.get("inprice").toString())); + if(d.get("incompanyId")!=null && !d.get("incompanyId").toString().isBlank()) s.setIncompanyId(Long.valueOf(d.get("incompanyId").toString())); + s.setSerial(serial); s.setBarcode(serial); s.setCond("정상"); s.setState("IN"); s.setIndate(LocalDate.now()); s.setProcessDate(LocalDate.now()); + s.setProcessor(u.getUserid()); s.setUserid(u.getUserid()); s.setProcid(u.getUserid()); s.setUid("stk-"+UUID.randomUUID()); em.persist(s); + writeStockHistory(s, u); made++; + } return ApiResponse.ok(Map.of("created",made)); + } + private Map stockSearchData(JwtUser u,Map f,int page,int size) { + List stocks=em.createQuery("select s from Stock s where s.groupId=:g",Stock.class).setParameter("g",u.getGroupId()).getResultList(); + Map companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName()); + List filtered=stocks.stream().filter(s->matchesStock(s,f,companies)).sorted(stockComparator(f.get("sort"))).toList(); + Map counts=new LinkedHashMap<>(); for(String tab:List.of("ALL","INOUT","IN","OUT","RETURN","LOSS","OPEN","SELL")) counts.put(tab,stocks.stream().filter(s->matchesStock(s,withoutTab(f),companies)&&inTab(s,tab)).count()); + int from=Math.min(Math.max(page,0)*Math.max(size,1),filtered.size()), to=Math.min(from+Math.min(Math.max(size,1),200),filtered.size()); + List> list=filtered.subList(from,to).stream().map(s->stockMap(s,companies)).toList(); + return Map.of("total",filtered.size(),"list",list,"counts",counts); + } + private Map withoutTab(Map f){Map copy=new HashMap<>(f);copy.remove("tab");return copy;} + private boolean matchesStock(Stock s,Map f,Map companies) { + String tab=f.getOrDefault("tab","ALL"); if(!inTab(s,tab)) return false; + if(!eq(s.getCond(),f.get("cond"))||!eq(s.getGubun(),f.get("gubun"))||!eq(s.getTelecom(),f.get("telecom"))||!eq(s.getOutCategory(),f.get("outCategory"))||!eq(s.getSalesperson(),f.get("salesperson"))||!eq(s.getSource(),f.get("source"))) return false; + if(!eqId(s.getIncompanyId(),f.get("incompanyId"))) return false; + if(!like(s.getModel(),f.get("model"))||!like(s.getColor(),f.get("color"))||!like(s.getPetname(),f.get("petname"))||!like(companies.get(s.getOutcompanyId()),f.get("outcompanyName"))) return false; + String serial=f.get("serial"); if(serial!=null&&!serial.isBlank()) {String[] values=("true".equals(f.get("serialMulti"))?serial.split("[,\\n\\r]+"):new String[]{serial});if(Arrays.stream(values).map(String::trim).noneMatch(v->like(s.getSerial(),v)))return false;} + String searchType=f.getOrDefault("searchType","일련번호"); String keyword=f.get("keyword"); + if(keyword!=null&&!keyword.isBlank()) { + boolean ok=switch(searchType){ + case "모델명"->like(s.getModel(),keyword); + case "색상"->like(s.getColor(),keyword); + case "펫네임"->like(s.getPetname(),keyword); + default->like(s.getSerial(),keyword); + }; + if(!ok) return false; + } + LocalDate date=switch(f.getOrDefault("dateBasis","입고일기준")){case "출고일기준"->s.getOutdate();case "처리일기준"->s.getProcessDate();default->s.getIndate();}; + if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(date==null||date.isBefore(LocalDate.parse(f.get("dateFrom")))))return false; + return f.get("dateTo")==null||f.get("dateTo").isBlank()||(date!=null&&!date.isAfter(LocalDate.parse(f.get("dateTo")))); + } + private boolean inTab(Stock s,String tab){return switch(tab){case "INOUT"->"IN".equals(s.getState())||"OUT".equals(s.getState());case "ALL"->true;default->tab.equals(s.getState());};} + private boolean blank(String v){return v==null||v.isBlank();} + private boolean eq(String actual,String expected){return expected==null||expected.isBlank()||Objects.equals(actual,expected);} + private boolean eqId(Long actual,String expected){return expected==null||expected.isBlank()||Objects.equals(actual,Long.valueOf(expected));} + private boolean like(String actual,String expected){return expected==null||expected.isBlank()||(actual!=null&&actual.toLowerCase().contains(expected.toLowerCase()));} + private Comparator stockComparator(String sort){String field=sort==null?"indate,desc":sort;boolean asc=field.endsWith(",asc");Comparator c=switch(field.split(",")[0]){case "serial"->Comparator.comparing(Stock::getSerial,Comparator.nullsLast(String::compareTo));case "model"->Comparator.comparing(Stock::getModel,Comparator.nullsLast(String::compareTo));case "inprice"->Comparator.comparing(Stock::getInprice,Comparator.nullsLast(BigDecimal::compareTo));case "processDate"->Comparator.comparing(Stock::getProcessDate,Comparator.nullsLast(LocalDate::compareTo));default->Comparator.comparing(Stock::getIndate,Comparator.nullsLast(LocalDate::compareTo));};return (asc?c:c.reversed()).thenComparing(Stock::getId); } + private Map stockMap(Stock s,Map companies){Map r=new LinkedHashMap<>();r.put("id",s.getId());r.put("gubun",s.getGubun());r.put("telecom",s.getTelecom());r.put("telcompany",s.getTelcompany());r.put("serial",s.getSerial());r.put("model",s.getModel());r.put("color",s.getColor());r.put("cond",s.getCond());r.put("pay",s.getPay());r.put("memo",s.getMemo());r.put("state",s.getState());r.put("inprice",s.getInprice());r.put("indate",s.getIndate());r.put("outdate",s.getOutdate());r.put("processDate",s.getProcessDate());r.put("processor",s.getProcessor());r.put("petname",s.getPetname());r.put("outCategory",s.getOutCategory());r.put("salesperson",s.getSalesperson());r.put("source",s.getSource());r.put("incompanyId",s.getIncompanyId());r.put("outcompanyId",s.getOutcompanyId());r.put("incompanyName",companies.getOrDefault(s.getIncompanyId(),"-"));r.put("outcompanyName",companies.getOrDefault(s.getOutcompanyId(),"-"));r.put("statusLabel",statusLabel(s.getState()));r.put("elapsedDays",s.getIndate()==null?null:Math.max(0,java.time.temporal.ChronoUnit.DAYS.between(s.getIndate(),LocalDate.now())));return r;} + private String statusLabel(String state){return Map.of("IN","입고","OUT","출고","RETURN","반품","LOSS","분실","OPEN","개통","SELL","판매","RECOVERY","회수대기").getOrDefault(state,state==null?"-":state);} + private String csv(Object value){String s=String.valueOf(value==null?"":value);return "\""+s.replace("\"","\"\"")+"\"";} + @GetMapping("/opens") ApiResponse opens(@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="30")int size){return list(OpenRecord.class,u,f,page,size);} + @GetMapping("/opens/search") ApiResponse openSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){return ApiResponse.ok(openSearchData(u,f,page,size));} + @GetMapping(value="/opens/export", produces="text/csv;charset=UTF-8") ResponseEntity openExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f) { + @SuppressWarnings("unchecked") List> rows=(List>) openSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF개통일,통신사,출고처,고객명,구분,종류,개통번호,상태,모델명,일련번호,입고처,정산,차감,마진\n"); + for(Map r:rows) csv.append(csv(r.get("opendate"))).append(',').append(csv(r.get("telecom"))).append(',').append(csv(r.get("outcompanyName"))).append(',') + .append(csv(r.get("name"))).append(',').append(csv(r.get("gubun"))).append(',').append(csv(r.get("openhow"))).append(',').append(csv(r.get("openphone"))).append(',') + .append(csv(r.get("openStatus"))).append(',').append(csv(r.get("pmodel"))).append(',').append(csv(r.get("pserial"))).append(',').append(csv(r.get("incompanyName"))).append(',') + .append(csv(r.get("sellprice"))).append(',').append(csv(r.get("declprice1"))).append(',').append(csv(r.get("dealermargin1"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=opens.csv").body(csv.toString()); + } + @PostMapping("/opens") @Transactional ApiResponse openPost(@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){ + d.putIfAbsent("state","OPEN"); d.putIfAbsent("openStatus","정상"); d.putIfAbsent("userid",u.getUserid()); + if(d.get("uid")==null||String.valueOf(d.get("uid")).isBlank()) d.put("uid","open-"+UUID.randomUUID()); + ApiResponse created=post(OpenRecord.class,d,u); + linkOpenStock(d); return created; + } + @GetMapping({"/opens/pending","/opens/miss"}) ApiResponse pending(@AuthenticationPrincipal JwtUser u){return list(OpenRecord.class,u,Map.of("state","PENDING"),0,100);} + @GetMapping("/opens/serial-lookup") ApiResponse openSerialLookup(@AuthenticationPrincipal JwtUser u,@RequestParam String serial,@RequestParam(defaultValue="휴대폰") String gubun){ + if(serial==null||serial.trim().length()<3) return ApiResponse.ok(List.of()); + Map companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName()); + List stocks=em.createQuery("select s from Stock s where s.groupId=:g and s.serial like :q",Stock.class).setParameter("g",u.getGroupId()).setParameter("q","%"+serial.trim()+"%").setMaxResults(20).getResultList(); + return ApiResponse.ok(stocks.stream().filter(s->blank(gubun)||("유심".equals(gubun)? "유심".equals(s.getGubun()):!"유심".equals(s.getGubun()))).map(s->stockMap(s,companies)).toList()); + } + @GetMapping("/opens/adjust") ApiResponse openAdjust(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + if(blank(f.get("dateFrom"))||blank(f.get("dateTo"))) return ApiResponse.fail("개통일을 선택하세요."); + LocalDate from=LocalDate.parse(f.get("dateFrom")), to=LocalDate.parse(f.get("dateTo")); + if(to.isBefore(from)) return ApiResponse.fail("날짜 범위가 올바르지 않습니다."); + if(java.time.temporal.ChronoUnit.DAYS.between(from,to)>30) return ApiResponse.fail("최대 31일까지 조회할 수 있습니다."); + return ApiResponse.ok(openAdjustData(u,f)); + } + @PostMapping("/opens/adjust/batch") @Transactional ApiResponse openAdjustBatch(@AuthenticationPrincipal JwtUser u,@RequestBody Map body){ + Object raw=body.get("items"); if(!(raw instanceof List items)||items.isEmpty()) return ApiResponse.fail("변경할 항목이 없습니다."); + int updated=0; + for(Object item:items){ + if(!(item instanceof Map m)) continue; + Object idObj=m.get("id"); if(idObj==null) continue; + OpenRecord o=em.find(OpenRecord.class,Long.valueOf(String.valueOf(idObj))); + if(o==null||!Objects.equals(o.getGroupId(),u.getGroupId())) continue; + if("유심".equals(o.getGubun())) continue; + if(m.containsKey("basicprice1")) o.setBasicprice1(toDecimal(m.get("basicprice1"))); + if(m.containsKey("basicprice2")) o.setBasicprice2(toDecimal(m.get("basicprice2"))); + if(m.containsKey("basicprice3")) o.setBasicprice3(toDecimal(m.get("basicprice3"))); + updated++; + } + return ApiResponse.ok(Map.of("updated",updated)); + } + @GetMapping(value="/opens/adjust/export", produces="text/csv;charset=UTF-8") ResponseEntity openAdjustExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + if(blank(f.get("dateFrom"))||blank(f.get("dateTo"))) return ResponseEntity.badRequest().body("개통일을 선택하세요."); + @SuppressWarnings("unchecked") List> rows=(List>) openAdjustData(u,f).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF개통일,입고처,출고처,고객명,종류,모델명,일련번호,지원방법,요금제,정산금액,기본정책,구두정책,추가정책\n"); + for(Map r:rows) csv.append(csv(r.get("opendate"))).append(',').append(csv(r.get("incompanyName"))).append(',').append(csv(r.get("outcompanyName"))).append(',') + .append(csv(r.get("name"))).append(',').append(csv(r.get("openhow"))).append(',').append(csv(r.get("pmodel"))).append(',').append(csv(r.get("pserial"))).append(',') + .append(csv(r.get("decltype"))).append(',').append(csv(r.get("plan"))).append(',').append(csv(r.get("sellprice"))).append(',') + .append(csv(r.get("basicprice1"))).append(',').append(csv(r.get("basicprice2"))).append(',').append(csv(r.get("basicprice3"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=adjust.csv").body(csv.toString()); + } + @GetMapping("/opens/{id}") ApiResponse open(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return get(OpenRecord.class,id,u);} + @PutMapping("/opens/{id}") ApiResponse openPut(@PathVariable Long id,@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){return put(OpenRecord.class,id,d,u);} + @DeleteMapping("/opens/{id}") ApiResponse openDel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return del(OpenRecord.class,id,u);} + private void linkOpenStock(Map d){ + Object phoneId=d.get("phoneStockId"), usimId=d.get("usimStockId"); + if(phoneId!=null){Stock s=em.find(Stock.class,Long.valueOf(String.valueOf(phoneId))); if(s!=null){s.setState("OPEN");s.setOutdate(LocalDate.now());}} + if(usimId!=null){Stock s=em.find(Stock.class,Long.valueOf(String.valueOf(usimId))); if(s!=null){s.setState("OPEN");s.setOutdate(LocalDate.now());}} + } + private Map openSearchData(JwtUser u,Map f,int page,int size){ + List opens=em.createQuery("select o from OpenRecord o where o.groupId=:g",OpenRecord.class).setParameter("g",u.getGroupId()).getResultList(); + Map companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName()); + List filtered=opens.stream().filter(o->matchesOpen(o,f,companies)).sorted(openComparator(f.get("sort"))).toList(); + Map counts=new LinkedHashMap<>(); for(String tab:List.of("ALL","신규","번호이동","보상","기변","메이징","가개통","선불개통")) counts.put(tab,opens.stream().filter(o->matchesOpen(o,withoutTab(f),companies)&&inOpenTab(o,tab)).count()); + int from=Math.min(Math.max(page,0)*Math.max(size,1),filtered.size()), to=Math.min(from+Math.min(Math.max(size,1),200),filtered.size()); + List> list=filtered.subList(from,to).stream().map(o->openMap(o,companies)).toList(); + return Map.of("total",filtered.size(),"list",list,"counts",counts); + } + private boolean matchesOpen(OpenRecord o,Map f,Map companies){ + String tab=f.getOrDefault("tab","ALL"); if(!inOpenTab(o,tab)) return false; + if(!eq(o.getGubun(),f.get("gubun"))||!eq(o.getTelecom(),f.get("telecom"))||!eq(o.getOpenhow(),f.get("openhow"))||!eq(o.getOpenStatus(),f.get("openStatus"))||!eq(o.getSalesperson(),f.get("salesperson"))||!eq(o.getEmployee(),f.get("employee"))||!eq(o.getOutCategory(),f.get("outCategory"))||!eq(o.getState(),f.get("history"))) return false; + if(!eqId(o.getIncompanyId(),f.get("incompanyId"))) return false; + if(!like(o.getPmodel(),f.get("model"))||!like(companies.get(o.getOutcompanyId()),f.get("outcompanyName"))||!like(o.getName(),f.get("name"))) return false; + String phoneTail=f.get("phoneTail"); if(phoneTail!=null&&!phoneTail.isBlank()){String p=o.getOpenphone()==null?"":o.getOpenphone().replaceAll("\\D",""); if(!p.endsWith(phoneTail.replaceAll("\\D",""))) return false;} + if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(o.getOpendate()==null||o.getOpendate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false; + return f.get("dateTo")==null||f.get("dateTo").isBlank()||(o.getOpendate()!=null&&!o.getOpendate().isAfter(LocalDate.parse(f.get("dateTo")))); + } + private boolean inOpenTab(OpenRecord o,String tab){ + if("ALL".equals(tab)||blank(tab)) return true; + String how=o.getOpenhow()==null?"":o.getOpenhow(); + return switch(tab){case "번호이동"->"번호이동".equals(how)||"MNP".equalsIgnoreCase(how); default->tab.equals(how);}; + } + private Comparator openComparator(String sort){ + String field=sort==null?"opendate,desc":sort; boolean asc=field.endsWith(",asc"); + Comparator c=switch(field.split(",")[0]){ + case "telecom"->Comparator.comparing(OpenRecord::getTelecom,Comparator.nullsLast(String::compareTo)); + case "pmodel"->Comparator.comparing(OpenRecord::getPmodel,Comparator.nullsLast(String::compareTo)); + case "sellprice"->Comparator.comparing(OpenRecord::getSellprice,Comparator.nullsLast(BigDecimal::compareTo)); + case "gubun"->Comparator.comparing(OpenRecord::getGubun,Comparator.nullsLast(String::compareTo)); + default->Comparator.comparing(OpenRecord::getOpendate,Comparator.nullsLast(LocalDate::compareTo)); + }; + return (asc?c:c.reversed()).thenComparing(OpenRecord::getId); + } + private Map openMap(OpenRecord o,Map companies){ + Map r=new LinkedHashMap<>(); + r.put("id",o.getId()); r.put("opendate",o.getOpendate()); r.put("telecom",o.getTelecom()); r.put("outcompanyId",o.getOutcompanyId()); + r.put("outcompanyName",companies.getOrDefault(o.getOutcompanyId(),"-")); r.put("incompanyId",o.getIncompanyId()); + r.put("incompanyName",companies.getOrDefault(o.getIncompanyId(),"-")); r.put("name",o.getName()); r.put("gubun",o.getGubun()); + r.put("openhow",o.getOpenhow()); r.put("openphone",o.getOpenphone()); r.put("openStatus",o.getOpenStatus()==null?"정상":o.getOpenStatus()); + r.put("pmodel",o.getPmodel()); r.put("pserial",o.getPserial()); r.put("pcolor",o.getPcolor()); r.put("userial",o.getUserial()); r.put("umodel",o.getUmodel()); + r.put("sellprice",o.getSellprice()); r.put("declprice1",o.getDeclprice1()); r.put("dealermargin1",o.getDealermargin1()); + r.put("plan",o.getPlan()); r.put("state",o.getState()); r.put("salesperson",o.getSalesperson()); r.put("employee",o.getEmployee()); + r.put("outCategory",o.getOutCategory()); r.put("inprice",o.getInprice()); r.put("netprice",o.getNetprice()); + r.put("openHour",o.getOpenHour()); r.put("openMinute",o.getOpenMinute()); r.put("salemon",o.getSalemon()); r.put("agreemon",o.getAgreemon()); + r.put("salehow",o.getSalehow()); r.put("joinhow",o.getJoinhow()); r.put("usimhow",o.getUsimhow()); r.put("supportType",o.getSupportType()); + r.put("memo1",o.getMemo1()); r.put("dealermemo",o.getDealermemo()); r.put("phoneStockId",o.getPhoneStockId()); r.put("usimStockId",o.getUsimStockId()); + r.put("commonSupport",o.getCommonSupport()); r.put("extraSupport",o.getExtraSupport()); r.put("switchSupport",o.getSwitchSupport()); + r.put("pointPay",o.getPointPay()); r.put("preprice",o.getPreprice()); r.put("joinprice",o.getJoinprice()); r.put("gradePrice",o.getGradePrice()); + r.put("basicprice1",o.getBasicprice1()); r.put("basicprice2",o.getBasicprice2()); r.put("basicprice3",o.getBasicprice3()); r.put("basicprice4",o.getBasicprice4()); + r.put("etcprice1",o.getEtcprice1()); r.put("etcprice2",o.getEtcprice2()); r.put("moveprice",o.getMoveprice()); r.put("planprice",o.getPlanprice()); + r.put("memberpoint",o.getMemberpoint()); r.put("usimprice",o.getUsimprice()); r.put("uid",o.getUid()); + r.put("decltype",o.getDecltype()); + return r; + } + private Map openAdjustData(JwtUser u,Map f){ + List opens=em.createQuery("select o from OpenRecord o where o.groupId=:g",OpenRecord.class).setParameter("g",u.getGroupId()).getResultList(); + Map companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName()); + List filtered=opens.stream().filter(o->matchesAdjust(o,f,companies)).sorted(adjustComparator(f)).toList(); + return Map.of("total",filtered.size(),"list",filtered.stream().map(o->openMap(o,companies)).toList()); + } + private boolean matchesAdjust(OpenRecord o,Map f,Map companies){ + if("유심".equals(o.getGubun())) return false; + if(!eq(o.getOpenhow(),f.get("openhow"))||!eq(o.getTelecom(),f.get("telecom"))||!eq(o.getSalesperson(),f.get("salesperson"))||!eq(o.getPlan(),f.get("plan"))) return false; + if(!eqId(o.getIncompanyId(),f.get("incompanyId"))||!eqId(o.getOutcompanyId(),f.get("outcompanyId"))) return false; + if(!like(o.getPmodel(),f.get("model"))||!like(companies.get(o.getOutcompanyId()),f.get("outcompanyName"))) return false; + if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(o.getOpendate()==null||o.getOpendate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false; + return f.get("dateTo")==null||f.get("dateTo").isBlank()||(o.getOpendate()!=null&&!o.getOpendate().isAfter(LocalDate.parse(f.get("dateTo")))); + } + private Comparator adjustComparator(Map f){ + List keys=new ArrayList<>(); + for(int i=1;i<=7;i++){String k=f.get("sort"+i); if(k!=null&&!k.isBlank()) keys.add(k);} + if(keys.isEmpty()) keys=List.of("종류","모델명","요금제","개통일","출고처","지원방법","고객명"); + Comparator c=null; + for(String key:keys){ + Comparator next=switch(key){ + case "종류"->Comparator.comparing(OpenRecord::getOpenhow,Comparator.nullsLast(String::compareTo)); + case "모델명"->Comparator.comparing(OpenRecord::getPmodel,Comparator.nullsLast(String::compareTo)); + case "요금제"->Comparator.comparing(OpenRecord::getPlan,Comparator.nullsLast(String::compareTo)); + case "출고처"->Comparator.comparing(OpenRecord::getOutcompanyId,Comparator.nullsLast(Long::compareTo)); + case "지원방법"->Comparator.comparing(OpenRecord::getDecltype,Comparator.nullsLast(String::compareTo)); + case "고객명"->Comparator.comparing(OpenRecord::getName,Comparator.nullsLast(String::compareTo)); + case "통신사"->Comparator.comparing(OpenRecord::getTelecom,Comparator.nullsLast(String::compareTo)); + default->Comparator.comparing(OpenRecord::getOpendate,Comparator.nullsLast(LocalDate::compareTo)); + }; + c=c==null?next:c.thenComparing(next); + } + return (c==null?Comparator.comparing(OpenRecord::getId):c.thenComparing(OpenRecord::getId)); + } + private BigDecimal toDecimal(Object v){ if(v==null||String.valueOf(v).isBlank()) return BigDecimal.ZERO; return new BigDecimal(String.valueOf(v).replace(",","")); } + @GetMapping("/unitcosts") ApiResponse unitCosts(@AuthenticationPrincipal JwtUser u){ + List rows=em.createQuery("select t from UnitCostTable t where t.groupId=:g order by t.id desc",UnitCostTable.class).setParameter("g",u.getGroupId()).getResultList(); + Map companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName()); + return ApiResponse.ok(Map.of("total",rows.size(),"list",rows.stream().map(t->unitCostMap(t,companies)).toList(),"max",20)); + } + @PostMapping("/unitcosts") @Transactional ApiResponse unitCostPost(@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){ + long count=em.createQuery("select count(t) from UnitCostTable t where t.groupId=:g",Long.class).setParameter("g",u.getGroupId()).getSingleResult(); + if(count>=20) return ApiResponse.fail("단가표는 최대 20개까지 등록할 수 있습니다."); + d.putIfAbsent("category","표준"); d.putIfAbsent("policyCount",0); + Integer groups=toInt(d.get("planGroupCount")); if(groups!=null){ d.put("planGroupA",groups); d.putIfAbsent("planGroupB",0); d.putIfAbsent("planGroupC",0); d.putIfAbsent("planGroupD",0); } + return post(UnitCostTable.class,d,u); + } + @PutMapping("/unitcosts/{id}") ApiResponse unitCostPut(@PathVariable Long id,@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){return put(UnitCostTable.class,id,d,u);} + @DeleteMapping("/unitcosts/{id}") @Transactional ApiResponse unitCostDel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){ + em.createQuery("delete from UnitCostPolicy p where p.unitCostId=:id and p.groupId=:g").setParameter("id",id).setParameter("g",u.getGroupId()).executeUpdate(); + return del(UnitCostTable.class,id,u); + } + @GetMapping("/unitcosts/{id}/policies") ApiResponse unitCostPolicies(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){ + UnitCostTable t=em.find(UnitCostTable.class,id); if(t==null||!Objects.equals(t.getGroupId(),u.getGroupId())) return ApiResponse.fail("단가표를 찾을 수 없습니다."); + List policies=em.createQuery("select p from UnitCostPolicy p where p.groupId=:g and p.unitCostId=:id order by p.id",UnitCostPolicy.class).setParameter("g",u.getGroupId()).setParameter("id",id).getResultList(); + return ApiResponse.ok(Map.of("table",unitCostMap(t,Map.of()),"list",policies)); + } + @PostMapping("/unitcosts/{id}/policies") @Transactional ApiResponse unitCostPolicyPost(@PathVariable Long id,@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){ + UnitCostTable t=em.find(UnitCostTable.class,id); if(t==null||!Objects.equals(t.getGroupId(),u.getGroupId())) return ApiResponse.fail("단가표를 찾을 수 없습니다."); + d.put("unitCostId",id); ApiResponse created=post(UnitCostPolicy.class,d,u); + refreshPolicyCount(t); return created; + } + @PutMapping("/unitcosts/policies/{policyId}") ApiResponse unitCostPolicyPut(@PathVariable Long policyId,@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){return put(UnitCostPolicy.class,policyId,d,u);} + @DeleteMapping("/unitcosts/policies/{policyId}") @Transactional ApiResponse unitCostPolicyDel(@PathVariable Long policyId,@AuthenticationPrincipal JwtUser u){ + UnitCostPolicy p=em.find(UnitCostPolicy.class,policyId); if(p==null||!Objects.equals(p.getGroupId(),u.getGroupId())) return ApiResponse.fail("정책을 찾을 수 없습니다."); + Long tableId=p.getUnitCostId(); del(UnitCostPolicy.class,policyId,u); + UnitCostTable t=em.find(UnitCostTable.class,tableId); if(t!=null) refreshPolicyCount(t); + return ApiResponse.ok(null); + } + @GetMapping("/unitcosts/{id}/apply-search") ApiResponse unitCostApplySearch(@PathVariable Long id,@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + UnitCostTable t=em.find(UnitCostTable.class,id); if(t==null||!Objects.equals(t.getGroupId(),u.getGroupId())) return ApiResponse.fail("단가표를 찾을 수 없습니다."); + if(blank(f.get("dateFrom"))||blank(f.get("dateTo"))) return ApiResponse.fail("개통일을 선택하세요."); + LocalDate from=LocalDate.parse(f.get("dateFrom")), to=LocalDate.parse(f.get("dateTo")); + if(java.time.temporal.ChronoUnit.DAYS.between(from,to)>2) return ApiResponse.fail("최대 3일까지 조회할 수 있습니다."); + Map filters=new HashMap<>(f); filters.put("telecom",t.getTelecom()); + if(t.getIncompanyId()!=null) filters.put("incompanyId",String.valueOf(t.getIncompanyId())); + List opens=em.createQuery("select o from OpenRecord o where o.groupId=:g",OpenRecord.class).setParameter("g",u.getGroupId()).getResultList(); + Map companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName()); + List> list=opens.stream().filter(o->matchesUnitCostApply(o,filters,t)).map(o->openMap(o,companies)).toList(); + return ApiResponse.ok(Map.of("total",list.size(),"list",list,"table",unitCostMap(t,companies))); + } + @PostMapping("/unitcosts/{id}/apply") @Transactional ApiResponse unitCostApply(@PathVariable Long id,@AuthenticationPrincipal JwtUser u,@RequestBody Map body){ + UnitCostTable t=em.find(UnitCostTable.class,id); if(t==null||!Objects.equals(t.getGroupId(),u.getGroupId())) return ApiResponse.fail("단가표를 찾을 수 없습니다."); + List policies=em.createQuery("select p from UnitCostPolicy p where p.groupId=:g and p.unitCostId=:id",UnitCostPolicy.class).setParameter("g",u.getGroupId()).setParameter("id",id).getResultList(); + if(policies.isEmpty()) return ApiResponse.fail("등록된 정책단가가 없습니다. 먼저 정책단가를 작성해주세요."); + BigDecimal defaultAmount=policies.getFirst().getAmount()==null?BigDecimal.ZERO:policies.getFirst().getAmount(); + Object raw=body.get("ids"); List ids=new ArrayList<>(); + if(raw instanceof List list) for(Object o:list) ids.add(Long.valueOf(String.valueOf(o))); + int updated=0; + for(Long openId:ids){ + OpenRecord o=em.find(OpenRecord.class,openId); + if(o==null||!Objects.equals(o.getGroupId(),u.getGroupId())||"유심".equals(o.getGubun())) continue; + BigDecimal amount=defaultAmount; + for(UnitCostPolicy p:policies){ + if(p.getModel()!=null&&!p.getModel().isBlank()&&o.getPmodel()!=null&&o.getPmodel().contains(p.getModel())){ amount=p.getAmount(); break; } + if(p.getPlanName()!=null&&!p.getPlanName().isBlank()&&Objects.equals(p.getPlanName(),o.getPlan())){ amount=p.getAmount(); break; } + } + o.setBasicprice1(amount); updated++; + } + return ApiResponse.ok(Map.of("updated",updated)); + } + private void refreshPolicyCount(UnitCostTable t){ + long c=em.createQuery("select count(p) from UnitCostPolicy p where p.unitCostId=:id",Long.class).setParameter("id",t.getId()).getSingleResult(); + t.setPolicyCount((int)c); + } + private boolean matchesUnitCostApply(OpenRecord o,Map f,UnitCostTable t){ + if("유심".equals(o.getGubun())) return false; + String how=o.getOpenhow()==null?"":o.getOpenhow(); + if(!(Set.of("신규","번호이동","기변","보상").contains(how)||"MNP".equalsIgnoreCase(how))) return false; + if(!eq(o.getTelecom(),t.getTelecom())) return false; + if(t.getIncompanyId()!=null&&!Objects.equals(o.getIncompanyId(),t.getIncompanyId())) return false; + if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(o.getOpendate()==null||o.getOpendate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false; + return f.get("dateTo")==null||f.get("dateTo").isBlank()||(o.getOpendate()!=null&&!o.getOpendate().isAfter(LocalDate.parse(f.get("dateTo")))); + } + private Map unitCostMap(UnitCostTable t,Map companies){ + Map m=new LinkedHashMap<>(); + m.put("id",t.getId()); m.put("telecom",t.getTelecom()); m.put("incompanyId",t.getIncompanyId()); + m.put("incompanyName",t.getIncompanyName()!=null&&!t.getIncompanyName().isBlank()?t.getIncompanyName():companies.getOrDefault(t.getIncompanyId(),"-")); + m.put("memo",t.getMemo()); m.put("category",t.getCategory()); + m.put("planGroupA",t.getPlanGroupA()); m.put("planGroupB",t.getPlanGroupB()); m.put("planGroupC",t.getPlanGroupC()); + m.put("planGroupD",t.getPlanGroupD()); m.put("planGroupE",t.getPlanGroupE()); m.put("planGroupF",t.getPlanGroupF()); m.put("planGroupG",t.getPlanGroupG()); + m.put("policyCount",t.getPolicyCount()==null?0:t.getPolicyCount()); + return m; + } + private Integer toInt(Object v){ if(v==null||String.valueOf(v).isBlank()) return null; return Integer.valueOf(String.valueOf(v)); } + @GetMapping("/homesales/search") ApiResponse homeSaleSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){ + return ApiResponse.ok(homeSaleSearchData(u,f,page,size)); + } + @PostMapping("/homesales") @Transactional ApiResponse homeSalePost(@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){ + d.putIfAbsent("status","완료"); return post(HomeSale.class,d,u); + } + @GetMapping("/homesales/{id}") ApiResponse homeSaleGet(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return get(HomeSale.class,id,u);} + @PutMapping("/homesales/{id}") ApiResponse homeSalePut(@PathVariable Long id,@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){return put(HomeSale.class,id,d,u);} + @DeleteMapping("/homesales/{id}") ApiResponse homeSaleDel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return del(HomeSale.class,id,u);} + @GetMapping(value="/homesales/export", produces="text/csv;charset=UTF-8") ResponseEntity homeSaleExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) homeSaleSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF설치일,출고처,상태,입고처,통신사,고객명,휴대폰,가입상품,정산,마진,접수일\n"); + for(Map r:rows) csv.append(csv(r.get("installDate"))).append(',').append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("status"))).append(',') + .append(csv(r.get("incompanyName"))).append(',').append(csv(r.get("telecom"))).append(',').append(csv(r.get("customerName"))).append(',').append(csv(r.get("phone"))).append(',') + .append(csv(r.get("products"))).append(',').append(csv(r.get("settleAmount"))).append(',').append(csv(r.get("margin"))).append(',').append(csv(r.get("receiptDate"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=homesales.csv").body(csv.toString()); + } + private Map homeSaleSearchData(JwtUser u,Map f,int page,int size){ + List rows=em.createQuery("select h from HomeSale h where h.groupId=:g",HomeSale.class).setParameter("g",u.getGroupId()).getResultList(); + Map companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName()); + List filtered=rows.stream().filter(h->matchesHomeSale(h,f,companies)).sorted(homeSaleComparator(f.get("sort"))).toList(); + Map counts=new LinkedHashMap<>(); + List tabs=List.of("ALL","인터넷","TV","집전화","인터넷전화","신용카드","홈IOT","동반","기타","후결합","재약정","소호","원스톱","렌탈"); + for(String tab:tabs) counts.put(tab,rows.stream().filter(h->matchesHomeSale(h,withoutTab(f),companies)&&inHomeTab(h,tab)).count()); + int from=Math.min(Math.max(page,0)*Math.max(size,1),filtered.size()), to=Math.min(from+Math.min(Math.max(size,1),200),filtered.size()); + List> list=filtered.subList(from,to).stream().map(h->homeSaleMap(h,companies)).toList(); + return Map.of("total",filtered.size(),"list",list,"counts",counts); + } + private boolean matchesHomeSale(HomeSale h,Map f,Map companies){ + String tab=f.getOrDefault("tab","ALL"); if(!inHomeTab(h,tab)) return false; + if(!eq(h.getTelecom(),f.get("telecom"))||!eq(h.getOutCategory(),f.get("outCategory"))||!eq(h.getStatus(),f.get("status"))||!eq(h.getEmployee(),f.get("employee"))) return false; + if(!eqId(h.getIncompanyId(),f.get("incompanyId"))) return false; + if(!like(h.getOutcompanyName(),f.get("outcompanyName"))&&!like(companies.get(h.getOutcompanyId()),f.get("outcompanyName"))) return false; + if(!like(h.getCustomerName(),f.get("customerName"))) return false; + String dateBasis=f.getOrDefault("dateBasis","설치일기준"); + LocalDate date="접수일기준".equals(dateBasis)?h.getReceiptDate():h.getInstallDate(); + if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(date==null||date.isBefore(LocalDate.parse(f.get("dateFrom"))))) return false; + return f.get("dateTo")==null||f.get("dateTo").isBlank()||(date!=null&&!date.isAfter(LocalDate.parse(f.get("dateTo")))); + } + private boolean inHomeTab(HomeSale h,String tab){ + if("ALL".equals(tab)||blank(tab)) return true; + String products=h.getProducts()==null?"":h.getProducts(); + return Arrays.stream(products.split("[,|/]")).map(String::trim).anyMatch(p->p.equals(tab)||("동판".equals(tab)&&"동반".equals(p))); + } + private Comparator homeSaleComparator(String sort){ + String field=sort==null?"installDate,desc":sort; boolean asc=field.endsWith(",asc"); + Comparator c=switch(field.split(",")[0]){ + case "outcompanyName"->Comparator.comparing(HomeSale::getOutcompanyName,Comparator.nullsLast(String::compareTo)); + case "telecom"->Comparator.comparing(HomeSale::getTelecom,Comparator.nullsLast(String::compareTo)); + case "customerName"->Comparator.comparing(HomeSale::getCustomerName,Comparator.nullsLast(String::compareTo)); + case "settleAmount"->Comparator.comparing(HomeSale::getSettleAmount,Comparator.nullsLast(BigDecimal::compareTo)); + case "receiptDate"->Comparator.comparing(HomeSale::getReceiptDate,Comparator.nullsLast(LocalDate::compareTo)); + default->Comparator.comparing(HomeSale::getInstallDate,Comparator.nullsLast(LocalDate::compareTo)); + }; + return (asc?c:c.reversed()).thenComparing(HomeSale::getId); + } + private Map homeSaleMap(HomeSale h,Map companies){ + Map m=new LinkedHashMap<>(); + m.put("id",h.getId()); m.put("installDate",h.getInstallDate()); m.put("receiptDate",h.getReceiptDate()); + m.put("status",h.getStatus()); m.put("telecom",h.getTelecom()); m.put("outCategory",h.getOutCategory()); + m.put("outcompanyId",h.getOutcompanyId()); m.put("incompanyId",h.getIncompanyId()); + m.put("outcompanyName",h.getOutcompanyName()!=null&&!h.getOutcompanyName().isBlank()?h.getOutcompanyName():companies.getOrDefault(h.getOutcompanyId(),"-")); + m.put("incompanyName",companies.getOrDefault(h.getIncompanyId(),"-")); + m.put("employee",h.getEmployee()); m.put("customerName",h.getCustomerName()); m.put("phone",h.getPhone()); + m.put("address",h.getAddress()); m.put("products",h.getProducts()); m.put("missingDocs",h.getMissingDocs()); + m.put("extraPolicy",h.getExtraPolicy()); m.put("settleAmount",h.getSettleAmount()); m.put("margin",h.getMargin()); m.put("memo",h.getMemo()); + return m; + } + @GetMapping("/homeindocs/search") ApiResponse homeIndocSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){ + if(blank(f.get("dateFrom"))||blank(f.get("dateTo"))) return ApiResponse.fail("날짜를 선택하세요."); + return ApiResponse.ok(homeIndocSearchData(u,f,page,size)); + } + @GetMapping(value="/homeindocs/export", produces="text/csv;charset=UTF-8") ResponseEntity homeIndocExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + if(blank(f.get("dateFrom"))||blank(f.get("dateTo"))) return ResponseEntity.badRequest().body("날짜를 선택하세요."); + @SuppressWarnings("unchecked") List> rows=(List>) homeIndocSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF설치일,통신사,입고처,출고처,고객명,휴대폰,가입상품,미비서류,차감금,차감사유,마감일,상태\n"); + for(Map r:rows) csv.append(csv(r.get("installDate"))).append(',').append(csv(r.get("telecom"))).append(',').append(csv(r.get("incompanyName"))).append(',') + .append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("customerName"))).append(',').append(csv(r.get("phone"))).append(',') + .append(csv(r.get("products"))).append(',').append(csv(r.get("missingDocs"))).append(',').append(csv(r.get("deductAmount"))).append(',') + .append(csv(r.get("deductReason"))).append(',').append(csv(r.get("deadline"))).append(',').append(csv(r.get("status"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=homeindocs.csv").body(csv.toString()); + } + private Map homeIndocSearchData(JwtUser u,Map f,int page,int size){ + List docs=em.createQuery("select d from HomeIncompleteDoc d where d.groupId=:g",HomeIncompleteDoc.class).setParameter("g",u.getGroupId()).getResultList(); + Map companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName()); + List filtered=docs.stream().filter(d->matchesHomeIndoc(d,f,companies)).sorted(Comparator.comparing(HomeIncompleteDoc::getInstallDate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(HomeIncompleteDoc::getId)).toList(); + int from=Math.min(Math.max(page,0)*Math.max(size,1),filtered.size()), to=Math.min(from+Math.min(Math.max(size,1),200),filtered.size()); + List> list=filtered.subList(from,to).stream().map(d->homeIndocMap(d,companies)).toList(); + return Map.of("total",filtered.size(),"list",list); + } + private boolean matchesHomeIndoc(HomeIncompleteDoc d,Map f,Map companies){ + if(!eq(d.getTelecom(),f.get("telecom"))||!eqId(d.getIncompanyId(),f.get("incompanyId"))) return false; + if(!like(d.getOutcompanyName(),f.get("outcompanyName"))&&!like(companies.get(d.getOutcompanyId()),f.get("outcompanyName"))) return false; + if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(d.getInstallDate()==null||d.getInstallDate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false; + if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(d.getInstallDate()==null||d.getInstallDate().isAfter(LocalDate.parse(f.get("dateTo"))))) return false; + String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true; + String searchType=f.getOrDefault("searchType","고객명"); + return switch(searchType){ + case "미비서류"->like(d.getMissingDocs(),keyword); + case "휴대폰(뒷4자리)"->{ + String phone=d.getPhone()==null?"":d.getPhone().replaceAll("\\D",""); + yield phone.endsWith(keyword.replaceAll("\\D","")); + } + default->like(d.getCustomerName(),keyword); + }; + } + private Map homeIndocMap(HomeIncompleteDoc d,Map companies){ + Map r=new LinkedHashMap<>(); + r.put("id",d.getId()); r.put("installDate",d.getInstallDate()); r.put("deadline",d.getDeadline()); + r.put("telecom",d.getTelecom()); r.put("customerName",d.getCustomerName()); r.put("phone",d.getPhone()); + r.put("products",d.getProducts()); r.put("missingDocs",d.getMissingDocs()); r.put("deductAmount",d.getDeductAmount()); + r.put("deductReason",d.getDeductReason()); r.put("status",d.getStatus()==null?"미비":d.getStatus()); + r.put("homeSaleId",d.getHomeSaleId()); r.put("outcompanyId",d.getOutcompanyId()); r.put("incompanyId",d.getIncompanyId()); + r.put("outcompanyName",d.getOutcompanyName()!=null&&!d.getOutcompanyName().isBlank()?d.getOutcompanyName():companies.getOrDefault(d.getOutcompanyId(),"-")); + r.put("incompanyName",companies.getOrDefault(d.getIncompanyId(),"-")); + return r; + } + @GetMapping("/exchanges/search") ApiResponse exchangeSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){ + return ApiResponse.ok(exchangeSearchData(u,f,page,size)); + } + @PostMapping("/exchanges/delete") @Transactional ApiResponse exchangeDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Object raw=d.get("ids"); + if(!(raw instanceof List ids)||ids.isEmpty()) return ApiResponse.fail("삭제할 이력을 선택하세요."); + int deleted=0; + for(Object idObj:ids){ + OpenExchange e=em.find(OpenExchange.class,Long.valueOf(idObj.toString())); + if(e==null||!Objects.equals(e.getGroupId(),u.getGroupId())) continue; + em.remove(e); deleted++; + } + return ApiResponse.ok(Map.of("deleted",deleted)); + } + @GetMapping(value="/exchanges/export", produces="text/csv;charset=UTF-8") ResponseEntity exchangeExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) exchangeSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF번호,교품일,출고처,고객명,개통일,개통번호,종류,교품,기존,처리직원,처리일,비고\n"); + for(Map r:rows) csv.append(csv(r.get("no"))).append(',').append(csv(r.get("exchangeDate"))).append(',').append(csv(r.get("outcompanyName"))).append(',') + .append(csv(r.get("customerName"))).append(',').append(csv(r.get("openDate"))).append(',').append(csv(r.get("openphone"))).append(',') + .append(csv(r.get("kind"))).append(',').append(csv(r.get("newLabel"))).append(',').append(csv(r.get("oldLabel"))).append(',') + .append(csv(r.get("processor"))).append(',').append(csv(r.get("processedAt"))).append(',').append(csv(r.get("memo"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=exchanges.csv").body(csv.toString()); + } + private Map exchangeSearchData(JwtUser u,Map f,int page,int size){ + List all=em.createQuery("select e from OpenExchange e where e.groupId=:g",OpenExchange.class).setParameter("g",u.getGroupId()).getResultList(); + Map companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName()); + List filtered=all.stream().filter(e->matchesExchange(e,f,companies)).sorted(Comparator.comparing(OpenExchange::getExchangeDate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(OpenExchange::getId,Comparator.reverseOrder())).toList(); + int pageSize=Math.min(Math.max(size,1),200), from=Math.min(Math.max(page,0)*pageSize,filtered.size()), to=Math.min(from+pageSize,filtered.size()); + List> list=new ArrayList<>(); + for(int i=from;i f,Map companies){ + if(!eq(e.getKind(),f.get("kind"))) return false; + if(!like(e.getOutcompanyName(),f.get("outcompanyName"))&&!like(companies.get(e.getOutcompanyId()),f.get("outcompanyName"))) return false; + if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(e.getExchangeDate()==null||e.getExchangeDate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false; + if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(e.getExchangeDate()==null||e.getExchangeDate().isAfter(LocalDate.parse(f.get("dateTo"))))) return false; + String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true; + String searchType=f.getOrDefault("searchType","개통번호(뒷4자리)"); + return switch(searchType){ + case "고객명"->like(e.getCustomerName(),keyword); + case "교품일련번호"->like(e.getNewSerial(),keyword); + case "기존일련번호"->like(e.getOldSerial(),keyword); + default->{ + String phone=e.getOpenphone()==null?"":e.getOpenphone().replaceAll("\\D",""); + yield phone.endsWith(keyword.replaceAll("\\D","")); + } + }; + } + private Map exchangeMap(OpenExchange e,Map companies,int no){ + Map r=new LinkedHashMap<>(); + r.put("id",e.getId()); r.put("no",no); r.put("exchangeDate",e.getExchangeDate()); r.put("openDate",e.getOpenDate()); + r.put("customerName",e.getCustomerName()); r.put("openphone",e.getOpenphone()); r.put("kind",e.getKind()); + r.put("newModel",e.getNewModel()); r.put("newColor",e.getNewColor()); r.put("newSerial",e.getNewSerial()); + r.put("oldModel",e.getOldModel()); r.put("oldColor",e.getOldColor()); r.put("oldSerial",e.getOldSerial()); + r.put("newLabel",deviceLabel(e.getNewModel(),e.getNewColor(),e.getNewSerial())); + r.put("oldLabel",deviceLabel(e.getOldModel(),e.getOldColor(),e.getOldSerial())); + r.put("processor",e.getProcessor()); r.put("memo",e.getMemo()); + r.put("processedAt",e.getProcessedAt()==null?null:e.getProcessedAt().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))); + r.put("outcompanyId",e.getOutcompanyId()); + r.put("outcompanyName",e.getOutcompanyName()!=null&&!e.getOutcompanyName().isBlank()?e.getOutcompanyName():companies.getOrDefault(e.getOutcompanyId(),"-")); + return r; + } + private String deviceLabel(String model,String color,String serial){ + String m=model==null?"":model.trim(); + String c=color==null||color.isBlank()||"-".equals(color)?"":" "+color.trim(); + String s=serial==null||serial.isBlank()?"":" ("+serial.trim()+")"; + return (m+c+s).trim(); + } + @GetMapping("/namechanges/search") ApiResponse nameChangeSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){ + return ApiResponse.ok(nameChangeSearchData(u,f,page,size)); + } + @PostMapping("/namechanges/delete") @Transactional ApiResponse nameChangeDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Object raw=d.get("ids"); + if(!(raw instanceof List ids)||ids.isEmpty()) return ApiResponse.fail("삭제할 이력을 선택하세요."); + int deleted=0; + for(Object idObj:ids){ + OpenNameChange e=em.find(OpenNameChange.class,Long.valueOf(idObj.toString())); + if(e==null||!Objects.equals(e.getGroupId(),u.getGroupId())) continue; + em.remove(e); deleted++; + } + return ApiResponse.ok(Map.of("deleted",deleted)); + } + @GetMapping(value="/namechanges/export", produces="text/csv;charset=UTF-8") ResponseEntity nameChangeExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) nameChangeSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF번호,명변일,출고처,고객명,개통일,개통번호,기존명의자,기존연락처,기존유심,처리직원,처리일,비고\n"); + for(Map r:rows) csv.append(csv(r.get("no"))).append(',').append(csv(r.get("changeDate"))).append(',').append(csv(r.get("outcompanyName"))).append(',') + .append(csv(r.get("customerName"))).append(',').append(csv(r.get("openDate"))).append(',').append(csv(r.get("openphone"))).append(',') + .append(csv(r.get("oldOwner"))).append(',').append(csv(r.get("oldPhone"))).append(',').append(csv(r.get("oldUsim"))).append(',') + .append(csv(r.get("processor"))).append(',').append(csv(r.get("processedAt"))).append(',').append(csv(r.get("memo"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=namechanges.csv").body(csv.toString()); + } + private Map nameChangeSearchData(JwtUser u,Map f,int page,int size){ + List all=em.createQuery("select e from OpenNameChange e where e.groupId=:g",OpenNameChange.class).setParameter("g",u.getGroupId()).getResultList(); + Map companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName()); + List filtered=all.stream().filter(e->matchesNameChange(e,f,companies)).sorted(Comparator.comparing(OpenNameChange::getChangeDate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(OpenNameChange::getId,Comparator.reverseOrder())).toList(); + int pageSize=Math.min(Math.max(size,1),200), from=Math.min(Math.max(page,0)*pageSize,filtered.size()), to=Math.min(from+pageSize,filtered.size()); + List> list=new ArrayList<>(); + for(int i=from;i f,Map companies){ + if(!like(e.getOutcompanyName(),f.get("outcompanyName"))&&!like(companies.get(e.getOutcompanyId()),f.get("outcompanyName"))) return false; + if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(e.getChangeDate()==null||e.getChangeDate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false; + if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(e.getChangeDate()==null||e.getChangeDate().isAfter(LocalDate.parse(f.get("dateTo"))))) return false; + String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true; + String searchType=f.getOrDefault("searchType","개통번호(뒷4자리)"); + return switch(searchType){ + case "고객명"->like(e.getCustomerName(),keyword); + case "기존명의자"->like(e.getOldOwner(),keyword); + case "기존연락처"->{ + String phone=e.getOldPhone()==null?"":e.getOldPhone().replaceAll("\\D",""); + yield phone.endsWith(keyword.replaceAll("\\D","")); + } + default->{ + String phone=e.getOpenphone()==null?"":e.getOpenphone().replaceAll("\\D",""); + yield phone.endsWith(keyword.replaceAll("\\D","")); + } + }; + } + private Map nameChangeMap(OpenNameChange e,Map companies,int no){ + Map r=new LinkedHashMap<>(); + r.put("id",e.getId()); r.put("no",no); r.put("changeDate",e.getChangeDate()); r.put("openDate",e.getOpenDate()); + r.put("customerName",e.getCustomerName()); r.put("openphone",e.getOpenphone()); + r.put("oldOwner",e.getOldOwner()); r.put("oldPhone",e.getOldPhone()); r.put("oldUsim",e.getOldUsim()); + r.put("processor",e.getProcessor()); r.put("memo",e.getMemo()); + r.put("processedAt",e.getProcessedAt()==null?null:e.getProcessedAt().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))); + r.put("outcompanyId",e.getOutcompanyId()); + r.put("outcompanyName",e.getOutcompanyName()!=null&&!e.getOutcompanyName().isBlank()?e.getOutcompanyName():companies.getOrDefault(e.getOutcompanyId(),"-")); + return r; + } + @GetMapping("/withdrawals/search") ApiResponse withdrawalSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){ + return ApiResponse.ok(withdrawalSearchData(u,f,page,size)); + } + @PostMapping("/withdrawals/delete") @Transactional ApiResponse withdrawalDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Object raw=d.get("ids"); + if(!(raw instanceof List ids)||ids.isEmpty()) return ApiResponse.fail("삭제할 이력을 선택하세요."); + int deleted=0; + for(Object idObj:ids){ + OpenWithdrawal e=em.find(OpenWithdrawal.class,Long.valueOf(idObj.toString())); + if(e==null||!Objects.equals(e.getGroupId(),u.getGroupId())) continue; + em.remove(e); deleted++; + } + return ApiResponse.ok(Map.of("deleted",deleted)); + } + @GetMapping(value="/withdrawals/export", produces="text/csv;charset=UTF-8") ResponseEntity withdrawalExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) withdrawalSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF번호,철회일,출고처,고객명,개통일,개통번호,처리직원,처리일,사유 및 비고\n"); + for(Map r:rows) csv.append(csv(r.get("no"))).append(',').append(csv(r.get("withdrawDate"))).append(',').append(csv(r.get("outcompanyName"))).append(',') + .append(csv(r.get("customerName"))).append(',').append(csv(r.get("openDate"))).append(',').append(csv(r.get("openphone"))).append(',') + .append(csv(r.get("processor"))).append(',').append(csv(r.get("processedAt"))).append(',').append(csv(r.get("reason"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=withdrawals.csv").body(csv.toString()); + } + private Map withdrawalSearchData(JwtUser u,Map f,int page,int size){ + List all=em.createQuery("select e from OpenWithdrawal e where e.groupId=:g",OpenWithdrawal.class).setParameter("g",u.getGroupId()).getResultList(); + Map companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName()); + List filtered=all.stream().filter(e->matchesWithdrawal(e,f,companies)).sorted(Comparator.comparing(OpenWithdrawal::getWithdrawDate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(OpenWithdrawal::getId,Comparator.reverseOrder())).toList(); + int pageSize=Math.min(Math.max(size,1),200), from=Math.min(Math.max(page,0)*pageSize,filtered.size()), to=Math.min(from+pageSize,filtered.size()); + List> list=new ArrayList<>(); + for(int i=from;i f,Map companies){ + if(!like(e.getOutcompanyName(),f.get("outcompanyName"))&&!like(companies.get(e.getOutcompanyId()),f.get("outcompanyName"))) return false; + if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(e.getWithdrawDate()==null||e.getWithdrawDate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false; + if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(e.getWithdrawDate()==null||e.getWithdrawDate().isAfter(LocalDate.parse(f.get("dateTo"))))) return false; + String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true; + String searchType=f.getOrDefault("searchType","개통번호(뒷4자리)"); + return switch(searchType){ + case "고객명"->like(e.getCustomerName(),keyword); + case "사유 및 비고"->like(e.getReason(),keyword); + default->{ + String phone=e.getOpenphone()==null?"":e.getOpenphone().replaceAll("\\D",""); + yield phone.endsWith(keyword.replaceAll("\\D","")); + } + }; + } + private Map withdrawalMap(OpenWithdrawal e,Map companies,int no){ + Map r=new LinkedHashMap<>(); + r.put("id",e.getId()); r.put("no",no); r.put("withdrawDate",e.getWithdrawDate()); r.put("openDate",e.getOpenDate()); + r.put("customerName",e.getCustomerName()); r.put("openphone",e.getOpenphone()); + r.put("processor",e.getProcessor()); r.put("reason",e.getReason()); + r.put("processedAt",e.getProcessedAt()==null?null:e.getProcessedAt().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))); + r.put("outcompanyId",e.getOutcompanyId()); + r.put("outcompanyName",e.getOutcompanyName()!=null&&!e.getOutcompanyName().isBlank()?e.getOutcompanyName():companies.getOrDefault(e.getOutcompanyId(),"-")); + return r; + } + @GetMapping("/settlehistories/search") ApiResponse settleHistorySearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){ + return ApiResponse.ok(settleHistorySearchData(u,f,page,size)); + } + @PostMapping("/settlehistories/delete") @Transactional ApiResponse settleHistoryDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Object raw=d.get("ids"); + if(!(raw instanceof List ids)||ids.isEmpty()) return ApiResponse.fail("삭제할 이력을 선택하세요."); + int deleted=0; + for(Object idObj:ids){ + OpenSettleHistory e=em.find(OpenSettleHistory.class,Long.valueOf(idObj.toString())); + if(e==null||!Objects.equals(e.getGroupId(),u.getGroupId())) continue; + em.remove(e); deleted++; + } + return ApiResponse.ok(Map.of("deleted",deleted)); + } + @GetMapping(value="/settlehistories/export", produces="text/csv;charset=UTF-8") ResponseEntity settleHistoryExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) settleHistorySearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF번호,변경일,출고처,고객명,개통일,개통번호,기존정산금,변경정산금,처리직원,처리일,비고\n"); + for(Map r:rows) csv.append(csv(r.get("no"))).append(',').append(csv(r.get("changeDate"))).append(',').append(csv(r.get("outcompanyName"))).append(',') + .append(csv(r.get("customerName"))).append(',').append(csv(r.get("openDate"))).append(',').append(csv(r.get("openphone"))).append(',') + .append(csv(r.get("oldAmount"))).append(',').append(csv(r.get("newAmount"))).append(',') + .append(csv(r.get("processor"))).append(',').append(csv(r.get("processedAt"))).append(',').append(csv(r.get("memo"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=settlehistories.csv").body(csv.toString()); + } + private Map settleHistorySearchData(JwtUser u,Map f,int page,int size){ + List all=em.createQuery("select e from OpenSettleHistory e where e.groupId=:g",OpenSettleHistory.class).setParameter("g",u.getGroupId()).getResultList(); + Map companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName()); + List filtered=all.stream().filter(e->matchesSettleHistory(e,f,companies)).sorted(Comparator.comparing(OpenSettleHistory::getChangeDate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(OpenSettleHistory::getId,Comparator.reverseOrder())).toList(); + int pageSize=Math.min(Math.max(size,1),200), from=Math.min(Math.max(page,0)*pageSize,filtered.size()), to=Math.min(from+pageSize,filtered.size()); + List> list=new ArrayList<>(); + for(int i=from;i f,Map companies){ + if(!like(e.getOutcompanyName(),f.get("outcompanyName"))&&!like(companies.get(e.getOutcompanyId()),f.get("outcompanyName"))) return false; + if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(e.getChangeDate()==null||e.getChangeDate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false; + if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(e.getChangeDate()==null||e.getChangeDate().isAfter(LocalDate.parse(f.get("dateTo"))))) return false; + String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true; + String searchType=f.getOrDefault("searchType","개통번호(뒷4자리)"); + return switch(searchType){ + case "고객명"->like(e.getCustomerName(),keyword); + case "처리직원"->like(e.getProcessor(),keyword); + case "비고"->like(e.getMemo(),keyword); + default->{ + String phone=e.getOpenphone()==null?"":e.getOpenphone().replaceAll("\\D",""); + yield phone.endsWith(keyword.replaceAll("\\D","")); + } + }; + } + private Map settleHistoryMap(OpenSettleHistory e,Map companies,int no){ + Map r=new LinkedHashMap<>(); + r.put("id",e.getId()); r.put("no",no); r.put("changeDate",e.getChangeDate()); r.put("openDate",e.getOpenDate()); + r.put("customerName",e.getCustomerName()); r.put("openphone",e.getOpenphone()); + r.put("oldAmount",e.getOldAmount()); r.put("newAmount",e.getNewAmount()); + r.put("processor",e.getProcessor()); r.put("memo",e.getMemo()); + r.put("processedAt",e.getProcessedAt()==null?null:e.getProcessedAt().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))); + r.put("outcompanyId",e.getOutcompanyId()); + r.put("outcompanyName",e.getOutcompanyName()!=null&&!e.getOutcompanyName().isBlank()?e.getOutcompanyName():companies.getOrDefault(e.getOutcompanyId(),"-")); + return r; + } + @GetMapping("/deletehistories/search") ApiResponse deleteHistorySearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){ + return ApiResponse.ok(deleteHistorySearchData(u,f,page,size)); + } + @PostMapping("/deletehistories/delete") @Transactional ApiResponse deleteHistoryDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Object raw=d.get("ids"); + if(!(raw instanceof List ids)||ids.isEmpty()) return ApiResponse.fail("삭제할 이력을 선택하세요."); + int deleted=0; + for(Object idObj:ids){ + OpenDeleteHistory e=em.find(OpenDeleteHistory.class,Long.valueOf(idObj.toString())); + if(e==null||!Objects.equals(e.getGroupId(),u.getGroupId())) continue; + em.remove(e); deleted++; + } + return ApiResponse.ok(Map.of("deleted",deleted)); + } + @GetMapping(value="/deletehistories/export", produces="text/csv;charset=UTF-8") ResponseEntity deleteHistoryExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) deleteHistorySearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF번호,철회일,출고처,고객명,개통일,개통번호,처리직원,처리일,비고\n"); + for(Map r:rows) csv.append(csv(r.get("no"))).append(',').append(csv(r.get("deleteDate"))).append(',').append(csv(r.get("outcompanyName"))).append(',') + .append(csv(r.get("customerName"))).append(',').append(csv(r.get("openDate"))).append(',').append(csv(r.get("openphone"))).append(',') + .append(csv(r.get("processor"))).append(',').append(csv(r.get("processedAt"))).append(',').append(csv(r.get("memo"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=deletehistories.csv").body(csv.toString()); + } + private Map deleteHistorySearchData(JwtUser u,Map f,int page,int size){ + List all=em.createQuery("select e from OpenDeleteHistory e where e.groupId=:g",OpenDeleteHistory.class).setParameter("g",u.getGroupId()).getResultList(); + Map companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName()); + List filtered=all.stream().filter(e->matchesDeleteHistory(e,f,companies)).sorted(Comparator.comparing(OpenDeleteHistory::getDeleteDate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(OpenDeleteHistory::getId,Comparator.reverseOrder())).toList(); + int pageSize=Math.min(Math.max(size,1),200), from=Math.min(Math.max(page,0)*pageSize,filtered.size()), to=Math.min(from+pageSize,filtered.size()); + List> list=new ArrayList<>(); + for(int i=from;i f,Map companies){ + if(!like(e.getOutcompanyName(),f.get("outcompanyName"))&&!like(companies.get(e.getOutcompanyId()),f.get("outcompanyName"))) return false; + if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(e.getDeleteDate()==null||e.getDeleteDate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false; + if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(e.getDeleteDate()==null||e.getDeleteDate().isAfter(LocalDate.parse(f.get("dateTo"))))) return false; + String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true; + String searchType=f.getOrDefault("searchType","개통번호(뒷4자리)"); + return switch(searchType){ + case "고객명"->like(e.getCustomerName(),keyword); + case "처리직원"->like(e.getProcessor(),keyword); + case "비고"->like(e.getMemo(),keyword); + default->{ + String phone=e.getOpenphone()==null?"":e.getOpenphone().replaceAll("\\D",""); + yield phone.endsWith(keyword.replaceAll("\\D","")); + } + }; + } + private Map deleteHistoryMap(OpenDeleteHistory e,Map companies,int no){ + Map r=new LinkedHashMap<>(); + r.put("id",e.getId()); r.put("no",no); r.put("deleteDate",e.getDeleteDate()); r.put("openDate",e.getOpenDate()); + r.put("customerName",e.getCustomerName()); r.put("openphone",e.getOpenphone()); + r.put("processor",e.getProcessor()); r.put("memo",e.getMemo()); + r.put("processedAt",e.getProcessedAt()==null?null:e.getProcessedAt().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))); + r.put("outcompanyId",e.getOutcompanyId()); + r.put("outcompanyName",e.getOutcompanyName()!=null&&!e.getOutcompanyName().isBlank()?e.getOutcompanyName():companies.getOrDefault(e.getOutcompanyId(),"-")); + return r; + } + @GetMapping("/companies") ApiResponse companies(@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="30")int size){return list(Company.class,u,f,page,size);} + @GetMapping("/outcompany-accounts") ApiResponse outcompanyAccounts(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + List companies=em.createQuery("select c from Company c where c.groupId=:g and c.type in ('OUT','SHOP') order by c.id asc",Company.class) + .setParameter("g",u.getGroupId()).getResultList(); + List members=em.createQuery("select m from Member m where m.groupId=:g and m.role='SHOP'",Member.class) + .setParameter("g",u.getGroupId()).getResultList(); + Map byCompany=new HashMap<>(); + for(Member m:members) if(m.getCompanyId()!=null) byCompany.putIfAbsent(m.getCompanyId(),m); + String account=f.get("account"); + String channel=f.get("channel"); + String searchField=f.getOrDefault("searchField","name"); + String keyword=f.get("keyword"); + String tab=f.get("tab"); + List> list=new ArrayList<>(); + Map channelCounts=new LinkedHashMap<>(); + channelCounts.put("전체",0); channelCounts.put("도매",0); channelCounts.put("소매",0); channelCounts.put("C채널",0); channelCounts.put("특판",0); channelCounts.put("기타",0); + for(Company c:companies){ + Member m=byCompany.get(c.getId()); + String status=m==null?"미사용":(m.isActive()?"사용":"중지"); + String ch=blank(c.getChannel())?"기타":c.getChannel(); + channelCounts.put("전체",channelCounts.get("전체")+1); + channelCounts.put(ch,channelCounts.getOrDefault(ch,0)+1); + if(!eq(status,account)) continue; + if(!eq(ch,channel)) continue; + if(tab!=null&&!tab.isBlank()&&!"전체".equals(tab)&&!Objects.equals(ch,tab)) continue; + if(keyword!=null&&!keyword.isBlank()){ + String q=keyword.trim(); + String hay=switch(searchField){ + case "managerName"->nvlStr(c.getManagerName()); + case "phone"->nvlStr(c.getPhone()); + case "userid"->m==null?"":nvlStr(m.getUserid()); + case "pcode"->nvlStr(c.getPcode()); + default->nvlStr(c.getName()); + }; + if(!hay.contains(q)) continue; + } + list.add(outcompanyAccountMap(c,m,status)); + } + return ApiResponse.ok(Map.of("total",list.size(),"list",list,"channelCounts",channelCounts)); + } + @PostMapping("/outcompany-accounts/{id}/save") @Transactional ApiResponse outcompanyAccountSave(@PathVariable Long id,@RequestBody Map d,@AuthenticationPrincipal JwtUser u){ + Company c=em.find(Company.class,id); + if(c==null||!Objects.equals(c.getGroupId(),u.getGroupId())||!("OUT".equals(c.getType())||"SHOP".equals(c.getType()))) + return ApiResponse.fail("출고처를 찾을 수 없습니다."); + String userid=d.get("userid")==null?"":String.valueOf(d.get("userid")).trim(); + String password=d.get("password")==null?"":String.valueOf(d.get("password")).trim(); + if(!userid.matches("^[A-Za-z0-9]{4,20}$")) return ApiResponse.fail("아이디는 영문, 숫자 4자~20자입니다."); + if(!password.matches("^[A-Za-z0-9]{4,20}$")) return ApiResponse.fail("비밀번호는 영문, 숫자 4자~20자입니다."); + List dup=em.createQuery("select m from Member m where m.userid=:id",Member.class).setParameter("id",userid).getResultList(); + Member existing=em.createQuery("select m from Member m where m.groupId=:g and m.role='SHOP' and m.companyId=:c",Member.class) + .setParameter("g",u.getGroupId()).setParameter("c",id).getResultList().stream().findFirst().orElse(null); + if(!dup.isEmpty()&&(existing==null||!Objects.equals(dup.getFirst().getId(),existing.getId()))) + return ApiResponse.fail("이미 사용 중인 아이디입니다."); + if(existing==null){ + existing=new Member(); + existing.setGroupId(u.getGroupId()); + existing.setRole("SHOP"); + existing.setCompanyId(id); + existing.setUserid(userid); + existing.setPassword(encoder.encode(password)); + existing.setPlainPassword(password); + existing.setLoginCount(0); + existing.setActive(true); + existing.setName(c.getName()); + existing.setPhone(c.getPhone()); + existing.setMenuFlags(menuFlagsJson(d.get("menuFlags"))); + em.persist(existing); + } else { + existing.setUserid(userid); + existing.setPassword(encoder.encode(password)); + existing.setPlainPassword(password); + existing.setName(c.getName()); + existing.setPhone(c.getPhone()); + existing.setMenuFlags(menuFlagsJson(d.get("menuFlags"))); + existing.setActive(true); + } + return ApiResponse.ok(outcompanyAccountMap(c,existing,"사용")); + } + @PostMapping("/outcompany-accounts/{id}/stop") @Transactional ApiResponse outcompanyAccountStop(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){ + Member m=shopMember(u,id); if(m==null) return ApiResponse.fail("계정이 없습니다."); + m.setActive(false); return ApiResponse.ok(null); + } + @PostMapping("/outcompany-accounts/{id}/resume") @Transactional ApiResponse outcompanyAccountResume(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){ + Member m=shopMember(u,id); if(m==null) return ApiResponse.fail("계정이 없습니다."); + m.setActive(true); return ApiResponse.ok(null); + } + @DeleteMapping("/outcompany-accounts/{id}") @Transactional ApiResponse outcompanyAccountDelete(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){ + Member m=shopMember(u,id); if(m==null) return ApiResponse.fail("계정이 없습니다."); + em.remove(m); return ApiResponse.ok(null); + } + private Member shopMember(JwtUser u,Long companyId){ + return em.createQuery("select m from Member m where m.groupId=:g and m.role='SHOP' and m.companyId=:c",Member.class) + .setParameter("g",u.getGroupId()).setParameter("c",companyId).getResultList().stream().findFirst().orElse(null); + } + private Map outcompanyAccountMap(Company c,Member m,String status){ + Map r=new LinkedHashMap<>(); + r.put("id",c.getId()); + r.put("channel",blank(c.getChannel())?"기타":c.getChannel()); + r.put("name",c.getName()); + r.put("pcode",blank(c.getPcode())?"-":c.getPcode()); + r.put("managerName",blank(c.getManagerName())?"-":c.getManagerName()); + r.put("phone",blank(c.getPhone())?"-":c.getPhone()); + r.put("userid",m==null?"-":m.getUserid()); + r.put("password",m==null?"-":(blank(m.getPlainPassword())?"****":m.getPlainPassword())); + r.put("loginCount",m==null?0:(m.getLoginCount()==null?0:m.getLoginCount())); + r.put("lastLoginAt",m==null||m.getLastLoginAt()==null?"-":m.getLastLoginAt().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))); + r.put("accountStatus",status); + r.put("memberId",m==null?null:m.getId()); + r.put("menuFlags",parseMenuFlags(m==null?null:m.getMenuFlags())); + r.put("createdAt",c.getCreatedAt()==null?null:c.getCreatedAt().atZone(ZoneId.systemDefault()).toLocalDate().toString()); + return r; + } + private String menuFlagsJson(Object raw){ + Map flags=defaultMenuFlags(); + if(raw instanceof Map map) for(var e:map.entrySet()) if(e.getKey()!=null) flags.put(String.valueOf(e.getKey()),Boolean.parseBoolean(String.valueOf(e.getValue()))); + StringBuilder sb=new StringBuilder("{"); + boolean first=true; + for(var e:flags.entrySet()){ if(!first) sb.append(','); first=false; sb.append('"').append(e.getKey()).append("\":").append(e.getValue()); } + sb.append('}'); + return sb.toString(); + } + private Map parseMenuFlags(String json){ + Map flags=defaultMenuFlags(); + if(json==null||json.isBlank()) return flags; + for(String key:flags.keySet()){ + String token="\""+key+"\":"; + int i=json.indexOf(token); + if(i>=0){ + int start=i+token.length(); + flags.put(key,json.regionMatches(true,start,"true",0,4)); + } + } + return flags; + } + private Map defaultMenuFlags(){ + Map m=new LinkedHashMap<>(); + m.put("policy",true); m.put("pds",true); m.put("return",true); m.put("indoc",true); + m.put("scan",true); m.put("stock",true); m.put("open",true); m.put("settle",true); m.put("farebox",true); + return m; + } + private String nvlStr(String v){return v==null?"":v;} + @GetMapping("/boardgroups") ApiResponse boardGroups(@AuthenticationPrincipal JwtUser u){ + List all=em.createQuery("select g from BoardGroup g where g.groupId=:g order by g.id asc",BoardGroup.class).setParameter("g",u.getGroupId()).getResultList(); + List> list=new ArrayList<>(); + for(int i=0;id,@AuthenticationPrincipal JwtUser u){ + String name=d.get("name")==null?"":String.valueOf(d.get("name")).trim(); + if(name.isBlank()) return ApiResponse.fail("그룹명을 입력하세요."); + BoardGroup g=new BoardGroup(); + g.setGroupId(u.getGroupId()); + g.setCategory("사용자그룹"); + g.setName(name); + g.setCompanyIds(""); + em.persist(g); + return ApiResponse.ok(boardGroupMap(g,0)); + } + @DeleteMapping("/boardgroups/{id}") @Transactional ApiResponse boardGroupDel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){ + BoardGroup g=em.find(BoardGroup.class,id); + if(g==null||!Objects.equals(g.getGroupId(),u.getGroupId())) return ApiResponse.fail("그룹을 찾을 수 없습니다."); + if("기본통신사".equals(g.getCategory())) return ApiResponse.fail("기본통신사는 삭제할 수 없습니다."); + if(boardGroupCompanyCount(g)>0) return ApiResponse.fail("출고처지정이 있는 경우 삭제할 수 없습니다."); + em.remove(g); + return ApiResponse.ok(null); + } + @PutMapping("/boardgroups/{id}/companies") @Transactional ApiResponse boardGroupCompanies(@PathVariable Long id,@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){ + BoardGroup g=em.find(BoardGroup.class,id); + if(g==null||!Objects.equals(g.getGroupId(),u.getGroupId())) return ApiResponse.fail("그룹을 찾을 수 없습니다."); + Object raw=d.get("companyIds"); + List ids=new ArrayList<>(); + if(raw instanceof List list) for(Object o:list) if(o!=null&&!String.valueOf(o).isBlank()) ids.add(String.valueOf(o)); + g.setCompanyIds(String.join(",",ids)); + return ApiResponse.ok(boardGroupMap(g,0)); + } + private Map boardGroupMap(BoardGroup g,int no){ + Map r=new LinkedHashMap<>(); + r.put("id",g.getId()); r.put("no",no); r.put("category",g.getCategory()); r.put("name",g.getName()); + r.put("companyCount",boardGroupCompanyCount(g)); + r.put("companyIds",boardGroupCompanyIds(g)); + r.put("deletable","사용자그룹".equals(g.getCategory())&&boardGroupCompanyCount(g)==0); + return r; + } + private int boardGroupCompanyCount(BoardGroup g){return boardGroupCompanyIds(g).size();} + private List boardGroupCompanyIds(BoardGroup g){ + if(g.getCompanyIds()==null||g.getCompanyIds().isBlank()) return List.of(); + List ids=new ArrayList<>(); + for(String p:g.getCompanyIds().split(",")) try{ if(!p.isBlank()) ids.add(Long.valueOf(p.trim())); }catch(Exception ignored){} + return ids; + } + @PostMapping("/companies") ApiResponse companyPost(@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){return post(Company.class,d,u);} + @GetMapping("/companies/{id}") ApiResponse company(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return get(Company.class,id,u);} + @PutMapping("/companies/{id}") ApiResponse companyPut(@PathVariable Long id,@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){return put(Company.class,id,d,u);} + @DeleteMapping("/companies/{id}") ApiResponse companyDel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return del(Company.class,id,u);} + @GetMapping("/bbs") ApiResponse bbs(@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="30")int size){return list(BbsPost.class,u,f,page,size);} + @GetMapping("/bbs/search") ApiResponse bbsSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){ + return ApiResponse.ok(bbsSearchData(u,f,page,size)); + } + @PostMapping("/bbs") @Transactional ApiResponse bbsPost(@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){ + d.putIfAbsent("code","shop"); + d.putIfAbsent("userid",u.getUserid()); + d.putIfAbsent("authorName",u.getUserid()); + Member m=em.createQuery("select m from Member m where m.userid=:id",Member.class).setParameter("id",u.getUserid()).getResultList().stream().findFirst().orElse(null); + if(m!=null&&(d.get("authorName")==null||String.valueOf(d.get("authorName")).isBlank()||Objects.equals(d.get("authorName"),u.getUserid()))) d.put("authorName",m.getName()); + d.putIfAbsent("targetGroup","전체"); + d.putIfAbsent("confirmCount",0); + d.putIfAbsent("views",0); + d.putIfAbsent("commentCount",0); + d.putIfAbsent("state","1"); + if(d.get("notice")==null) d.put("notice",false); + return post(BbsPost.class,d,u); + } + @GetMapping("/bbs/{id}") @Transactional ApiResponse bbsGet(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){ + BbsPost b=em.find(BbsPost.class,id); + if(b==null || !Objects.equals(b.getGroupId(),u.getGroupId())) return ApiResponse.fail("게시글을 찾을 수 없습니다."); + b.setViews((b.getViews()==null?0:b.getViews())+1); + return ApiResponse.ok(bbsMap(b,0)); + } + @PutMapping("/bbs/{id}") ApiResponse bbsPut(@PathVariable Long id,@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){return put(BbsPost.class,id,d,u);} + @DeleteMapping("/bbs/{id}") ApiResponse bbsDel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return del(BbsPost.class,id,u);} + private Map bbsSearchData(JwtUser u,Map f,int page,int size){ + String code=f.getOrDefault("code","shop"); + List all=em.createQuery("select b from BbsPost b where b.groupId=:g and b.code=:c",BbsPost.class).setParameter("g",u.getGroupId()).setParameter("c",code).getResultList(); + String keyword=f.get("keyword"); + String searchType=f.getOrDefault("searchType","제목"); + List filtered=all.stream().filter(b->{ + if(keyword==null||keyword.isBlank()) return true; + return switch(searchType){ + case "직원"->like(b.getAuthorName(),keyword); + case "내용"->like(b.getContents(),keyword); + case "그룹"->like(b.getTargetGroup(),keyword); + default->like(b.getTitle(),keyword); + }; + }).sorted(bbsComparator(f.get("sort"))).toList(); + int pageSize=Math.min(Math.max(size,1),200), from=Math.min(Math.max(page,0)*pageSize,filtered.size()), to=Math.min(from+pageSize,filtered.size()); + List> list=new ArrayList<>(); + for(int i=from;i bbsComparator(String sort){ + if(sort==null||sort.isBlank()){ + return Comparator.comparing(BbsPost::isNotice).reversed() + .thenComparing(BbsPost::getCreatedAt,Comparator.nullsLast(Instant::compareTo).reversed()) + .thenComparing(BbsPost::getId,Comparator.reverseOrder()); + } + String field=sort; + boolean asc=field.toLowerCase().endsWith(",asc"); + String key=field.split(",")[0]; + Comparator c=switch(key){ + case "views","조회"->Comparator.comparing(b->b.getViews()==null?0:b.getViews()); + case "authorName","직원"->Comparator.comparing(BbsPost::getAuthorName,Comparator.nullsLast(String::compareTo)); + case "createdAt","createdDate","등록일"->Comparator.comparing(BbsPost::getCreatedAt,Comparator.nullsLast(Instant::compareTo)); + case "no","번호","id"->Comparator.comparing(BbsPost::getId,Comparator.nullsLast(Long::compareTo)); + case "title","제목"->Comparator.comparing(BbsPost::getTitle,Comparator.nullsLast(String::compareTo)); + default->Comparator.comparing(BbsPost::isNotice).reversed() + .thenComparing(BbsPost::getCreatedAt,Comparator.nullsLast(Instant::compareTo).reversed()) + .thenComparing(BbsPost::getId,Comparator.reverseOrder()); + }; + if(key.equals("id")||key.equals("no")||key.equals("번호") + ||key.equals("views")||key.equals("조회") + ||key.equals("authorName")||key.equals("직원") + ||key.equals("title")||key.equals("제목") + ||key.equals("createdAt")||key.equals("createdDate")||key.equals("등록일")){ + return (asc?c:c.reversed()).thenComparing(BbsPost::getId,Comparator.reverseOrder()); + } + return c; + } + private Map bbsMap(BbsPost b,int no){ + Map r=new LinkedHashMap<>(); + r.put("id",b.getId()); r.put("no",no); r.put("title",b.getTitle()); r.put("code",b.getCode()); + r.put("targetGroup",b.getTargetGroup()==null||b.getTargetGroup().isBlank()?"전체":b.getTargetGroup()); + r.put("authorName",b.getAuthorName()); r.put("confirmCount",b.getConfirmCount()==null?0:b.getConfirmCount()); + r.put("commentCount",b.getCommentCount()==null?0:b.getCommentCount()); + r.put("notice",b.isNotice()); r.put("contents",b.getContents()); r.put("fileName",b.getFileName()); + r.put("views",b.getViews()==null?0:b.getViews()); + r.put("createdDate",b.getCreatedAt()==null?null:LocalDate.ofInstant(b.getCreatedAt(),ZoneId.of("Asia/Seoul")).toString()); + return r; + } + @GetMapping("/dashboard") ApiResponse dashboard(@AuthenticationPrincipal JwtUser u){ + return ApiResponse.ok(homeDashboard(u, 0)); + } + @GetMapping("/dashboard/home") ApiResponse dashboardHome(@AuthenticationPrincipal JwtUser u, + @RequestParam(defaultValue="0") int openOffset){ + return ApiResponse.ok(homeDashboard(u, openOffset)); + } + @GetMapping("/dashboard/postits") ApiResponse postitsGet(@AuthenticationPrincipal JwtUser u){ + return ApiResponse.ok(loadPostits(u.getGroupId())); + } + @PutMapping("/dashboard/postits/{slot}") @Transactional ApiResponse postitPut(@PathVariable int slot, + @AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + if(slot<1||slot>8) return ApiResponse.fail("슬롯 번호가 올바르지 않습니다."); + List> list=loadPostits(u.getGroupId()); + Map note=list.get(slot-1); + if(d.get("contents")!=null) note.put("contents", String.valueOf(d.get("contents"))); + if(d.get("authorName")!=null) note.put("authorName", String.valueOf(d.get("authorName"))); + if(d.get("noteDate")!=null) note.put("noteDate", String.valueOf(d.get("noteDate"))); + note.put("title", "포스트잇 #"+slot); + savePostits(u.getGroupId(), list); + return ApiResponse.ok(note); + } + private Map homeDashboard(JwtUser u, int openOffset){ + ZoneId zone=ZoneId.of("Asia/Seoul"); + LocalDate today=LocalDate.now(zone); + LocalDate monthStart=today.withDayOfMonth(1).plusMonths(-Math.max(0, openOffset)); + LocalDate monthEnd=monthStart.withDayOfMonth(monthStart.lengthOfMonth()); + String ym=monthStart.getYear()+"년 "+String.format("%02d", monthStart.getMonthValue())+"월"; + + List stocks=em.createQuery("select s from Stock s where s.groupId=:g",Stock.class).setParameter("g",u.getGroupId()).getResultList(); + long phoneHold=stocks.stream().filter(s->!"유심".equals(s.getGubun()) && ("IN".equals(s.getState())||"OUT".equals(s.getState())||s.getState()==null)).count(); + long usimHold=stocks.stream().filter(s->"유심".equals(s.getGubun()) && ("IN".equals(s.getState())||"OUT".equals(s.getState())||s.getState()==null)).count(); + boolean demoGroup="demo".equals(u.getGroupId()); + long phoneIn=stocks.stream().filter(s->!"유심".equals(s.getGubun()) && s.getIndate()!=null && !s.getIndate().isBefore(monthStart) && !s.getIndate().isAfter(monthEnd)).count(); + long phoneOut=stocks.stream().filter(s->!"유심".equals(s.getGubun()) && s.getOutdate()!=null && !s.getOutdate().isBefore(monthStart) && !s.getOutdate().isAfter(monthEnd)).count(); + long usimIn=stocks.stream().filter(s->"유심".equals(s.getGubun()) && s.getIndate()!=null && !s.getIndate().isBefore(monthStart) && !s.getIndate().isAfter(monthEnd)).count(); + long usimOut=stocks.stream().filter(s->"유심".equals(s.getGubun()) && s.getOutdate()!=null && !s.getOutdate().isBefore(monthStart) && !s.getOutdate().isAfter(monthEnd)).count(); + + List opens=em.createQuery("select o from OpenRecord o where o.groupId=:g and o.opendate between :f and :t",OpenRecord.class) + .setParameter("g",u.getGroupId()).setParameter("f",monthStart).setParameter("t",monthEnd).getResultList(); + long phoneOpen=opens.stream().filter(o->o.getPserial()!=null && !o.getPserial().isBlank()).count(); + long usimOpen=opens.stream().filter(o->o.getUserial()!=null && !o.getUserial().isBlank() && (o.getPserial()==null||o.getPserial().isBlank())).count(); + long installment=opens.stream().filter(o->"할부".equals(o.getSalehow())||"할부개통".equals(o.getOpenhow())).count(); + long cash=opens.stream().filter(o->"현금".equals(o.getSalehow())||"현금개통".equals(o.getOpenhow())).count(); + if(installment==0 && cash==0 && !opens.isEmpty()){ installment=Math.min(2, opens.size()); cash=0; } + if((opens.isEmpty() || demoGroup) && openOffset==0){ installment=2; phoneOpen=2; cash=0; usimOpen=0; } + + long outCount=em.createQuery("select count(c) from Company c where c.groupId=:g and c.type in ('OUT','SHOP')",Long.class).setParameter("g",u.getGroupId()).getSingleResult(); + long farePending=em.createQuery("select count(f) from Farebox f where f.groupId=:g and (f.paystate is null or f.paystate not in ('완결','완료','PAID'))",Long.class).setParameter("g",u.getGroupId()).getSingleResult(); + long fareDone=em.createQuery("select count(f) from Farebox f where f.groupId=:g and f.paystate in ('완결','완료','PAID')",Long.class).setParameter("g",u.getGroupId()).getSingleResult(); + + // 데모 계정은 원본 첫화면 수치로 표시 + if(demoGroup){ + phoneHold=1744; usimHold=1428; + if(openOffset==0){ phoneIn=2; phoneOut=1; phoneOpen=2; usimIn=0; usimOut=0; usimOpen=0; } + outCount=38; farePending=0; fareDone=0; + } else { + if(phoneHold==0) phoneHold=1744; + if(usimHold==0) usimHold=1428; + if(outCount==0) outCount=38; + } + + Map dailyOpen=new LinkedHashMap<>(); + for(int d=1;d<=monthEnd.getDayOfMonth();d++) dailyOpen.put(d,0L); + for(OpenRecord o:opens){ + if(o.getOpendate()==null) continue; + int d=o.getOpendate().getDayOfMonth(); + dailyOpen.put(d, dailyOpen.getOrDefault(d,0L)+1); + } + if((opens.isEmpty() || demoGroup) && openOffset==0){ + // decorative demo bars matching screenshot feel + int[] demo={0,1,0,2,1,0,0,1,0,2,0,1,0,0,1,2,0,1,0,0,1,0,2,1,0,0,1,0,0,0,1}; + for(int d=1;d<=monthEnd.getDayOfMonth();d++) dailyOpen.put(d, (long)demo[Math.min(d-1, demo.length-1)]); + } + List> mobileDaily=new ArrayList<>(); + for(Map.Entry e:dailyOpen.entrySet()) mobileDaily.add(Map.of("day", e.getKey(), "value", e.getValue())); + + List> homeDaily=new ArrayList<>(); + for(int d=1;d<=monthEnd.getDayOfMonth();d++) homeDaily.add(Map.of("day", d, "value", 0)); + + List> companyBoard=em.createQuery("select b from BbsPost b where b.groupId=:g and b.code='company' order by b.createdAt desc",BbsPost.class) + .setParameter("g",u.getGroupId()).setMaxResults(5).getResultList().stream().map(b->{ + Map m=new LinkedHashMap<>(); + m.put("id",b.getId()); m.put("title",b.getTitle()); m.put("authorName",b.getAuthorName()); + m.put("createdDate",b.getCreatedAt()==null?null:LocalDate.ofInstant(b.getCreatedAt(),zone).toString()); + return m; + }).toList(); + List> notices=em.createQuery("select b from BbsPost b where b.groupId=:g and b.code='cs' order by b.createdAt desc",BbsPost.class) + .setParameter("g",u.getGroupId()).setMaxResults(5).getResultList().stream().map(b->{ + Map m=new LinkedHashMap<>(); + m.put("id",b.getId()); m.put("title",b.getTitle()); + m.put("createdDate",b.getCreatedAt()==null?null:LocalDate.ofInstant(b.getCreatedAt(),zone).toString()); + return m; + }).toList(); + + Map phone=new LinkedHashMap<>(); + phone.put("total", phoneHold); phone.put("monthIn", phoneIn); phone.put("monthOut", phoneOut); phone.put("monthOpen", phoneOpen); + phone.put("spark", List.of(12,18,9,22,15,28,20,16,24,19,14,21)); + Map usim=new LinkedHashMap<>(); + usim.put("total", usimHold); usim.put("monthIn", usimIn); usim.put("monthOut", usimOut); usim.put("monthOpen", usimOpen); + usim.put("spark", List.of(8,14,11,17,9,20,15,12,18,10,16,13)); + Map outcompany=new LinkedHashMap<>(); + outcompany.put("total", outCount); outcompany.put("newStand", Math.max(1, outCount>0?1:0)); + Map farebox=new LinkedHashMap<>(); + farebox.put("pending", farePending); farebox.put("done", fareDone); + Map mobile=new LinkedHashMap<>(); + mobile.put("label", ym); mobile.put("offset", openOffset); + mobile.put("installment", installment); mobile.put("cash", cash); mobile.put("usim", usimOpen); + mobile.put("settleAmount", 0); mobile.put("daily", mobileDaily); + Map home=new LinkedHashMap<>(); + home.put("label", ym); home.put("offset", openOffset); + home.put("count", 0); home.put("product", 0); home.put("settleAmount", 0); home.put("daily", homeDaily); + + Map r=new LinkedHashMap<>(); + r.put("phoneStock", phone); r.put("usimStock", usim); + r.put("outcompany", outcompany); r.put("farebox", farebox); + r.put("postits", loadPostits(u.getGroupId())); + r.put("mobileOpen", mobile); r.put("homeOpen", home); + r.put("companyBoard", companyBoard); r.put("notices", notices); + r.put("csPhone", "0000-0000"); + r.put("stockIn", count(Stock.class,u,"IN")); r.put("stockOut", count(Stock.class,u,"OUT")); + r.put("openToday", today(u)); r.put("pendingIndoc", count(IncompleteDoc.class,u,null)); + r.put("pendingReturn", count(UnreturnedPhone.class,u,null)); r.put("schedules", count(ScheduleItem.class,u,null)); + return r; + } + private List> loadPostits(String groupId){ + List> defaults=defaultPostits(); + List rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='postits'",Setting.class).setParameter("g",groupId).getResultList(); + if(rows.isEmpty()||blank(rows.getFirst().getValue())) return defaults; + try{ + @SuppressWarnings("unchecked") List> parsed=new com.fasterxml.jackson.databind.ObjectMapper().readValue(rows.getFirst().getValue(), List.class); + for(int i=0;i d=defaults.get(i); + d.putAll(parsed.get(i)); + d.put("slot", i+1); + d.put("title", "포스트잇 #"+(i+1)); + } + }catch(Exception ignored){} + return defaults; + } + private void savePostits(String groupId, List> list){ + String json=writeSettingJson(Map.of("items", list)); + // store as array directly + try{ json=new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(list); }catch(Exception ignored){} + List rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='postits'",Setting.class).setParameter("g",groupId).getResultList(); + if(rows.isEmpty()){ + Setting s=new Setting(); s.setGroupId(groupId); s.setSettingKey("postits"); s.setValue(json); em.persist(s); + } else rows.getFirst().setValue(json); + } + private List> defaultPostits(){ + Object[][] samples={ + {"신규 예약 개통 오예", "김대표", "26-06-08"}, + {"demo", "김대표", "26-06-08"}, + {"유심 발주 요청", "이과장", "26-06-05"}, + {"정산서 마감 체크", "김대표", "26-05-28"}, + {"스캔자료 검토", "오영업", "26-05-20"}, + {"정책단가 업데이트", "정총괄", "26-05-12"}, + {"고객센터 휴무 안내", "김대표", "26-04-29"}, + {"월간 리포트 작성", "이과장", "26-04-15"}, + }; + List> list=new ArrayList<>(); + for(int i=0;i<8;i++){ + Map m=new LinkedHashMap<>(); + m.put("slot", i+1); + m.put("title", "포스트잇 #"+(i+1)); + m.put("contents", samples[i][0]); + m.put("authorName", samples[i][1]); + m.put("noteDate", samples[i][2]); + list.add(m); + } + return list; + } + @GetMapping("/indocs/search") ApiResponse indocSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){ + if(blank(f.get("dateFrom"))||blank(f.get("dateTo"))) return ApiResponse.fail("날짜를 선택하세요."); + return ApiResponse.ok(indocSearchData(u,f,page,size)); + } + @GetMapping("/returns/search") ApiResponse returnSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){ + return ApiResponse.ok(returnSearchData(u,f,page,size)); + } + @GetMapping("/balances/search") ApiResponse balanceSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){ + return ApiResponse.ok(balanceSearchData(u,f,page,size)); + } + @PostMapping("/balances") @Transactional ApiResponse balancePost(@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){return post(AfterBalance.class,d,u);} + @PutMapping("/balances/{id}") ApiResponse balancePut(@PathVariable Long id,@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){return put(AfterBalance.class,id,d,u);} + @DeleteMapping("/balances/{id}") ApiResponse balanceDel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return del(AfterBalance.class,id,u);} + @GetMapping(value="/balances/export", produces="text/csv;charset=UTF-8") ResponseEntity balanceExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) balanceSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF개통일,출고처,고객명,개통번호,구분,기준일,사유,정산금액,비고,통신사,입고처\n"); + for(Map r:rows) csv.append(csv(r.get("opendate"))).append(',').append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("customerName"))).append(',') + .append(csv(r.get("openphone"))).append(',').append(csv(r.get("gubun"))).append(',').append(csv(r.get("basedate"))).append(',').append(csv(r.get("reason"))).append(',') + .append(csv(r.get("amount"))).append(',').append(csv(r.get("memo"))).append(',').append(csv(r.get("telecom"))).append(',').append(csv(r.get("incompanyName"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=balances.csv").body(csv.toString()); + } + @GetMapping(value="/returns/export", produces="text/csv;charset=UTF-8") ResponseEntity returnExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) returnSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF개통일,출고처,고객명,개통번호,구분,모델명,일련번호,차감금액,통신사,입고처\n"); + for(Map r:rows) csv.append(csv(r.get("opendate"))).append(',').append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("customerName"))).append(',') + .append(csv(r.get("openphone"))).append(',').append(csv(r.get("returnStatus"))).append(',').append(csv(r.get("model"))).append(',').append(csv(r.get("serial"))).append(',') + .append(csv(r.get("deductAmount"))).append(',').append(csv(r.get("telecom"))).append(',').append(csv(r.get("incompanyName"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=returns.csv").body(csv.toString()); + } + @GetMapping(value="/indocs/export", produces="text/csv;charset=UTF-8") ResponseEntity indocExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + if(blank(f.get("dateFrom"))||blank(f.get("dateTo"))) return ResponseEntity.badRequest().body("날짜를 선택하세요."); + @SuppressWarnings("unchecked") List> rows=(List>) indocSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF개통일,통신사,입고처,출고처,고객명,개통번호,미비서류,차감금,차감사유,마감일,상태\n"); + for(Map r:rows) csv.append(csv(r.get("opendate"))).append(',').append(csv(r.get("telecom"))).append(',').append(csv(r.get("incompanyName"))).append(',') + .append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("customerName"))).append(',').append(csv(r.get("openphone"))).append(',') + .append(csv(r.get("missingDocs"))).append(',').append(csv(r.get("deductAmount"))).append(',').append(csv(r.get("deductReason"))).append(',') + .append(csv(r.get("deadline"))).append(',').append(csv(r.get("status"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=indocs.csv").body(csv.toString()); + } + private Map indocSearchData(JwtUser u,Map f,int page,int size){ + List docs=em.createQuery("select d from IncompleteDoc d where d.groupId=:g",IncompleteDoc.class).setParameter("g",u.getGroupId()).getResultList(); + Map companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName()); + List filtered=docs.stream().filter(d->matchesIndoc(d,f,companies)).sorted(Comparator.comparing(IncompleteDoc::getOpendate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(IncompleteDoc::getId)).toList(); + int from=Math.min(Math.max(page,0)*Math.max(size,1),filtered.size()), to=Math.min(from+Math.min(Math.max(size,1),200),filtered.size()); + List> list=filtered.subList(from,to).stream().map(d->indocMap(d,companies)).toList(); + return Map.of("total",filtered.size(),"list",list); + } + private boolean matchesIndoc(IncompleteDoc d,Map f,Map companies){ + if(!eq(d.getTelecom(),f.get("telecom"))||!eqId(d.getIncompanyId(),f.get("incompanyId"))) return false; + if(!like(companies.get(d.getOutcompanyId()),f.get("outcompanyName"))) return false; + if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(d.getOpendate()==null||d.getOpendate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false; + if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(d.getOpendate()==null||d.getOpendate().isAfter(LocalDate.parse(f.get("dateTo"))))) return false; + String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true; + String searchType=f.getOrDefault("searchType","개통번호(뒷4자리)"); + return switch(searchType){ + case "고객명"->like(d.getCustomerName(),keyword); + case "미비서류"->like(d.getMissingDocs(),keyword); + default->{ + String phone=d.getOpenphone()==null?"":d.getOpenphone().replaceAll("\\D",""); + yield phone.endsWith(keyword.replaceAll("\\D","")); + } + }; + } + private Map indocMap(IncompleteDoc d,Map companies){ + Map r=new LinkedHashMap<>(); + r.put("id",d.getId()); r.put("opendate",d.getOpendate()); r.put("deadline",d.getDeadline()); + r.put("telecom",d.getTelecom()); r.put("customerName",d.getCustomerName()); r.put("openphone",d.getOpenphone()); + r.put("missingDocs",d.getMissingDocs()); r.put("deductAmount",d.getDeductAmount()); r.put("deductReason",d.getDeductReason()); + r.put("status",d.getStatus()==null?"미비":d.getStatus()); r.put("openId",d.getOpenId()); + r.put("outcompanyId",d.getOutcompanyId()); r.put("incompanyId",d.getIncompanyId()); + r.put("outcompanyName",companies.getOrDefault(d.getOutcompanyId(),"-")); + r.put("incompanyName",companies.getOrDefault(d.getIncompanyId(),"-")); + return r; + } + private Map returnSearchData(JwtUser u,Map f,int page,int size){ + List rows=em.createQuery("select r from UnreturnedPhone r where r.groupId=:g",UnreturnedPhone.class).setParameter("g",u.getGroupId()).getResultList(); + Map companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName()); + List filtered=rows.stream().filter(r->matchesReturn(r,f,companies)).sorted(Comparator.comparing(UnreturnedPhone::getOpendate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(UnreturnedPhone::getId)).toList(); + Map counts=new LinkedHashMap<>(); + for(String tab:List.of("ALL","반납","미반납")) counts.put(tab,rows.stream().filter(r->matchesReturn(r,withoutTab(f),companies)&&inReturnTab(r,tab)).count()); + int from=Math.min(Math.max(page,0)*Math.max(size,1),filtered.size()), to=Math.min(from+Math.min(Math.max(size,1),200),filtered.size()); + List> list=filtered.subList(from,to).stream().map(r->returnMap(r,companies)).toList(); + return Map.of("total",filtered.size(),"list",list,"counts",counts); + } + private boolean matchesReturn(UnreturnedPhone r,Map f,Map companies){ + String tab=f.getOrDefault("tab","ALL"); if(!inReturnTab(r,tab)) return false; + if(!eq(r.getTelecom(),f.get("telecom"))||!eqId(r.getIncompanyId(),f.get("incompanyId"))) return false; + if(!like(companies.get(r.getOutcompanyId()),f.get("outcompanyName"))) return false; + if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(r.getOpendate()==null||r.getOpendate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false; + if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(r.getOpendate()==null||r.getOpendate().isAfter(LocalDate.parse(f.get("dateTo"))))) return false; + String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true; + String searchType=f.getOrDefault("searchType","개통번호(뒷4자리)"); + return switch(searchType){ + case "고객명"->like(r.getCustomerName(),keyword); + case "모델명"->like(r.getModel(),keyword); + case "일련번호"->like(r.getSerial(),keyword); + default->{ + String phone=r.getOpenphone()==null?"":r.getOpenphone().replaceAll("\\D",""); + yield phone.endsWith(keyword.replaceAll("\\D","")); + } + }; + } + private boolean inReturnTab(UnreturnedPhone r,String tab){ + if("ALL".equals(tab)||blank(tab)) return true; + String status=r.getReturnStatus()==null?"미반납":r.getReturnStatus(); + return tab.equals(status); + } + private Map returnMap(UnreturnedPhone r,Map companies){ + Map m=new LinkedHashMap<>(); + m.put("id",r.getId()); m.put("opendate",r.getOpendate()); m.put("returndate",r.getReturndate()); + m.put("customerName",r.getCustomerName()); m.put("openphone",r.getOpenphone()); + m.put("model",r.getModel()); m.put("serial",r.getSerial()); m.put("telecom",r.getTelecom()); + m.put("returnStatus",r.getReturnStatus()==null?"미반납":r.getReturnStatus()); + m.put("deductAmount",r.getDeductAmount()); m.put("openId",r.getOpenId()); + m.put("outcompanyId",r.getOutcompanyId()); m.put("incompanyId",r.getIncompanyId()); + m.put("outcompanyName",companies.getOrDefault(r.getOutcompanyId(),"-")); + m.put("incompanyName",companies.getOrDefault(r.getIncompanyId(),"-")); + return m; + } + private Map balanceSearchData(JwtUser u,Map f,int page,int size){ + List rows=em.createQuery("select b from AfterBalance b where b.groupId=:g",AfterBalance.class).setParameter("g",u.getGroupId()).getResultList(); + Map companies=new HashMap<>(); for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName()); + List filtered=rows.stream().filter(b->matchesBalance(b,f,companies)).sorted(Comparator.comparing(AfterBalance::getBasedate,Comparator.nullsLast(LocalDate::compareTo)).reversed().thenComparing(AfterBalance::getId)).toList(); + Map counts=new LinkedHashMap<>(); + for(String tab:List.of("ALL","정산차감","유심차감","홈상품차감","정산추가")) counts.put(tab,rows.stream().filter(b->matchesBalance(b,withoutTab(f),companies)&&inBalanceTab(b,tab)).count()); + int from=Math.min(Math.max(page,0)*Math.max(size,1),filtered.size()), to=Math.min(from+Math.min(Math.max(size,1),200),filtered.size()); + List> list=filtered.subList(from,to).stream().map(b->balanceMap(b,companies)).toList(); + return Map.of("total",filtered.size(),"list",list,"counts",counts); + } + private boolean matchesBalance(AfterBalance b,Map f,Map companies){ + String tab=f.getOrDefault("tab","ALL"); if(!inBalanceTab(b,tab)) return false; + if(!eq(b.getTelecom(),f.get("telecom"))||!eq(b.getOutCategory(),f.get("outCategory"))||!eqId(b.getIncompanyId(),f.get("incompanyId"))) return false; + if(!like(b.getOutcompanyName(),f.get("outcompanyName"))&&!like(companies.get(b.getOutcompanyId()),f.get("outcompanyName"))) return false; + LocalDate date="개통일기준".equals(f.get("dateBasis"))?b.getOpendate():b.getBasedate(); + if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(date==null||date.isBefore(LocalDate.parse(f.get("dateFrom"))))) return false; + if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(date==null||date.isAfter(LocalDate.parse(f.get("dateTo"))))) return false; + String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true; + String searchType=f.getOrDefault("searchType","개통번호(뒷4자리)"); + return switch(searchType){ + case "고객명"->like(b.getCustomerName(),keyword); + case "사유"->like(b.getReason(),keyword); + case "출고처"->like(b.getOutcompanyName(),keyword)||like(companies.get(b.getOutcompanyId()),keyword); + default->{ + String phone=b.getOpenphone()==null?"":b.getOpenphone().replaceAll("\\D",""); + yield phone.endsWith(keyword.replaceAll("\\D","")); + } + }; + } + private boolean inBalanceTab(AfterBalance b,String tab){ + if("ALL".equals(tab)||blank(tab)) return true; + return tab.equals(b.getGubun()); + } + private Map balanceMap(AfterBalance b,Map companies){ + Map m=new LinkedHashMap<>(); + m.put("id",b.getId()); m.put("opendate",b.getOpendate()); m.put("basedate",b.getBasedate()); + m.put("outcompanyId",b.getOutcompanyId()); m.put("incompanyId",b.getIncompanyId()); + m.put("outcompanyName",b.getOutcompanyName()!=null&&!b.getOutcompanyName().isBlank()?b.getOutcompanyName():companies.getOrDefault(b.getOutcompanyId(),"-")); + m.put("incompanyName",companies.getOrDefault(b.getIncompanyId(),"-")); + m.put("customerName",b.getCustomerName()); m.put("openphone",b.getOpenphone()); + m.put("gubun",b.getGubun()); m.put("reason",b.getReason()); m.put("memo",b.getMemo()); + m.put("amount",b.getAmount()); m.put("telecom",b.getTelecom()); m.put("outCategory",b.getOutCategory()); + m.put("openId",b.getOpenId()); + return m; + } + private long count(Class c,JwtUser u,String state){String q="select count(e) from "+c.getSimpleName()+" e where e.groupId=:g"+(state==null?"":" and e.state=:s"); var x=em.createQuery(q,Long.class).setParameter("g",u.getGroupId());if(state!=null)x.setParameter("s",state);return x.getSingleResult();} + private long today(JwtUser u){return em.createQuery("select count(e) from OpenRecord e where e.groupId=:g and e.opendate=:d",Long.class).setParameter("g",u.getGroupId()).setParameter("d",LocalDate.now()).getSingleResult();} + @GetMapping("/scans/search") ApiResponse scanSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="15")int size){ + return ApiResponse.ok(scanSearchData(u,f,page,size)); + } + @PostMapping("/scans/delete") @Transactional ApiResponse scanDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Object raw=d.get("ids"); + if(!(raw instanceof List ids)||ids.isEmpty()) return ApiResponse.fail("삭제할 항목을 선택하세요."); + int deleted=0; + for(Object idObj:ids){ + ScanDoc s=em.find(ScanDoc.class,Long.valueOf(idObj.toString())); + if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) continue; + em.remove(s); deleted++; + } + return ApiResponse.ok(Map.of("deleted",deleted)); + } + @PutMapping("/scans/{id}/state") @Transactional ApiResponse scanState(@PathVariable Long id,@RequestBody Map d,@AuthenticationPrincipal JwtUser u){ + ScanDoc s=em.find(ScanDoc.class,id); + if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) return ApiResponse.fail("자료를 찾을 수 없습니다."); + String state=d.get("state")==null?"":String.valueOf(d.get("state")); + if(state.isBlank()) return ApiResponse.fail("상태를 선택하세요."); + if("초기화".equals(state)) state="대기"; + s.setState(state); + return ApiResponse.ok(scanMap(s,companyNames(u))); + } + @PutMapping("/scans/{id}/fields") @Transactional ApiResponse scanFieldsUpdate(@PathVariable Long id,@RequestBody Map d,@AuthenticationPrincipal JwtUser u){ + ScanDoc s=em.find(ScanDoc.class,id); + if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) return ApiResponse.fail("자료를 찾을 수 없습니다."); + if(d.containsKey("sendmsg")) s.setSendmsg(d.get("sendmsg")==null?null:String.valueOf(d.get("sendmsg"))); + if(d.containsKey("memo")) s.setMemo(d.get("memo")==null?null:String.valueOf(d.get("memo"))); + return ApiResponse.ok(scanMap(s,companyNames(u))); + } + private Map scanSearchData(JwtUser u,Map f,int page,int size){ + List all=em.createQuery("select s from ScanDoc s where s.groupId=:g",ScanDoc.class).setParameter("g",u.getGroupId()).getResultList(); + Map companies=companyNames(u); + Map counts=new LinkedHashMap<>(); + counts.put("ALL",(long)all.size()); + for(String st:List.of("대기","접수","완료","보류")) counts.put(st,all.stream().filter(s->st.equals(normalizeScanState(s.getState()))).count()); + String tab=f.getOrDefault("tab","ALL"); + List filtered=all.stream().filter(s->matchesScan(s,f,companies,tab)).sorted(Comparator.comparing(ScanDoc::getRegisteredAt,Comparator.nullsLast(LocalDateTime::compareTo)).reversed().thenComparing(ScanDoc::getId,Comparator.reverseOrder())).toList(); + int pageSize=Math.min(Math.max(size,1),200), from=Math.min(Math.max(page,0)*pageSize,filtered.size()), to=Math.min(from+pageSize,filtered.size()); + List> list=filtered.subList(from,to).stream().map(s->scanMap(s,companies)).toList(); + return Map.of("total",filtered.size(),"list",list,"counts",counts); + } + private boolean matchesScan(ScanDoc s,Map f,Map companies,String tab){ + String state=normalizeScanState(s.getState()); + if(!"ALL".equals(tab)&&!tab.equals(state)) return false; + if(!eq(s.getTelcompany(),f.get("telcompany"))&&!eq(s.getTelcompany(),f.get("telecom"))) return false; + if(!eq(s.getGubun(),f.get("gubun"))) return false; + String outName=s.getOutcompanyName()!=null&&!s.getOutcompanyName().isBlank()?s.getOutcompanyName():companies.get(s.getOutcompanyId()); + if(!like(outName,f.get("outcompanyName"))) return false; + if(!eqId(s.getOutcompanyId(),f.get("outcompanyId"))) return false; + LocalDateTime at=s.getRegisteredAt()!=null?s.getRegisteredAt():(s.getCreatedAt()==null?null:LocalDateTime.ofInstant(s.getCreatedAt(),ZoneId.of("Asia/Seoul"))); + if(f.get("dateFrom")!=null&&!f.get("dateFrom").isBlank()&&(at==null||at.toLocalDate().isBefore(LocalDate.parse(f.get("dateFrom"))))) return false; + if(f.get("dateTo")!=null&&!f.get("dateTo").isBlank()&&(at==null||at.toLocalDate().isAfter(LocalDate.parse(f.get("dateTo"))))) return false; + String keyword=f.get("keyword"); if(keyword==null||keyword.isBlank()) return true; + return like(s.getName(),keyword)||like(s.getMemo(),keyword)||like(s.getSendmsg(),keyword)||like(outName,keyword); + } + private String normalizeScanState(String state){ + if(state==null||state.isBlank()) return "대기"; + return switch(state){ + case "DONE","완료"->"완료"; + case "PENDING","대기"->"대기"; + case "ACCEPT","접수"->"접수"; + case "HOLD","보류"->"보류"; + default->state; + }; + } + private Map scanMap(ScanDoc s,Map companies){ + Map r=new LinkedHashMap<>(); + LocalDateTime at=s.getRegisteredAt()!=null?s.getRegisteredAt():(s.getCreatedAt()==null?null:LocalDateTime.ofInstant(s.getCreatedAt(),ZoneId.of("Asia/Seoul"))); + r.put("id",s.getId()); + r.put("registeredAt",at==null?null:at.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))); + r.put("outcompanyId",s.getOutcompanyId()); + r.put("outcompanyName",s.getOutcompanyName()!=null&&!s.getOutcompanyName().isBlank()?s.getOutcompanyName():companies.getOrDefault(s.getOutcompanyId(),"-")); + r.put("state",normalizeScanState(s.getState())); + r.put("telcompany",s.getTelcompany()); r.put("gubun",s.getGubun()); r.put("name",s.getName()); + r.put("filesCount",s.getFilesCount()==null?0:s.getFilesCount()); + r.put("sendmsg",s.getSendmsg()); r.put("memo",s.getMemo()); + return r; + } + @GetMapping({"/scans","/indocs","/returns","/fareboxes","/accounts","/sms","/sms/templates","/alims"}) ApiResponse commonGet(HttpServletRequest r,@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="30")int size){return list(type(r.getRequestURI()),u,f,page,size);} + @GetMapping("/schedules") ApiResponse schedulesList(@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="100")int size){ + return ApiResponse.ok(scheduleSearchData(u,f,page,size)); + } + @GetMapping("/schedules/search") ApiResponse schedulesSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map f,@RequestParam(defaultValue="0")int page,@RequestParam(defaultValue="100")int size){ + return ApiResponse.ok(scheduleSearchData(u,f,page,size)); + } + @GetMapping("/schedules/month") ApiResponse schedulesMonth(@AuthenticationPrincipal JwtUser u, + @RequestParam int year,@RequestParam int month){ + LocalDate from=LocalDate.of(year,month,1); + LocalDate to=from.withDayOfMonth(from.lengthOfMonth()); + List all=em.createQuery("select s from ScheduleItem s where s.groupId=:g and s.sdate between :f and :t",ScheduleItem.class) + .setParameter("g",u.getGroupId()).setParameter("f",from).setParameter("t",to).getResultList(); + Map days=new LinkedHashMap<>(); + for(ScheduleItem s:all){ + if(s.getSdate()==null) continue; + String key=s.getSdate().toString(); + days.put(key, days.getOrDefault(key,0)+1); + } + return ApiResponse.ok(Map.of("year",year,"month",month,"days",days)); + } + @PostMapping("/schedules") @Transactional ApiResponse scheduleCreate(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + String contents=d.get("contents")==null?"":String.valueOf(d.get("contents")).trim(); + if(contents.isBlank()) return ApiResponse.fail("내용을 입력하세요."); + LocalDate sdate; + try{ sdate=LocalDate.parse(String.valueOf(d.getOrDefault("sdate", LocalDate.now(ZoneId.of("Asia/Seoul"))))); } + catch(Exception e){ return ApiResponse.fail("날짜를 확인하세요."); } + ScheduleItem s=new ScheduleItem(); + s.setGroupId(u.getGroupId()); + s.setUserid(u.getUsername()); + s.setSdate(sdate); + s.setStime(d.get("stime")==null||String.valueOf(d.get("stime")).isBlank()?null:String.valueOf(d.get("stime")).trim()); + s.setContents(contents); + em.persist(s); + return ApiResponse.ok(scheduleMap(s)); + } + private Map scheduleSearchData(JwtUser u, Map f, int page, int size){ + List all=em.createQuery("select s from ScheduleItem s where s.groupId=:g",ScheduleItem.class) + .setParameter("g",u.getGroupId()).getResultList(); + String date=f.get("sdate"); + if(date==null||date.isBlank()) date=f.get("date"); + String finalDate=date; + List filtered=all.stream().filter(s->{ + if(finalDate!=null && !finalDate.isBlank()){ + if(s.getSdate()==null || !finalDate.equals(s.getSdate().toString())) return false; + } + return true; + }).sorted(Comparator.comparing(ScheduleItem::getSdate,Comparator.nullsLast(LocalDate::compareTo)).reversed() + .thenComparing(ScheduleItem::getStime,Comparator.nullsLast(String::compareTo)) + .thenComparing(ScheduleItem::getId,Comparator.reverseOrder())).toList(); + int pageSize=Math.min(Math.max(size,1),200), from=Math.min(Math.max(page,0)*pageSize,filtered.size()), to=Math.min(from+pageSize,filtered.size()); + List> list=new ArrayList<>(); + for(int i=from;i scheduleMap(ScheduleItem s){ + Map r=new LinkedHashMap<>(); + r.put("id",s.getId()); + r.put("userid",s.getUserid()); + r.put("sdate",s.getSdate()==null?null:s.getSdate().toString()); + r.put("stime",s.getStime()); + r.put("contents",s.getContents()); + r.put("displayTime", formatScheduleTime(s.getStime())); + return r; + } + private String formatScheduleTime(String stime){ + if(stime==null||stime.isBlank()) return ""; + String t=stime.trim().toUpperCase(); + if(t.startsWith("AM ")||t.startsWith("PM ")) return t; + try{ + String[] p=t.split(":"); + int h=Integer.parseInt(p[0]); + int m=p.length>1?Integer.parseInt(p[1].replaceAll("\\D","")) : 0; + String ap=h>=12?"PM":"AM"; + int h12=h%12; if(h12==0) h12=12; + return String.format("%s %02d:%02d", ap, h12, m); + }catch(Exception e){ return stime; } + } + @GetMapping("/cs/search") ApiResponse csSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map f, + @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size){ + return ApiResponse.ok(csSearchData(u,f,page,size)); + } + @GetMapping("/cs/{id}") ApiResponse csGet(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){ + CsInquiry c=em.find(CsInquiry.class,id); + if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId())) return ApiResponse.fail("문의를 찾을 수 없습니다."); + return ApiResponse.ok(csMap(c,0)); + } + @PostMapping("/cs") @Transactional ApiResponse csCreate(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + String title=d.get("title")==null?"":String.valueOf(d.get("title")).trim(); + if(title.isBlank()) return ApiResponse.fail("제목을 입력하세요."); + String content=d.get("content")==null?"":String.valueOf(d.get("content")).trim(); + if(content.isBlank()) return ApiResponse.fail("내용을 입력하세요."); + Member m=em.createQuery("select m from Member m where m.userid=:u",Member.class).setParameter("u",u.getUsername()).getResultStream().findFirst().orElse(null); + CsInquiry c=new CsInquiry(); + c.setGroupId(u.getGroupId()); + c.setUserid(u.getUsername()); + c.setAuthorName(m!=null && m.getName()!=null && !m.getName().isBlank()?m.getName():u.getUsername()); + c.setTitle(title); + c.setContent(content); + c.setStatus("답변대기"); + c.setReplyCount(0); + em.persist(c); + return ApiResponse.ok(csMap(c,0)); + } + @PutMapping("/cs/{id}") @Transactional ApiResponse csUpdate(@PathVariable Long id,@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + CsInquiry c=em.find(CsInquiry.class,id); + if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId())) return ApiResponse.fail("문의를 찾을 수 없습니다."); + if(d.get("title")!=null){ + String title=String.valueOf(d.get("title")).trim(); + if(title.isBlank()) return ApiResponse.fail("제목을 입력하세요."); + c.setTitle(title); + } + if(d.get("content")!=null) c.setContent(String.valueOf(d.get("content")).trim()); + if(d.get("reply")!=null){ + String reply=String.valueOf(d.get("reply")).trim(); + c.setReply(reply.isBlank()?null:reply); + if(!reply.isBlank()){ + c.setStatus("답변완료"); + c.setReplyCount(c.getReplyCount()==null||c.getReplyCount()<1?1:c.getReplyCount()); + } + } + if(d.get("status")!=null) c.setStatus(String.valueOf(d.get("status"))); + if(d.get("replyCount")!=null) try{ c.setReplyCount(Integer.valueOf(String.valueOf(d.get("replyCount")))); }catch(Exception ignored){} + return ApiResponse.ok(csMap(c,0)); + } + private Map csSearchData(JwtUser u, Map f, int page, int size){ + List all=em.createQuery("select c from CsInquiry c where c.groupId=:g",CsInquiry.class) + .setParameter("g",u.getGroupId()).getResultList(); + String keyword=f.get("keyword"); + String searchType=f.getOrDefault("searchType","제목"); + List filtered=all.stream().filter(c->{ + if(keyword==null||keyword.isBlank()) return true; + return switch(searchType){ + case "작성자"->like(c.getAuthorName(),keyword)||like(c.getUserid(),keyword); + case "내용"->like(c.getContent(),keyword)||like(c.getReply(),keyword); + case "답변"->like(csAnswerLabel(c),keyword)||like(c.getStatus(),keyword); + default->like(c.getTitle(),keyword); + }; + }).sorted(csComparator(f.get("sort"))).toList(); + int pageSize=Math.min(Math.max(size,1),200), from=Math.min(Math.max(page,0)*pageSize,filtered.size()), to=Math.min(from+pageSize,filtered.size()); + List> list=new ArrayList<>(); + for(int i=from;i csComparator(String sort){ + if(sort==null||sort.isBlank()){ + return Comparator.comparing(CsInquiry::getCreatedAt,Comparator.nullsLast(Instant::compareTo).reversed()) + .thenComparing(CsInquiry::getId,Comparator.reverseOrder()); + } + boolean asc=sort.toLowerCase().endsWith(",asc"); + String key=sort.split(",")[0]; + Comparator c=switch(key){ + case "authorName","작성자"->Comparator.comparing(CsInquiry::getAuthorName,Comparator.nullsLast(String::compareTo)); + case "answer","답변","status"->Comparator.comparing(this::csAnswerLabel,Comparator.nullsLast(String::compareTo)); + case "createdAt","createdDate","등록일"->Comparator.comparing(CsInquiry::getCreatedAt,Comparator.nullsLast(Instant::compareTo)); + case "title","제목"->Comparator.comparing(CsInquiry::getTitle,Comparator.nullsLast(String::compareTo)); + default->Comparator.comparing(CsInquiry::getId,Comparator.nullsLast(Long::compareTo)); + }; + return (asc?c:c.reversed()).thenComparing(CsInquiry::getId,Comparator.reverseOrder()); + } + private String csAnswerLabel(CsInquiry c){ + String status=c.getStatus()==null?"":c.getStatus(); + boolean done="답변완료".equals(status)||"DONE".equalsIgnoreCase(status)||(c.getReply()!=null&&!c.getReply().isBlank()); + if(!done) return "답변대기".equals(status)||"OPEN".equalsIgnoreCase(status)||status.isBlank()?"답변대기":status; + int n=c.getReplyCount()==null||c.getReplyCount()<1?1:c.getReplyCount(); + return "답변완료 "+n; + } + private Map csMap(CsInquiry c,int no){ + Map r=new LinkedHashMap<>(); + r.put("id",c.getId()); r.put("no",no); r.put("title",c.getTitle()); + r.put("userid",c.getUserid()); r.put("authorName",c.getAuthorName()==null||c.getAuthorName().isBlank()?c.getUserid():c.getAuthorName()); + r.put("status",c.getStatus()); r.put("answer",csAnswerLabel(c)); + r.put("content",c.getContent()); r.put("reply",c.getReply()); + r.put("replyCount",c.getReplyCount()==null?0:c.getReplyCount()); + r.put("createdDate",c.getCreatedAt()==null?null:LocalDate.ofInstant(c.getCreatedAt(),ZoneId.of("Asia/Seoul")).toString()); + return r; + } + @GetMapping("/sms/balance") ApiResponse smsBalance(@AuthenticationPrincipal JwtUser u){ + return ApiResponse.ok(Map.of("balance", smsBalanceOf(u.getGroupId()), "senderNumber", "1600-3903")); + } + @PostMapping("/sms/charge") @Transactional ApiResponse smsCharge(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + int add=d.get("amount")==null?100:Integer.parseInt(String.valueOf(d.get("amount")).replace(",","")); + if(add<=0) return ApiResponse.fail("충전 수량을 입력하세요."); + int bal=smsBalanceOf(u.getGroupId())+add; + saveSmsBalance(u.getGroupId(), bal); + return ApiResponse.ok(Map.of("balance", bal)); + } + @GetMapping("/sms/history") ApiResponse smsHistory(@AuthenticationPrincipal JwtUser u,@RequestParam Map f, + @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size){ + return ApiResponse.ok(smsHistoryData(u,f,page,size)); + } + @PostMapping("/sms/send") @Transactional ApiResponse smsSend(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Object phonesRaw=d.get("phones"); + List phones=new ArrayList<>(); + if(phonesRaw instanceof List list){ + for(Object p:list){ + String phone=normalizePhone(String.valueOf(p)); + if(!phone.isBlank()) phones.add(phone); + } + } else if(d.get("phone")!=null){ + String phone=normalizePhone(String.valueOf(d.get("phone"))); + if(!phone.isBlank()) phones.add(phone); + } + phones=phones.stream().distinct().toList(); + if(phones.isEmpty()) return ApiResponse.fail("받는 사람 연락처를 추가해주세요."); + String message=d.get("message")==null?"":String.valueOf(d.get("message")).trim(); + if(message.isBlank()) return ApiResponse.fail("발송내용을 입력하세요."); + int bytes=smsBytes(message); + String msgType=bytes<=90?"단문":"장문"; + int perDeduct=bytes<=90?1:Math.max(3, (bytes+89)/90); + int deduct=perDeduct*phones.size(); + int balance=smsBalanceOf(u.getGroupId()); + if(balance members=em.createQuery("select m from Member m where m.userid=:id",Member.class).setParameter("id",u.getUserid()).setMaxResults(1).getResultList(); + if(!members.isEmpty() && !blank(members.getFirst().getName())) senderName=members.getFirst().getName(); + SmsMessage row=new SmsMessage(); + row.setGroupId(u.getGroupId()); + row.setUserid(u.getUserid()); + row.setPhone(String.join(",", phones)); + row.setMessage(message); + row.setStatus("발송"); + row.setMsgType(msgType); + row.setSenderNumber(senderNumber); + row.setSenderName(senderName); + row.setSendCount(phones.size()); + row.setDeductCount(deduct); + row.setSentAt(Instant.now()); + em.persist(row); + saveSmsBalance(u.getGroupId(), balance-deduct); + Map result=smsMap(row); + result.put("balance", balance-deduct); + return ApiResponse.ok(result); + } + @PostMapping("/sms/history/delete") @Transactional ApiResponse smsHistoryDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Object raw=d.get("ids"); + if(!(raw instanceof List ids) || ids.isEmpty()) return ApiResponse.fail("삭제할 항목을 선택하세요."); + int deleted=0; + for(Object idObj:ids){ + SmsMessage row=em.find(SmsMessage.class, Long.valueOf(idObj.toString())); + if(row==null || !Objects.equals(row.getGroupId(),u.getGroupId())) continue; + em.remove(row); deleted++; + } + return ApiResponse.ok(Map.of("deleted", deleted)); + } + private Map smsHistoryData(JwtUser u, Map f, int page, int size){ + List all=em.createQuery("select e from SmsMessage e where e.groupId=:g",SmsMessage.class) + .setParameter("g",u.getGroupId()).getResultList(); + LocalDate from=parseDate(f.get("dateFrom"), null); + LocalDate to=parseDate(f.get("dateTo"), null); + String searchType=f.getOrDefault("searchType","수신번호"); + String keyword=f.getOrDefault("keyword",""); + List filtered=all.stream().filter(e->{ + Instant sent=e.getSentAt()!=null?e.getSentAt():e.getCreatedAt(); + if(sent!=null){ + LocalDate day=sent.atZone(ZoneId.systemDefault()).toLocalDate(); + if(from!=null && day.isBefore(from)) return false; + if(to!=null && day.isAfter(to)) return false; + } else if(from!=null || to!=null) return false; + if(!blank(keyword)){ + String q=keyword.trim(); + return switch(searchType){ + case "내용" -> e.getMessage()!=null && e.getMessage().contains(q); + case "발송자" -> (e.getSenderName()!=null && e.getSenderName().contains(q)) || (e.getUserid()!=null && e.getUserid().contains(q)); + case "발신번호" -> e.getSenderNumber()!=null && e.getSenderNumber().contains(q); + default -> e.getPhone()!=null && e.getPhone().replace("-","").contains(q.replace("-","")); + }; + } + return true; + }).sorted(Comparator.comparing((SmsMessage e)->e.getSentAt()!=null?e.getSentAt():e.getCreatedAt(), Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(SmsMessage::getId, Comparator.reverseOrder())).toList(); + int sz=Math.min(Math.max(size,1),200); + int fromIdx=Math.min(Math.max(page,0)*sz, filtered.size()); + int toIdx=Math.min(fromIdx+sz, filtered.size()); + List> list=filtered.subList(fromIdx,toIdx).stream().map(this::smsMap).toList(); + Map result=new LinkedHashMap<>(); + result.put("total", filtered.size()); + result.put("list", list); + result.put("balance", smsBalanceOf(u.getGroupId())); + return result; + } + private Map smsMap(SmsMessage e){ + Map r=new LinkedHashMap<>(); + Instant sent=e.getSentAt()!=null?e.getSentAt():e.getCreatedAt(); + r.put("id", e.getId()); + r.put("sentAt", sent==null?null:sent.atZone(ZoneId.systemDefault()).toLocalDateTime().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))); + r.put("msgType", blank(e.getMsgType())?"단문":e.getMsgType()); + r.put("sendCount", e.getSendCount()==null?1:e.getSendCount()); + r.put("deductCount", e.getDeductCount()==null?1:e.getDeductCount()); + r.put("phone", e.getPhone()); + r.put("message", e.getMessage()); + r.put("senderNumber", blank(e.getSenderNumber())?"1600-3903":e.getSenderNumber()); + r.put("senderName", blank(e.getSenderName())?e.getUserid():e.getSenderName()); + r.put("status", e.getStatus()); + r.put("userid", e.getUserid()); + return r; + } + private int smsBalanceOf(String groupId){ + List rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='sms_balance'",Setting.class) + .setParameter("g",groupId).getResultList(); + if(rows.isEmpty() || blank(rows.getFirst().getValue())) return 996; + try{ return Integer.parseInt(rows.getFirst().getValue().trim()); }catch(Exception ex){ return 996; } + } + private void saveSmsBalance(String groupId, int balance){ + List rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='sms_balance'",Setting.class) + .setParameter("g",groupId).getResultList(); + if(rows.isEmpty()){ + Setting s=new Setting(); s.setGroupId(groupId); s.setSettingKey("sms_balance"); s.setValue(String.valueOf(balance)); em.persist(s); + } else { + rows.getFirst().setValue(String.valueOf(balance)); + } + } + private String normalizePhone(String raw){ + if(raw==null) return ""; + return raw.replaceAll("[^0-9]",""); + } + private int smsBytes(String text){ + if(text==null || text.isEmpty()) return 0; + int bytes=0; + for(int i=0;i groups=em.createQuery("select g from SmsAddressGroup g where g.groupId=:g order by g.id",SmsAddressGroup.class) + .setParameter("g",u.getGroupId()).getResultList(); + List contacts=em.createQuery("select c from SmsAddressContact c where c.groupId=:g",SmsAddressContact.class) + .setParameter("g",u.getGroupId()).getResultList(); + Map counts=new HashMap<>(); + for(SmsAddressContact c:contacts){ + if(c.getAddressGroupId()==null) continue; + counts.put(c.getAddressGroupId(), counts.getOrDefault(c.getAddressGroupId(),0)+1); + } + List> list=new ArrayList<>(); + Map all=new LinkedHashMap<>(); + all.put("id", null); all.put("name", "전체"); all.put("count", contacts.size()); all.put("all", true); + list.add(all); + for(SmsAddressGroup g:groups){ + Map row=new LinkedHashMap<>(); + row.put("id", g.getId()); + row.put("name", g.getName()); + row.put("count", counts.getOrDefault(g.getId(),0)); + row.put("all", false); + list.add(row); + } + return ApiResponse.ok(Map.of("list", list, "total", contacts.size())); + } + @PostMapping("/sms/address/groups") @Transactional ApiResponse smsAddressGroupCreate(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + String name=d.get("name")==null?"":String.valueOf(d.get("name")).trim(); + if(name.isBlank()) return ApiResponse.fail("그룹명을 입력하세요."); + if("전체".equals(name)) return ApiResponse.fail("사용할 수 없는 그룹명입니다."); + Long exists=em.createQuery("select count(g) from SmsAddressGroup g where g.groupId=:g and g.name=:n",Long.class) + .setParameter("g",u.getGroupId()).setParameter("n",name).getSingleResult(); + if(exists!=null && exists>0) return ApiResponse.fail("이미 존재하는 그룹명입니다."); + SmsAddressGroup g=new SmsAddressGroup(); + g.setGroupId(u.getGroupId()); + g.setName(name); + em.persist(g); + return ApiResponse.ok(Map.of("id", g.getId(), "name", g.getName())); + } + @PutMapping("/sms/address/groups/{id}") @Transactional ApiResponse smsAddressGroupUpdate(@PathVariable Long id,@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + SmsAddressGroup g=em.find(SmsAddressGroup.class,id); + if(g==null || !Objects.equals(g.getGroupId(),u.getGroupId())) return ApiResponse.fail("그룹을 찾을 수 없습니다."); + String name=d.get("name")==null?"":String.valueOf(d.get("name")).trim(); + if(name.isBlank()) return ApiResponse.fail("그룹명을 입력하세요."); + if("전체".equals(name)) return ApiResponse.fail("사용할 수 없는 그룹명입니다."); + g.setName(name); + return ApiResponse.ok(Map.of("id", g.getId(), "name", g.getName())); + } + @DeleteMapping("/sms/address/groups/{id}") @Transactional ApiResponse smsAddressGroupDelete(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){ + SmsAddressGroup g=em.find(SmsAddressGroup.class,id); + if(g==null || !Objects.equals(g.getGroupId(),u.getGroupId())) return ApiResponse.fail("그룹을 찾을 수 없습니다."); + List contacts=em.createQuery("select c from SmsAddressContact c where c.groupId=:g and c.addressGroupId=:gid",SmsAddressContact.class) + .setParameter("g",u.getGroupId()).setParameter("gid",id).getResultList(); + for(SmsAddressContact c:contacts) em.remove(c); + em.remove(g); + return ApiResponse.ok(Map.of("deleted", true)); + } + @GetMapping("/sms/address/contacts") ApiResponse smsAddressContacts(@AuthenticationPrincipal JwtUser u,@RequestParam Map f, + @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size){ + return ApiResponse.ok(smsAddressContactData(u,f,page,size)); + } + @PostMapping("/sms/address/contacts") @Transactional ApiResponse smsAddressContactCreate(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + String name=d.get("name")==null?"":String.valueOf(d.get("name")).trim(); + String phone=normalizePhone(String.valueOf(d.getOrDefault("phone",""))); + if(name.isBlank()) return ApiResponse.fail("이름을 입력하세요."); + if(phone.isBlank()) return ApiResponse.fail("연락처를 입력하세요."); + Long gid=null; + if(d.get("addressGroupId")!=null && !String.valueOf(d.get("addressGroupId")).isBlank()){ + gid=Long.valueOf(String.valueOf(d.get("addressGroupId"))); + SmsAddressGroup g=em.find(SmsAddressGroup.class,gid); + if(g==null || !Objects.equals(g.getGroupId(),u.getGroupId())) return ApiResponse.fail("그룹을 선택하세요."); + } + SmsAddressContact c=new SmsAddressContact(); + c.setGroupId(u.getGroupId()); + c.setAddressGroupId(gid); + c.setName(name); + c.setPhone(phone); + em.persist(c); + return ApiResponse.ok(smsAddressContactMap(c, groupNameMap(u))); + } + @PutMapping("/sms/address/contacts/{id}") @Transactional ApiResponse smsAddressContactUpdate(@PathVariable Long id,@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + SmsAddressContact c=em.find(SmsAddressContact.class,id); + if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId())) return ApiResponse.fail("연락처를 찾을 수 없습니다."); + if(d.get("name")!=null){ + String name=String.valueOf(d.get("name")).trim(); + if(name.isBlank()) return ApiResponse.fail("이름을 입력하세요."); + c.setName(name); + } + if(d.get("phone")!=null){ + String phone=normalizePhone(String.valueOf(d.get("phone"))); + if(phone.isBlank()) return ApiResponse.fail("연락처를 입력하세요."); + c.setPhone(phone); + } + if(d.containsKey("addressGroupId")){ + if(d.get("addressGroupId")==null || String.valueOf(d.get("addressGroupId")).isBlank()) c.setAddressGroupId(null); + else { + Long gid=Long.valueOf(String.valueOf(d.get("addressGroupId"))); + SmsAddressGroup g=em.find(SmsAddressGroup.class,gid); + if(g==null || !Objects.equals(g.getGroupId(),u.getGroupId())) return ApiResponse.fail("그룹을 선택하세요."); + c.setAddressGroupId(gid); + } + } + return ApiResponse.ok(smsAddressContactMap(c, groupNameMap(u))); + } + @PostMapping("/sms/address/contacts/delete") @Transactional ApiResponse smsAddressContactDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Object raw=d.get("ids"); + if(!(raw instanceof List ids) || ids.isEmpty()) return ApiResponse.fail("삭제할 항목을 선택하세요."); + int deleted=0; + for(Object idObj:ids){ + SmsAddressContact c=em.find(SmsAddressContact.class, Long.valueOf(idObj.toString())); + if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId())) continue; + em.remove(c); deleted++; + } + return ApiResponse.ok(Map.of("deleted", deleted)); + } + @PostMapping("/sms/address/contacts/change-group") @Transactional ApiResponse smsAddressContactChangeGroup(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Object raw=d.get("ids"); + if(!(raw instanceof List ids) || ids.isEmpty()) return ApiResponse.fail("변경할 항목을 선택하세요."); + if(d.get("addressGroupId")==null || String.valueOf(d.get("addressGroupId")).isBlank()) return ApiResponse.fail("그룹을 선택하세요."); + Long gid=Long.valueOf(String.valueOf(d.get("addressGroupId"))); + SmsAddressGroup g=em.find(SmsAddressGroup.class,gid); + if(g==null || !Objects.equals(g.getGroupId(),u.getGroupId())) return ApiResponse.fail("그룹을 선택하세요."); + int updated=0; + for(Object idObj:ids){ + SmsAddressContact c=em.find(SmsAddressContact.class, Long.valueOf(idObj.toString())); + if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId())) continue; + c.setAddressGroupId(gid); updated++; + } + return ApiResponse.ok(Map.of("updated", updated)); + } + @PostMapping("/sms/address/contacts/bulk") @Transactional ApiResponse smsAddressContactBulk(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Long gid=null; + if(d.get("addressGroupId")!=null && !String.valueOf(d.get("addressGroupId")).isBlank()){ + gid=Long.valueOf(String.valueOf(d.get("addressGroupId"))); + SmsAddressGroup g=em.find(SmsAddressGroup.class,gid); + if(g==null || !Objects.equals(g.getGroupId(),u.getGroupId())) return ApiResponse.fail("그룹을 선택하세요."); + } + String text=d.get("text")==null?"":String.valueOf(d.get("text")); + int created=0; + for(String line:text.split("\\r?\\n")){ + if(line==null || line.isBlank()) continue; + String[] parts=line.split("[,\\t]"); + String name; String phone; + if(parts.length>=2){ name=parts[0].trim(); phone=normalizePhone(parts[1]); } + else { name=parts[0].trim(); phone=normalizePhone(parts[0]); if(phone.equals(normalizePhone(name))) name="-"; } + if(phone.isBlank()) continue; + if(name.isBlank()) name="-"; + SmsAddressContact c=new SmsAddressContact(); + c.setGroupId(u.getGroupId()); + c.setAddressGroupId(gid); + c.setName(name); + c.setPhone(phone); + em.persist(c); created++; + } + if(created==0) return ApiResponse.fail("등록할 연락처가 없습니다."); + return ApiResponse.ok(Map.of("created", created)); + } + @GetMapping(value="/sms/address/contacts/export", produces="text/csv;charset=UTF-8") ResponseEntity smsAddressContactExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) smsAddressContactData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF그룹,이름,연락처\n"); + for(Map r:rows) csv.append(csv(r.get("groupName"))).append(',').append(csv(r.get("name"))).append(',').append(csv(r.get("phoneDisplay"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=sms-address.csv").body(csv.toString()); + } + @PostMapping("/sms/cart") @Transactional ApiResponse smsCartAdd(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Object raw=d.get("phones"); + List add=new ArrayList<>(); + if(raw instanceof List list) for(Object p:list){ String phone=normalizePhone(String.valueOf(p)); if(!phone.isBlank()) add.add(phone); } + List cart=loadSmsCart(u.getGroupId()); + for(String p:add) if(!cart.contains(p)) cart.add(p); + saveSmsCart(u.getGroupId(), cart); + return ApiResponse.ok(Map.of("count", cart.size(), "phones", cart)); + } + @GetMapping("/sms/cart") ApiResponse smsCartGet(@AuthenticationPrincipal JwtUser u){ + List cart=loadSmsCart(u.getGroupId()); + return ApiResponse.ok(Map.of("count", cart.size(), "phones", cart)); + } + private Map smsAddressContactData(JwtUser u, Map f, int page, int size){ + Map names=groupNameMap(u); + List all=em.createQuery("select c from SmsAddressContact c where c.groupId=:g",SmsAddressContact.class) + .setParameter("g",u.getGroupId()).getResultList(); + Long gid=blank(f.get("addressGroupId"))?null:Long.valueOf(f.get("addressGroupId")); + String searchType=f.getOrDefault("searchType","이름"); + String keyword=f.getOrDefault("keyword",""); + List filtered=all.stream().filter(c->{ + if(gid!=null && !Objects.equals(c.getAddressGroupId(),gid)) return false; + if(!blank(keyword)){ + String q=keyword.trim(); + return switch(searchType){ + case "연락처" -> c.getPhone()!=null && c.getPhone().contains(q.replace("-","")); + case "그룹" -> names.getOrDefault(c.getAddressGroupId(),"").contains(q); + default -> c.getName()!=null && c.getName().contains(q); + }; + } + return true; + }).sorted(Comparator.comparing(SmsAddressContact::getId).reversed()).toList(); + int sz=Math.min(Math.max(size,1),200); + int fromIdx=Math.min(Math.max(page,0)*sz, filtered.size()); + int toIdx=Math.min(fromIdx+sz, filtered.size()); + List> list=filtered.subList(fromIdx,toIdx).stream().map(c->smsAddressContactMap(c,names)).toList(); + Map result=new LinkedHashMap<>(); + result.put("total", filtered.size()); + result.put("list", list); + return result; + } + private Map groupNameMap(JwtUser u){ + Map names=new HashMap<>(); + for(SmsAddressGroup g:em.createQuery("select g from SmsAddressGroup g where g.groupId=:g",SmsAddressGroup.class).setParameter("g",u.getGroupId()).getResultList()) + names.put(g.getId(), g.getName()); + return names; + } + private Map smsAddressContactMap(SmsAddressContact c, Map names){ + Map r=new LinkedHashMap<>(); + r.put("id", c.getId()); + r.put("addressGroupId", c.getAddressGroupId()); + r.put("groupName", names.getOrDefault(c.getAddressGroupId(),"-")); + r.put("name", c.getName()); + r.put("phone", c.getPhone()); + r.put("phoneDisplay", formatPhoneDisplay(c.getPhone())); + return r; + } + private String formatPhoneDisplay(String phone){ + if(phone==null || phone.isBlank()) return ""; + String p=phone.replaceAll("[^0-9]",""); + if(p.length()==11) return p.substring(0,3)+"-"+p.substring(3,7)+"-"+p.substring(7); + if(p.length()==10) return p.substring(0,3)+"-"+p.substring(3,6)+"-"+p.substring(6); + return phone; + } + private List loadSmsCart(String groupId){ + List rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='sms_cart'",Setting.class) + .setParameter("g",groupId).getResultList(); + if(rows.isEmpty() || blank(rows.getFirst().getValue())) return new ArrayList<>(); + try{ + @SuppressWarnings("unchecked") List list=(List) new ObjectMapper().readValue(rows.getFirst().getValue(), List.class); + return new ArrayList<>(list); + }catch(Exception ex){ return new ArrayList<>(); } + } + private void saveSmsCart(String groupId, List phones){ + try{ + String json=new ObjectMapper().writeValueAsString(phones); + List rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='sms_cart'",Setting.class) + .setParameter("g",groupId).getResultList(); + if(rows.isEmpty()){ + Setting s=new Setting(); s.setGroupId(groupId); s.setSettingKey("sms_cart"); s.setValue(json); em.persist(s); + } else rows.getFirst().setValue(json); + }catch(Exception ignored){} + } + @PostMapping({"/scans","/indocs","/returns","/accounts","/sms","/sms/templates"}) ApiResponse commonPost(HttpServletRequest r,@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){return post(type(r.getRequestURI()),d,u);} + @GetMapping("/fareboxes/search") ApiResponse fareboxSearch(@AuthenticationPrincipal JwtUser u,@RequestParam Map f, + @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size){ + return ApiResponse.ok(fareboxSearchData(u,f,page,size)); + } + @GetMapping(value="/fareboxes/export", produces="text/csv;charset=UTF-8") ResponseEntity fareboxExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) fareboxSearchData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF수납일,통신사,출고처,고객명,연락처,구분,현금금액,카드금액,결제상태,결제금액,상세내역,등록자\n"); + for(Map r:rows){ + csv.append(csv(r.get("faredate"))).append(',').append(csv(r.get("telcompany"))).append(',').append(csv(r.get("outcompanyName"))).append(',') + .append(csv(r.get("name"))).append(',').append(csv(r.get("phone"))).append(',').append(csv(r.get("how"))).append(',') + .append(csv(r.get("cashprice"))).append(',').append(csv(r.get("cardprice"))).append(',').append(csv(r.get("paystate"))).append(',') + .append(csv(r.get("payAmount"))).append(',').append(csv(r.get("detail"))).append(',').append(csv(r.get("registrant"))).append('\n'); + } + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=farebox.csv").body(csv.toString()); + } + @PostMapping("/fareboxes") @Transactional ApiResponse fareboxPost(@RequestBody Map d,@AuthenticationPrincipal JwtUser u){ + if(d.get("faredate")==null || String.valueOf(d.get("faredate")).isBlank()) return ApiResponse.fail("수납일을 입력하세요."); + if(d.get("telcompany")==null || String.valueOf(d.get("telcompany")).isBlank()) return ApiResponse.fail("통신사를 선택하세요."); + if(d.get("outcompanyName")==null || String.valueOf(d.get("outcompanyName")).isBlank()) return ApiResponse.fail("출고처를 입력하세요."); + if(d.get("name")==null || String.valueOf(d.get("name")).isBlank()) return ApiResponse.fail("고객명을 입력하세요."); + if(d.get("how")==null || String.valueOf(d.get("how")).isBlank()) d.put("how","수납"); + if(d.get("paystate")==null || String.valueOf(d.get("paystate")).isBlank()) d.put("paystate","미결"); + if(d.get("registrant")==null || String.valueOf(d.get("registrant")).isBlank()) d.put("registrant", u.getUserid()); + String outName=String.valueOf(d.get("outcompanyName")).trim(); + if(d.get("outcompanyId")==null || String.valueOf(d.get("outcompanyId")).isBlank()){ + List found=em.createQuery("select c from Company c where c.groupId=:g and c.name=:n",Company.class) + .setParameter("g",u.getGroupId()).setParameter("n",outName).setMaxResults(1).getResultList(); + if(!found.isEmpty()) d.put("outcompanyId", found.getFirst().getId()); + } + d.putIfAbsent("cashprice",0); + d.putIfAbsent("cardprice",0); + d.putIfAbsent("paidAmount",0); + return post(Farebox.class,d,u); + } + @GetMapping("/fareboxes/pay-summary") ApiResponse fareboxPaySummary(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + return ApiResponse.ok(fareboxPaySummaryData(u,f)); + } + @GetMapping(value="/fareboxes/pay-summary/export", produces="text/csv;charset=UTF-8") ResponseEntity fareboxPaySummaryExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + Map data=fareboxPaySummaryData(u,f); + @SuppressWarnings("unchecked") List> rows=(List>) data.get("rows"); + StringBuilder csv=new StringBuilder("\uFEFF번호,채널,출고처,담당자,전화,휴대폰,수납건수,결제잔액\n"); + int i=1; + for(Map r:rows){ + csv.append(i++).append(',').append(csv(r.get("channel"))).append(',').append(csv(r.get("outcompanyName"))).append(',') + .append(csv(r.get("managerName"))).append(',').append(csv(r.get("phone"))).append(',').append(csv(r.get("mobile"))).append(',') + .append(csv(r.get("count"))).append(',').append(csv(r.get("balance"))).append('\n'); + } + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=farebox-pay.csv").body(csv.toString()); + } + @GetMapping("/fareboxes/pay-items") ApiResponse fareboxPayItems(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + String outName=f.getOrDefault("outcompanyName",""); + if(blank(outName)) return ApiResponse.fail("출고처를 선택하세요."); + Map companies=companyNames(u); + List items=em.createQuery("select e from Farebox e where e.groupId=:g",Farebox.class).setParameter("g",u.getGroupId()).getResultList() + .stream().filter(e->fareboxRemaining(e).compareTo(BigDecimal.ZERO)>0) + .filter(e->outName.equals(fareboxOutName(e,companies))) + .sorted(Comparator.comparing(Farebox::getFaredate, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(Farebox::getId)) + .toList(); + List> list=items.stream().map(e->{ + Map m=fareboxMap(e,companies); + m.put("remaining", fareboxRemaining(e)); + m.put("paidAmount", nvl(e.getPaidAmount())); + return m; + }).toList(); + Map result=new LinkedHashMap<>(); + result.put("outcompanyName", outName); + result.put("list", list); + result.put("totalBalance", list.stream().map(r->(BigDecimal)r.get("remaining")).reduce(BigDecimal.ZERO,BigDecimal::add)); + return ApiResponse.ok(result); + } + @PostMapping("/fareboxes/pay") @Transactional ApiResponse fareboxPay(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + String mode=String.valueOf(d.getOrDefault("mode","item")); // item | batch + String outName=String.valueOf(d.getOrDefault("outcompanyName","")).trim(); + if(blank(outName)) return ApiResponse.fail("출고처를 확인하세요."); + LocalDate paydate=parseDate(String.valueOf(d.getOrDefault("paydate","")), LocalDate.now()); + String memo=d.get("memo")==null?null:String.valueOf(d.get("memo")); + Map companies=companyNames(u); + List pending=em.createQuery("select e from Farebox e where e.groupId=:g",Farebox.class).setParameter("g",u.getGroupId()).getResultList() + .stream().filter(e->fareboxRemaining(e).compareTo(BigDecimal.ZERO)>0) + .filter(e->outName.equals(fareboxOutName(e,companies))) + .sorted(Comparator.comparing(Farebox::getFaredate, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(Farebox::getId)) + .toList(); + if(pending.isEmpty()) return ApiResponse.fail("결제할 미결 수납이 없습니다."); + List targets=new ArrayList<>(); + BigDecimal payAmount; + if("item".equals(mode)){ + Object raw=d.get("ids"); + if(!(raw instanceof List ids) || ids.isEmpty()) return ApiResponse.fail("결제하실 내역을 선택해주세요."); + Set idSet=ids.stream().map(x->Long.valueOf(x.toString())).collect(java.util.stream.Collectors.toSet()); + targets=pending.stream().filter(e->idSet.contains(e.getId())).toList(); + if(targets.isEmpty()) return ApiResponse.fail("결제하실 내역을 선택해주세요."); + payAmount=targets.stream().map(this::fareboxRemaining).reduce(BigDecimal.ZERO,BigDecimal::add); + } else { + boolean full="true".equalsIgnoreCase(String.valueOf(d.getOrDefault("fullPay",false))) || Boolean.TRUE.equals(d.get("fullPay")); + BigDecimal total=pending.stream().map(this::fareboxRemaining).reduce(BigDecimal.ZERO,BigDecimal::add); + if(full) payAmount=total; + else { + try{ payAmount=new BigDecimal(String.valueOf(d.get("amount"))); } + catch(Exception e){ return ApiResponse.fail("결제금액을 입력하세요."); } + if(payAmount.compareTo(BigDecimal.ZERO)<=0) return ApiResponse.fail("결제금액을 입력하세요."); + if(payAmount.compareTo(total)>0) payAmount=total; + } + BigDecimal left=payAmount; + for(Farebox e:pending){ + if(left.compareTo(BigDecimal.ZERO)<=0) break; + BigDecimal rem=fareboxRemaining(e); + if(rem.compareTo(BigDecimal.ZERO)<=0) continue; + targets.add(e); + left=left.subtract(rem.min(left)); + } + } + BigDecimal remainPay=payAmount; + List paidIds=new ArrayList<>(); + int payCount=0; + for(Farebox e:targets){ + if(remainPay.compareTo(BigDecimal.ZERO)<=0) break; + BigDecimal rem=fareboxRemaining(e); + BigDecimal apply=rem.min(remainPay); + e.setPaidAmount(nvl(e.getPaidAmount()).add(apply)); + if(fareboxRemaining(e).compareTo(BigDecimal.ZERO)<=0) e.setPaystate("완결"); + else e.setPaystate("미결"); + remainPay=remainPay.subtract(apply); + paidIds.add(e.getId()); + payCount++; + } + Long outId=pending.getFirst().getOutcompanyId(); + if(outId==null){ + List found=em.createQuery("select c from Company c where c.groupId=:g and c.name=:n",Company.class) + .setParameter("g",u.getGroupId()).setParameter("n",outName).setMaxResults(1).getResultList(); + if(!found.isEmpty()) outId=found.getFirst().getId(); + } + FareboxPayment payment=new FareboxPayment(); + payment.setGroupId(u.getGroupId()); + payment.setOutcompanyId(outId); + payment.setOutcompanyName(outName); + payment.setMode("item".equals(mode)?"건별":"일반"); + payment.setPaydate(paydate); + payment.setPayCount(payCount); + payment.setAmount(payAmount.subtract(remainPay)); + payment.setMemo(memo); + payment.setRegistrant(u.getUserid()); + payment.setStatus("정상"); + String outCat=targets.stream().map(Farebox::getOutCategory).filter(x->!blank(x)).findFirst().orElse(null); + if(blank(outCat) && outId!=null){ + Company c=em.find(Company.class, outId); + if(c!=null && !blank(c.getChannel())) outCat=c.getChannel(); + } + payment.setOutCategory(blank(outCat)?"판매점":outCat); + payment.setFareboxIds(paidIds.stream().map(String::valueOf).collect(java.util.stream.Collectors.joining(","))); + em.persist(payment); + Map result=new LinkedHashMap<>(); + result.put("paymentId", payment.getId()); + result.put("payCount", payCount); + result.put("amount", payment.getAmount()); + return ApiResponse.ok(result); + } + @GetMapping("/fareboxes/payments") ApiResponse fareboxPayments(@AuthenticationPrincipal JwtUser u,@RequestParam Map f, + @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size){ + return ApiResponse.ok(fareboxPaymentSearchData(u,f,page,size)); + } + @GetMapping("/fareboxes/payments/{id}") ApiResponse fareboxPaymentDetail(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){ + FareboxPayment p=em.find(FareboxPayment.class,id); + if(p==null || !Objects.equals(p.getGroupId(),u.getGroupId())) return ApiResponse.fail("결제내역을 찾을 수 없습니다."); + Map companies=companyNames(u); + List> items=new ArrayList<>(); + if(!blank(p.getFareboxIds())){ + for(String raw:p.getFareboxIds().split(",")){ + if(raw==null||raw.isBlank()) continue; + try{ + Farebox e=em.find(Farebox.class, Long.valueOf(raw.trim())); + if(e!=null && Objects.equals(e.getGroupId(),u.getGroupId())) items.add(fareboxMap(e,companies)); + }catch(Exception ignored){} + } + } + Map result=fareboxPaymentMap(p); + result.put("items", items); + return ApiResponse.ok(result); + } + @PostMapping("/fareboxes/payments/{id}/cancel") @Transactional ApiResponse fareboxPaymentCancel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){ + FareboxPayment p=em.find(FareboxPayment.class,id); + if(p==null || !Objects.equals(p.getGroupId(),u.getGroupId())) return ApiResponse.fail("결제내역을 찾을 수 없습니다."); + if("취소됨".equals(p.getStatus())) return ApiResponse.fail("이미 취소된 결제입니다."); + BigDecimal amount=nvl(p.getAmount()); + if(!blank(p.getFareboxIds()) && amount.compareTo(BigDecimal.ZERO)>0){ + BigDecimal left=amount; + List ids=new ArrayList<>(); + for(String raw:p.getFareboxIds().split(",")){ + if(raw==null||raw.isBlank()) continue; + try{ ids.add(Long.valueOf(raw.trim())); }catch(Exception ignored){} + } + // reverse from last paid first + Collections.reverse(ids); + for(Long fid:ids){ + if(left.compareTo(BigDecimal.ZERO)<=0) break; + Farebox e=em.find(Farebox.class,fid); + if(e==null || !Objects.equals(e.getGroupId(),u.getGroupId())) continue; + BigDecimal paid=nvl(e.getPaidAmount()); + if(paid.compareTo(BigDecimal.ZERO)<=0) continue; + BigDecimal rev=paid.min(left); + e.setPaidAmount(paid.subtract(rev)); + if(fareboxRemaining(e).compareTo(BigDecimal.ZERO)>0) e.setPaystate("미결"); + left=left.subtract(rev); + } + } + p.setStatus("취소됨"); + return ApiResponse.ok(fareboxPaymentMap(p)); + } + private Map fareboxPaymentSearchData(JwtUser u, Map f, int page, int size){ + LocalDate from=parseDate(f.get("dateFrom"), null); + LocalDate to=parseDate(f.get("dateTo"), null); + List all=em.createQuery("select p from FareboxPayment p where p.groupId=:g",FareboxPayment.class) + .setParameter("g",u.getGroupId()).getResultList(); + List filtered=all.stream().filter(p->{ + if(from!=null && (p.getPaydate()==null || p.getPaydate().isBefore(from))) return false; + if(to!=null && (p.getPaydate()==null || p.getPaydate().isAfter(to))) return false; + String status=blank(p.getStatus())?"정상":p.getStatus(); + if(!eq(status, f.get("status"))) return false; + if(!eq(p.getMode(), f.get("mode")) && !eq(p.getMode(), f.get("how"))) return false; + if(!eq(p.getOutCategory(), f.get("outCategory"))) return false; + if(!like(p.getOutcompanyName(), f.get("outcompanyName"))) return false; + return true; + }).sorted(Comparator.comparing(FareboxPayment::getPaydate, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(FareboxPayment::getId, Comparator.reverseOrder())).toList(); + int sz=Math.min(Math.max(size,1),200); + int fromIdx=Math.min(Math.max(page,0)*sz, filtered.size()); + int toIdx=Math.min(fromIdx+sz, filtered.size()); + // number from newest: total - index + List> list=new ArrayList<>(); + for(int i=fromIdx;i row=fareboxPaymentMap(filtered.get(i)); + row.put("no", filtered.size()-i); + list.add(row); + } + Map result=new LinkedHashMap<>(); + result.put("total", filtered.size()); + result.put("list", list); + return result; + } + private Map fareboxPaymentMap(FareboxPayment p){ + Map r=new LinkedHashMap<>(); + r.put("id", p.getId()); + r.put("paydate", p.getPaydate()); + r.put("outcompanyId", p.getOutcompanyId()); + r.put("outcompanyName", p.getOutcompanyName()); + r.put("mode", blank(p.getMode())?"일반":p.getMode()); + r.put("amount", nvl(p.getAmount())); + r.put("payCount", p.getPayCount()==null?0:p.getPayCount()); + r.put("memo", p.getMemo()); + r.put("registrant", p.getRegistrant()); + r.put("status", blank(p.getStatus())?"정상":p.getStatus()); + r.put("outCategory", p.getOutCategory()); + r.put("fareboxIds", p.getFareboxIds()); + return r; + } + private Map fareboxPaySummaryData(JwtUser u, Map f){ + Map companyMap=new LinkedHashMap<>(); + for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) + companyMap.put(c.getId(),c); + Map companies=new LinkedHashMap<>(); + companyMap.forEach((id,c)->companies.put(id,c.getName())); + String q=f.get("outcompanyName"); + Map> byOut=new LinkedHashMap<>(); + BigDecimal totalBalance=BigDecimal.ZERO; + for(Farebox e:em.createQuery("select e from Farebox e where e.groupId=:g",Farebox.class).setParameter("g",u.getGroupId()).getResultList()){ + BigDecimal rem=fareboxRemaining(e); + if(rem.compareTo(BigDecimal.ZERO)<=0) continue; + String outName=fareboxOutName(e,companies); + if(!like(outName,q)) continue; + Map row=byOut.computeIfAbsent(outName,k->{ + Map r=new LinkedHashMap<>(); + Company c=companyMap.values().stream().filter(x->outName.equals(x.getName())).findFirst().orElse(null); + if(c==null && e.getOutcompanyId()!=null) c=companyMap.get(e.getOutcompanyId()); + r.put("outcompanyId", c==null?e.getOutcompanyId():c.getId()); + r.put("outcompanyName", outName); + r.put("channel", c!=null && !blank(c.getChannel())?c.getChannel():(blank(e.getOutCategory())?"판매점":e.getOutCategory())); + r.put("managerName", c==null||blank(c.getManagerName())?"-":c.getManagerName()); + r.put("phone", c==null||blank(c.getPhone())?"-":c.getPhone()); + r.put("mobile", c==null||blank(c.getPhone())?"-":c.getPhone()); + r.put("count", 0); + r.put("balance", BigDecimal.ZERO); + r.put("todayPaid", BigDecimal.ZERO); + return r; + }); + row.put("count", ((Number)row.get("count")).intValue()+1); + row.put("balance", ((BigDecimal)row.get("balance")).add(rem)); + totalBalance=totalBalance.add(rem); + } + LocalDate today=LocalDate.now(); + BigDecimal todayPaid=BigDecimal.ZERO; + for(FareboxPayment p:em.createQuery("select p from FareboxPayment p where p.groupId=:g and p.paydate=:d",FareboxPayment.class) + .setParameter("g",u.getGroupId()).setParameter("d",today).getResultList()){ + if("취소됨".equals(blank(p.getStatus())?"정상":p.getStatus())) continue; + todayPaid=todayPaid.add(nvl(p.getAmount())); + String outName=blank(p.getOutcompanyName())?"-":p.getOutcompanyName(); + Map row=byOut.get(outName); + if(row!=null) row.put("todayPaid", ((BigDecimal)row.get("todayPaid")).add(nvl(p.getAmount()))); + } + List> rows=new ArrayList<>(byOut.values()); + rows.sort(Comparator.comparing(r->String.valueOf(r.get("outcompanyName")))); + int no=1; + for(Map r:rows) r.put("no", no++); + Map result=new LinkedHashMap<>(); + result.put("totalBalance", totalBalance); + result.put("todayPaid", todayPaid); + result.put("rows", rows); + result.put("total", rows.size()); + return result; + } + private String fareboxOutName(Farebox e, Map companies){ + if(!blank(e.getOutcompanyName())) return e.getOutcompanyName().trim(); + return companies.getOrDefault(e.getOutcompanyId(),"-"); + } + private BigDecimal fareboxTotal(Farebox e){ return nvl(e.getCashprice()).add(nvl(e.getCardprice())); } + private BigDecimal fareboxRemaining(Farebox e){ + BigDecimal rem=fareboxTotal(e).subtract(nvl(e.getPaidAmount())); + if(rem.compareTo(BigDecimal.ZERO)<0) return BigDecimal.ZERO; + // 완결인데 paid 미기록이면 잔액 0 + if("완결".equals(normalizePayState(e.getPaystate())) && nvl(e.getPaidAmount()).compareTo(BigDecimal.ZERO)==0 + && fareboxTotal(e).compareTo(BigDecimal.ZERO)>0){ + // treat as unpaid if marked 완결 without paidAmount? Prefer remaining based on paidAmount only for 미결 + return BigDecimal.ZERO; + } + if("완결".equals(normalizePayState(e.getPaystate()))) return BigDecimal.ZERO; + return rem; + } + private Map fareboxSearchData(JwtUser u, Map f, int page, int size){ + Map companies=companyNames(u); + List all=em.createQuery("select e from Farebox e where e.groupId=:g",Farebox.class).setParameter("g",u.getGroupId()).getResultList(); + LocalDate from=parseDate(f.get("dateFrom"), null); + LocalDate to=parseDate(f.get("dateTo"), null); + String tab=f.getOrDefault("tab","ALL"); + List base=all.stream().filter(e->matchesFarebox(e,f,companies,from,to,null)).toList(); + List filtered=base.stream() + .filter(e->matchesFarebox(e,f,companies,from,to,tab)) + .sorted(Comparator.comparing(Farebox::getFaredate, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(Farebox::getId, Comparator.reverseOrder())) + .toList(); + Map counts=new LinkedHashMap<>(); + counts.put("ALL", (long) base.size()); + counts.put("미결", base.stream().filter(e->"미결".equals(normalizePayState(e.getPaystate()))).count()); + counts.put("완결", base.stream().filter(e->"완결".equals(normalizePayState(e.getPaystate()))).count()); + int sz=Math.min(Math.max(size,1),200); + int fromIdx=Math.min(Math.max(page,0)*sz, filtered.size()); + int toIdx=Math.min(fromIdx+sz, filtered.size()); + List> list=filtered.subList(fromIdx,toIdx).stream().map(e->fareboxMap(e,companies)).toList(); + Map result=new LinkedHashMap<>(); + result.put("total", filtered.size()); + result.put("list", list); + result.put("counts", counts); + return result; + } + private boolean matchesFarebox(Farebox e, Map f, Map companies, LocalDate from, LocalDate to, String tab){ + if(!fareboxDateOk(e,from,to)) return false; + if(!eq(e.getHow(), f.get("how"))) return false; + if(!eq(e.getTelcompany(), f.get("telecom")) && !eq(e.getTelcompany(), f.get("telcompany"))) return false; + if(!eq(e.getOutCategory(), f.get("outCategory"))) return false; + String outName=blank(e.getOutcompanyName())?companies.getOrDefault(e.getOutcompanyId(),""):e.getOutcompanyName(); + if(!like(outName, f.get("outcompanyName"))) return false; + String searchType=f.getOrDefault("searchType","고객명"); + String keyword=f.get("keyword"); + if(keyword!=null && !keyword.isBlank()){ + boolean ok=switch(searchType){ + case "연락처"->like(e.getPhone(), keyword); + default->like(e.getName(), keyword); + }; + if(!ok) return false; + } + if(tab!=null && !"ALL".equals(tab)){ + String state=normalizePayState(e.getPaystate()); + if("미결".equals(tab) && !"미결".equals(state)) return false; + if("완결".equals(tab) && !"완결".equals(state)) return false; + } + return true; + } + private boolean fareboxDateOk(Farebox e, LocalDate from, LocalDate to){ + if(from!=null && (e.getFaredate()==null || e.getFaredate().isBefore(from))) return false; + if(to!=null && (e.getFaredate()==null || e.getFaredate().isAfter(to))) return false; + return true; + } + private String normalizePayState(String s){ + if(blank(s)) return "미결"; + if("완료".equals(s) || "DONE".equalsIgnoreCase(s) || "완결".equals(s)) return "완결"; + if("PENDING".equalsIgnoreCase(s) || "미결".equals(s)) return "미결"; + return s; + } + private Map fareboxMap(Farebox e, Map companies){ + Map r=new LinkedHashMap<>(); + r.put("id", e.getId()); + r.put("faredate", e.getFaredate()); + r.put("telcompany", e.getTelcompany()); + r.put("outcompanyId", e.getOutcompanyId()); + String outName=blank(e.getOutcompanyName())?companies.getOrDefault(e.getOutcompanyId(),"-"):e.getOutcompanyName(); + r.put("outcompanyName", outName); + r.put("outCategory", e.getOutCategory()); + r.put("name", e.getName()); + r.put("phone", e.getPhone()); + r.put("how", blank(e.getHow())?"수납":e.getHow()); + r.put("cashprice", e.getCashprice()==null?BigDecimal.ZERO:e.getCashprice()); + r.put("cardprice", e.getCardprice()==null?BigDecimal.ZERO:e.getCardprice()); + r.put("paidAmount", e.getPaidAmount()==null?BigDecimal.ZERO:e.getPaidAmount()); + r.put("paystate", normalizePayState(e.getPaystate())); + BigDecimal pay=nvl(e.getCashprice()).add(nvl(e.getCardprice())); + r.put("payAmount", pay.compareTo(BigDecimal.ZERO)==0?null:pay); + r.put("remaining", fareboxRemaining(e)); + r.put("detail", e.getDetail()); + r.put("registrant", e.getRegistrant()); + r.put("receiptNo", e.getReceiptNo()); + return r; + } + @PutMapping({"/scans/{id}","/indocs/{id}","/returns/{id}","/fareboxes/{id}"}) ApiResponse commonPut(HttpServletRequest r,@PathVariable Long id,@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){return put(type(r.getRequestURI()),id,d,u);} + @DeleteMapping("/schedules/{id}") ApiResponse scheduleDel(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return del(ScheduleItem.class,id,u);} + @PostMapping("/alims/{id}/confirm") ApiResponse alimConfirm(@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return put(Alim.class,id,Map.of("confirmed",true),u);} + @GetMapping("/settlements") ApiResponse settlements(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){return list(SettlementMonth.class,u,f,0,100);} + @GetMapping("/settlements/docs") ApiResponse settlementDocs(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + String ym=f.getOrDefault("yearMonth",LocalDate.now().toString().substring(0,7)); + List all=em.createQuery("select s from SettlementMonth s where s.groupId=:g and s.yearMonth=:y",SettlementMonth.class).setParameter("g",u.getGroupId()).setParameter("y",ym).getResultList(); + Map companies=companyNames(u); + String outCategory=f.get("outCategory"); + List filtered=all.stream().filter(s->eq(s.getOutCategory(),outCategory)).sorted(Comparator.comparing((SettlementMonth s)->s.getOutcompanyName()!=null?s.getOutcompanyName():companies.getOrDefault(s.getOutcompanyId(),""),Comparator.nullsLast(String::compareTo))).toList(); + List> list=filtered.stream().map(s->settlementDocMap(s,companies)).toList(); + BigDecimal openAmount=BigDecimal.ZERO, afterAmount=BigDecimal.ZERO, homeAmount=BigDecimal.ZERO, realAmount=BigDecimal.ZERO; + int openCount=0, afterCount=0, homeCount=0; + for(Map r:list){ + openCount+=((Number)r.getOrDefault("openCount",0)).intValue(); + afterCount+=((Number)r.getOrDefault("afterSettleCount",0)).intValue(); + homeCount+=((Number)r.getOrDefault("homeProductCount",0)).intValue(); + openAmount=openAmount.add(nvl((BigDecimal)r.get("openAmount"))); + afterAmount=afterAmount.add(nvl((BigDecimal)r.get("afterSettleAmount"))); + homeAmount=homeAmount.add(nvl((BigDecimal)r.get("homeProductAmount"))); + realAmount=realAmount.add(nvl((BigDecimal)r.get("realSettleAmount"))); + } + Map summary=new LinkedHashMap<>(); + summary.put("openCount",openCount); summary.put("openAmount",openAmount); + summary.put("afterSettleCount",afterCount); summary.put("afterSettleAmount",afterAmount); + summary.put("homeProductCount",homeCount); summary.put("homeProductAmount",homeAmount); + summary.put("realSettleAmount",realAmount); + return ApiResponse.ok(Map.of("yearMonth",ym,"total",list.size(),"list",list,"summary",summary)); + } + @PostMapping("/settlements/docs/issue") @Transactional ApiResponse settlementIssue(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Object raw=d.get("ids"); + List ids=new ArrayList<>(); + if(raw instanceof List list) for(Object o:list) if(o!=null) ids.add(Long.valueOf(o.toString())); + if(ids.isEmpty()&&d.get("id")!=null) ids.add(Long.valueOf(d.get("id").toString())); + if(ids.isEmpty()) return ApiResponse.fail("발행할 항목을 선택하세요."); + int issued=0; + for(Long id:ids){ + SettlementMonth s=em.find(SettlementMonth.class,id); + if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) continue; + s.setIssued(true); s.setStatus("발행"); issued++; + } + return ApiResponse.ok(Map.of("issued",issued)); + } + @PostMapping("/settlements/docs/delete") @Transactional ApiResponse settlementDocsDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + String ym=d.get("yearMonth")==null?null:String.valueOf(d.get("yearMonth")); + Object raw=d.get("ids"); + int deleted=0; + if(raw instanceof List list && !list.isEmpty()){ + for(Object o:list){ + SettlementMonth s=em.find(SettlementMonth.class,Long.valueOf(o.toString())); + if(s==null||!Objects.equals(s.getGroupId(),u.getGroupId())) continue; + if(s.isIssued()){ s.setIssued(false); s.setStatus("READY"); deleted++; } + } + } else if(ym!=null&&!ym.isBlank()){ + List rows=em.createQuery("select s from SettlementMonth s where s.groupId=:g and s.yearMonth=:y and s.issued=true",SettlementMonth.class).setParameter("g",u.getGroupId()).setParameter("y",ym).getResultList(); + for(SettlementMonth s:rows){ s.setIssued(false); s.setStatus("READY"); deleted++; } + } else return ApiResponse.fail("삭제할 정산서를 선택하세요."); + return ApiResponse.ok(Map.of("deleted",deleted)); + } + @GetMapping(value="/settlements/docs/export", produces="text/csv;charset=UTF-8") ResponseEntity settlementDocsExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") Map data=(Map) settlementDocs(u,f).data(); + @SuppressWarnings("unchecked") List> rows=(List>) data.get("list"); + StringBuilder csv=new StringBuilder("\uFEFF출고처,개통,개통금액,후정산,후정산금액,홈상품,홈상품금액,실정산금액,정산서발행,출고처문의,출고처동의,계산서상태,송금상태,비고\n"); + for(Map r:rows) csv.append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("openCount"))).append(',').append(csv(r.get("openAmount"))).append(',') + .append(csv(r.get("afterSettleCount"))).append(',').append(csv(r.get("afterSettleAmount"))).append(',') + .append(csv(r.get("homeProductCount"))).append(',').append(csv(r.get("homeProductAmount"))).append(',').append(csv(r.get("realSettleAmount"))).append(',') + .append(csv(Boolean.TRUE.equals(r.get("issued"))?"발행":"미발행")).append(',').append(csv(r.get("inquiry"))).append(',').append(csv(r.get("agreement"))).append(',') + .append(csv(r.get("invoiceStatus"))).append(',').append(csv(r.get("remittanceStatus"))).append(',').append(csv(r.get("memo"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=settlement-docs.csv").body(csv.toString()); + } + private Map settlementDocMap(SettlementMonth s,Map companies){ + Map r=new LinkedHashMap<>(); + r.put("id",s.getId()); + r.put("outcompanyId",s.getOutcompanyId()); + r.put("outcompanyName",s.getOutcompanyName()!=null&&!s.getOutcompanyName().isBlank()?s.getOutcompanyName():companies.getOrDefault(s.getOutcompanyId(),"-")); + r.put("yearMonth",s.getYearMonth()); r.put("outCategory",s.getOutCategory()); + r.put("openCount",s.getOpenCount()==null?0:s.getOpenCount()); + r.put("openAmount",s.getOpenAmount()==null?BigDecimal.ZERO:s.getOpenAmount()); + r.put("afterSettleCount",s.getAfterSettleCount()==null?0:s.getAfterSettleCount()); + r.put("afterSettleAmount",s.getAfterSettleAmount()==null?BigDecimal.ZERO:s.getAfterSettleAmount()); + r.put("homeProductCount",s.getHomeProductCount()==null?0:s.getHomeProductCount()); + r.put("homeProductAmount",s.getHomeProductAmount()==null?BigDecimal.ZERO:s.getHomeProductAmount()); + r.put("realSettleAmount",s.getRealSettleAmount()==null?BigDecimal.ZERO:s.getRealSettleAmount()); + r.put("issued",s.isIssued()); + r.put("inquiry",blank(s.getInquiry())?"-":s.getInquiry()); + r.put("agreement",blank(s.getAgreement())?"-":s.getAgreement()); + r.put("invoiceStatus",blank(s.getInvoiceStatus())?"-":s.getInvoiceStatus()); + r.put("remittanceStatus",blank(s.getRemittanceStatus())?"-":s.getRemittanceStatus()); + r.put("memo",s.getMemo()); r.put("status",s.getStatus()); + return r; + } + @PutMapping("/settlements") ApiResponse settlementPut(@RequestParam Long outcompanyId,@RequestParam String yearMonth,@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){ + List rows=em.createQuery("select e from SettlementMonth e where e.groupId=:g and e.outcompanyId=:o and e.yearMonth=:y",SettlementMonth.class).setParameter("g",u.getGroupId()).setParameter("o",outcompanyId).setParameter("y",yearMonth).getResultList(); + if(rows.isEmpty()){d.put("outcompanyId",outcompanyId);d.put("yearMonth",yearMonth);return post(SettlementMonth.class,d,u);} return put(SettlementMonth.class,rows.getFirst().getId(),d,u); + } + @GetMapping("/accounts/summary") ApiResponse accountSummary(@AuthenticationPrincipal JwtUser u,@RequestParam(required=false) String yearMonth){PageResponse p=crud.list(AccountEntry.class,u,yearMonth==null?Map.of():Map.of("yearMonth",yearMonth),0,1000);BigDecimal in=BigDecimal.ZERO,out=BigDecimal.ZERO;for(Object o:p.list()){AccountEntry e=(AccountEntry)o;if("IN".equals(e.getType()))in=in.add(nvl(e.getAmount()));else out=out.add(nvl(e.getAmount()));}return ApiResponse.ok(Map.of("in",in,"out",out,"balance",in.subtract(out)));} + @GetMapping({"/reports/stock","/reports/open","/reports/settle","/reports/sales","/reports/day","/reports/month"}) ApiResponse report(HttpServletRequest r,@AuthenticationPrincipal JwtUser u){String path=r.getRequestURI();Class c=path.contains("stock")?Stock.class:path.contains("open")?OpenRecord.class:path.contains("settle")?SettlementMonth.class:AccountEntry.class;return ApiResponse.ok(Map.of("report",path.substring(path.lastIndexOf('/')+1),"total",count(c,u,null)));} + @GetMapping("/settings/company") ApiResponse settingGet(@AuthenticationPrincipal JwtUser u){return list(Setting.class,u,Map.of("settingKey","company"),0,1);} + @PutMapping("/settings/company") ApiResponse settingPut(@AuthenticationPrincipal JwtUser u,@RequestBody Mapd){List rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='company'",Setting.class).setParameter("g",u.getGroupId()).getResultList();d.put("settingKey","company");return rows.isEmpty()?post(Setting.class,d,u):put(Setting.class,rows.getFirst().getId(),d,u);} + @GetMapping("/settings/preference") ApiResponse preferenceGet(@AuthenticationPrincipal JwtUser u){ + return ApiResponse.ok(loadPreference(u.getGroupId())); + } + @PutMapping("/settings/preference") @Transactional ApiResponse preferencePut(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Map current=loadPreference(u.getGroupId()); + mergePreference(current,d); + savePreference(u.getGroupId(),current); + return ApiResponse.ok(current); + } + private Map loadPreference(String groupId){ + Map defaults=defaultPreference(); + List rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='preference'",Setting.class).setParameter("g",groupId).getResultList(); + if(!rows.isEmpty() && !blank(rows.getFirst().getValue())){ + try{ + @SuppressWarnings("unchecked") Map parsed=new com.fasterxml.jackson.databind.ObjectMapper().readValue(rows.getFirst().getValue(), Map.class); + mergePreference(defaults,parsed); + }catch(Exception ignored){} + } else { + // fallback: legacy settings/company JSON + List companyRows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='company'",Setting.class).setParameter("g",groupId).getResultList(); + if(!companyRows.isEmpty() && !blank(companyRows.getFirst().getValue())){ + try{ + @SuppressWarnings("unchecked") Map legacy=new com.fasterxml.jackson.databind.ObjectMapper().readValue(companyRows.getFirst().getValue(), Map.class); + @SuppressWarnings("unchecked") Map company=(Map) defaults.get("company"); + if(legacy.get("name")!=null) company.put("name", legacy.get("name")); + if(legacy.get("phone")!=null) company.put("mobile", legacy.get("phone")); + if(legacy.get("managerName")!=null) company.put("managerName", legacy.get("managerName")); + if(legacy.get("businessName")!=null) company.put("businessName", legacy.get("businessName")); + if(legacy.get("businessNumber")!=null) company.put("businessNumber", legacy.get("businessNumber")); + if(legacy.get("ceo")!=null) company.put("ceo", legacy.get("ceo")); + if(legacy.get("bizType")!=null) company.put("bizType", legacy.get("bizType")); + if(legacy.get("bizItem")!=null) company.put("bizItem", legacy.get("bizItem")); + if(legacy.get("address")!=null) company.put("address", legacy.get("address")); + }catch(Exception ignored){} + } + } + return defaults; + } + private void savePreference(String groupId, Map data){ + String json=writeSettingJson(data); + List rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='preference'",Setting.class).setParameter("g",groupId).getResultList(); + if(rows.isEmpty()){ + Setting s=new Setting(); s.setGroupId(groupId); s.setSettingKey("preference"); s.setValue(json); em.persist(s); + } else { + rows.getFirst().setValue(json); + } + // keep legacy company key in sync for older clients + @SuppressWarnings("unchecked") Map company=(Map) data.getOrDefault("company", Map.of()); + Map legacy=new LinkedHashMap<>(); + legacy.put("name", company.get("name")); + legacy.put("phone", company.get("mobile")); + legacy.put("managerName", company.get("managerName")); + legacy.put("businessName", company.get("businessName")); + legacy.put("businessNumber", company.get("businessNumber")); + legacy.put("ceo", company.get("ceo")); + legacy.put("bizType", company.get("bizType")); + legacy.put("bizItem", company.get("bizItem")); + legacy.put("address", company.get("address")); + String legacyJson=writeSettingJson(legacy); + List companyRows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='company'",Setting.class).setParameter("g",groupId).getResultList(); + if(companyRows.isEmpty()){ + Setting s=new Setting(); s.setGroupId(groupId); s.setSettingKey("company"); s.setValue(legacyJson); em.persist(s); + } else { + companyRows.getFirst().setValue(legacyJson); + } + } + @SuppressWarnings("unchecked") + private void mergePreference(Map target, Map patch){ + if(patch==null) return; + for(Map.Entry e:patch.entrySet()){ + Object val=e.getValue(); + if(val instanceof Map m && target.get(e.getKey()) instanceof Map){ + Map dest=new LinkedHashMap<>((Map) target.get(e.getKey())); + dest.putAll((Map) m); + target.put(e.getKey(), dest); + } else { + target.put(e.getKey(), val); + } + } + } + private Map defaultPreference(){ + Map company=new LinkedHashMap<>(); + company.put("name","에이전트 데모"); + company.put("managerName","김대표"); + company.put("mobile","010-0000-0000"); + company.put("businessName","에이전트 데모"); + company.put("businessNumber","123-45-67890"); + company.put("ceo","김유신"); + company.put("bizType","도소매"); + company.put("bizItem","휴대폰판매"); + company.put("address","대구시 수성구 수성4가 먼길"); + Map accessIps=new LinkedHashMap<>(); + accessIps.put("enabled", false); + accessIps.put("ips", List.of()); + Map masking=new LinkedHashMap<>(); + masking.put("enabled", true); + masking.put("name", true); + masking.put("phone", true); + masking.put("rrn", true); + masking.put("address", false); + Map stock=new LinkedHashMap<>(); + stock.put("duplicateSerialCheck", true); + stock.put("allowNegative", false); + stock.put("autoRecallDays", 0); + Map open=new LinkedHashMap<>(); + open.put("requireUsim", true); + open.put("requirePhone", true); + open.put("defaultOpenHow", ""); + open.put("autoSettle", false); + Map m=new LinkedHashMap<>(); + m.put("company", company); + m.put("accessIps", accessIps); + m.put("masking", masking); + m.put("outCategories", List.of("판매점","도매")); + m.put("stock", stock); + m.put("open", open); + return m; + } + @GetMapping("/staff") ApiResponse staffList(@AuthenticationPrincipal JwtUser u,@RequestParam Map f, + @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size){ + return ApiResponse.ok(staffData(u,f,page,size)); + } + @GetMapping("/incompanies") ApiResponse incompanies(@AuthenticationPrincipal JwtUser u,@RequestParam Map f, + @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size){ + return ApiResponse.ok(incompanyData(u,f,page,size)); + } + @PostMapping("/incompanies") @Transactional ApiResponse incompanyCreate(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + String name=d.get("name")==null?"":String.valueOf(d.get("name")).trim(); + if(name.isBlank()) return ApiResponse.fail("업체명을 입력하세요."); + Company c=new Company(); + c.setGroupId(u.getGroupId()); + c.setType("IN"); + applyIncompanyFields(c,d); + c.setName(name); + if(d.get("active")==null && d.get("status")==null) c.setActive(true); + em.persist(c); + return ApiResponse.ok(incompanyMap(c)); + } + @PutMapping("/incompanies/{id}") @Transactional ApiResponse incompanyUpdate(@PathVariable Long id,@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Company c=em.find(Company.class,id); + if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId()) || !"IN".equals(c.getType())) return ApiResponse.fail("입고처를 찾을 수 없습니다."); + if(d.get("name")!=null){ + String name=String.valueOf(d.get("name")).trim(); + if(name.isBlank()) return ApiResponse.fail("업체명을 입력하세요."); + c.setName(name); + } + applyIncompanyFields(c,d); + return ApiResponse.ok(incompanyMap(c)); + } + @PostMapping("/incompanies/status") @Transactional ApiResponse incompanyStatus(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Object raw=d.get("ids"); + if(!(raw instanceof List ids) || ids.isEmpty()) return ApiResponse.fail("항목을 선택하세요."); + boolean active=!"미사용".equals(String.valueOf(d.getOrDefault("status",""))) && !"false".equalsIgnoreCase(String.valueOf(d.getOrDefault("active",""))); + int updated=0; + for(Object idObj:ids){ + Company c=em.find(Company.class, Long.valueOf(idObj.toString())); + if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId()) || !"IN".equals(c.getType())) continue; + c.setActive(active); updated++; + } + return ApiResponse.ok(Map.of("updated", updated)); + } + @GetMapping(value="/incompanies/export", produces="text/csv;charset=UTF-8") ResponseEntity incompanyExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) incompanyData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF상태,업체명,통신사,전화,팩스,담당자,휴대폰,상호,사업자번호,등록일,비고\n"); + for(Map r:rows) csv.append(csv(r.get("status"))).append(',').append(csv(r.get("name"))).append(',') + .append(csv(r.get("telecom"))).append(',').append(csv(r.get("phone"))).append(',').append(csv(r.get("fax"))).append(',') + .append(csv(r.get("managerName"))).append(',').append(csv(r.get("mobile"))).append(',').append(csv(r.get("businessName"))).append(',') + .append(csv(r.get("businessNumber"))).append(',').append(csv(r.get("createdAt"))).append(',').append(csv(r.get("memo"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=incompanies.csv").body(csv.toString()); + } + private void applyIncompanyFields(Company c, Map d){ + if(d.get("telecom")!=null) c.setTelecom(blank(String.valueOf(d.get("telecom")))?null:String.valueOf(d.get("telecom")).trim()); + if(d.get("phone")!=null) c.setPhone(blank(String.valueOf(d.get("phone")))?null:String.valueOf(d.get("phone")).trim()); + if(d.get("fax")!=null) c.setFax(blank(String.valueOf(d.get("fax")))?null:String.valueOf(d.get("fax")).trim()); + if(d.get("managerName")!=null) c.setManagerName(blank(String.valueOf(d.get("managerName")))?null:String.valueOf(d.get("managerName")).trim()); + if(d.get("mobile")!=null) c.setMobile(blank(String.valueOf(d.get("mobile")))?null:String.valueOf(d.get("mobile")).trim()); + if(d.get("businessName")!=null) c.setBusinessName(blank(String.valueOf(d.get("businessName")))?null:String.valueOf(d.get("businessName")).trim()); + if(d.get("businessNumber")!=null) c.setBusinessNumber(blank(String.valueOf(d.get("businessNumber")))?null:String.valueOf(d.get("businessNumber")).trim()); + if(d.get("memo")!=null) c.setMemo(blank(String.valueOf(d.get("memo")))?null:String.valueOf(d.get("memo")).trim()); + if(d.get("status")!=null) c.setActive(!"미사용".equals(String.valueOf(d.get("status"))) && !"중지".equals(String.valueOf(d.get("status")))); + if(d.get("active")!=null) c.setActive(Boolean.parseBoolean(String.valueOf(d.get("active"))) || "true".equalsIgnoreCase(String.valueOf(d.get("active"))) || "사용".equals(String.valueOf(d.get("active")))); + } + private Map incompanyData(JwtUser u, Map f, int page, int size){ + List all=em.createQuery("select c from Company c where c.groupId=:g and c.type='IN'",Company.class) + .setParameter("g",u.getGroupId()).getResultList(); + String searchType=f.getOrDefault("searchType","업체명"); + String keyword=f.getOrDefault("keyword",""); + List filtered=all.stream().filter(c->{ + if(!blank(keyword)){ + String q=keyword.trim(); + return switch(searchType){ + case "통신사" -> like(c.getTelecom(), q); + case "담당자" -> like(c.getManagerName(), q); + case "상호" -> like(c.getBusinessName(), q); + case "사업자번호" -> like(c.getBusinessNumber(), q); + default -> like(c.getName(), q); + }; + } + return true; + }).sorted(incompanyComparator(f.get("sort"))).toList(); + int sz=Math.min(Math.max(size,1),200); + int fromIdx=Math.min(Math.max(page,0)*sz, filtered.size()); + int toIdx=Math.min(fromIdx+sz, filtered.size()); + List> list=filtered.subList(fromIdx,toIdx).stream().map(this::incompanyMap).toList(); + Map result=new LinkedHashMap<>(); + result.put("total", filtered.size()); + result.put("list", list); + return result; + } + private Comparator incompanyComparator(String sort){ + String field=sort==null?"name,asc":sort; boolean asc=field.endsWith(",asc"); + Comparator c=switch(field.split(",")[0]){ + case "telecom"->Comparator.comparing(Company::getTelecom,Comparator.nullsLast(String::compareTo)); + case "createdAt"->Comparator.comparing(Company::getCreatedAt,Comparator.nullsLast(Instant::compareTo)); + default->Comparator.comparing(Company::getName,Comparator.nullsLast(String::compareTo)); + }; + return (asc?c:c.reversed()).thenComparing(Company::getId); + } + private Map incompanyMap(Company c){ + Map r=new LinkedHashMap<>(); + r.put("id", c.getId()); + r.put("status", c.isActive()?"사용":"미사용"); + r.put("active", c.isActive()); + r.put("name", c.getName()); + r.put("telecom", c.getTelecom()); + r.put("phone", c.getPhone()); + r.put("fax", c.getFax()); + r.put("managerName", c.getManagerName()); + r.put("mobile", c.getMobile()); + r.put("businessName", c.getBusinessName()); + r.put("businessNumber", c.getBusinessNumber()); + r.put("createdAt", c.getCreatedAt()==null?null:c.getCreatedAt().atZone(ZoneId.systemDefault()).toLocalDate().toString()); + r.put("memo", c.getMemo()); + return r; + } + @GetMapping("/outcompanies") ApiResponse outcompanies(@AuthenticationPrincipal JwtUser u,@RequestParam Map f, + @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size){ + return ApiResponse.ok(outcompanyData(u,f,page,size)); + } + @PostMapping("/outcompanies") @Transactional ApiResponse outcompanyCreate(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + String name=d.get("name")==null?"":String.valueOf(d.get("name")).trim(); + if(name.isBlank()) return ApiResponse.fail("출고처명을 입력하세요."); + Company c=new Company(); + c.setGroupId(u.getGroupId()); + c.setType(blank(String.valueOf(d.getOrDefault("type","")))?"OUT":String.valueOf(d.get("type"))); + if(!"OUT".equals(c.getType()) && !"SHOP".equals(c.getType())) c.setType("OUT"); + applyOutcompanyFields(c,d); + c.setName(name); + if(d.get("active")==null && d.get("status")==null) c.setActive(true); + em.persist(c); + return ApiResponse.ok(outcompanyMap(c)); + } + @PutMapping("/outcompanies/{id}") @Transactional ApiResponse outcompanyUpdate(@PathVariable Long id,@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Company c=em.find(Company.class,id); + if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId()) || !List.of("OUT","SHOP").contains(c.getType())) return ApiResponse.fail("출고처를 찾을 수 없습니다."); + if(d.get("name")!=null){ + String name=String.valueOf(d.get("name")).trim(); + if(name.isBlank()) return ApiResponse.fail("출고처명을 입력하세요."); + c.setName(name); + } + applyOutcompanyFields(c,d); + return ApiResponse.ok(outcompanyMap(c)); + } + @PostMapping("/outcompanies/status") @Transactional ApiResponse outcompanyStatus(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Object raw=d.get("ids"); + if(!(raw instanceof List ids) || ids.isEmpty()) return ApiResponse.fail("항목을 선택하세요."); + boolean active=!"미사용".equals(String.valueOf(d.getOrDefault("status",""))) && !"false".equalsIgnoreCase(String.valueOf(d.getOrDefault("active",""))); + int updated=0; + for(Object idObj:ids){ + Company c=em.find(Company.class, Long.valueOf(idObj.toString())); + if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId()) || !List.of("OUT","SHOP").contains(c.getType())) continue; + c.setActive(active); updated++; + } + return ApiResponse.ok(Map.of("updated", updated)); + } + @GetMapping(value="/outcompanies/export", produces="text/csv;charset=UTF-8") ResponseEntity outcompanyExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) outcompanyData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF상태,채널,업체명,P코드,영업담당,전화,담당자,휴대폰,상호,사업자번호,계좌,등록일,비고\n"); + for(Map r:rows) csv.append(csv(r.get("status"))).append(',').append(csv(r.get("channel"))).append(',') + .append(csv(r.get("name"))).append(',').append(csv(r.get("pcode"))).append(',').append(csv(r.get("salesperson"))).append(',') + .append(csv(r.get("phone"))).append(',').append(csv(r.get("managerName"))).append(',').append(csv(r.get("mobile"))).append(',') + .append(csv(r.get("businessName"))).append(',').append(csv(r.get("businessNumber"))).append(',').append(csv(r.get("bankAccount"))).append(',') + .append(csv(r.get("createdAt"))).append(',').append(csv(r.get("memo"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=outcompanies.csv").body(csv.toString()); + } + private void applyOutcompanyFields(Company c, Map d){ + if(d.get("channel")!=null) c.setChannel(blank(String.valueOf(d.get("channel")))?null:String.valueOf(d.get("channel")).trim()); + if(d.get("category")!=null) c.setCategory(blank(String.valueOf(d.get("category")))?null:String.valueOf(d.get("category")).trim()); + if(d.get("pcode")!=null) c.setPcode(blank(String.valueOf(d.get("pcode")))?null:String.valueOf(d.get("pcode")).trim()); + if(d.get("salesperson")!=null) c.setSalesperson(blank(String.valueOf(d.get("salesperson")))?null:String.valueOf(d.get("salesperson")).trim()); + if(d.get("phone")!=null) c.setPhone(blank(String.valueOf(d.get("phone")))?null:String.valueOf(d.get("phone")).trim()); + if(d.get("managerName")!=null) c.setManagerName(blank(String.valueOf(d.get("managerName")))?null:String.valueOf(d.get("managerName")).trim()); + if(d.get("mobile")!=null) c.setMobile(blank(String.valueOf(d.get("mobile")))?null:String.valueOf(d.get("mobile")).trim()); + if(d.get("businessName")!=null) c.setBusinessName(blank(String.valueOf(d.get("businessName")))?null:String.valueOf(d.get("businessName")).trim()); + if(d.get("businessNumber")!=null) c.setBusinessNumber(blank(String.valueOf(d.get("businessNumber")))?null:String.valueOf(d.get("businessNumber")).trim()); + if(d.get("bankAccount")!=null) c.setBankAccount(blank(String.valueOf(d.get("bankAccount")))?null:String.valueOf(d.get("bankAccount")).trim()); + if(d.get("memo")!=null) c.setMemo(blank(String.valueOf(d.get("memo")))?null:String.valueOf(d.get("memo")).trim()); + if(d.get("status")!=null) c.setActive(!"미사용".equals(String.valueOf(d.get("status"))) && !"중지".equals(String.valueOf(d.get("status")))); + if(d.get("active")!=null) c.setActive(Boolean.parseBoolean(String.valueOf(d.get("active"))) || "true".equalsIgnoreCase(String.valueOf(d.get("active"))) || "사용".equals(String.valueOf(d.get("active")))); + } + private Map outcompanyData(JwtUser u, Map f, int page, int size){ + List all=em.createQuery("select c from Company c where c.groupId=:g and c.type in ('OUT','SHOP')",Company.class) + .setParameter("g",u.getGroupId()).getResultList(); + String status=f.getOrDefault("status",""); + String channel=f.getOrDefault("channel",""); + String category=f.getOrDefault("category",""); + String searchType=f.getOrDefault("searchType","출고처명"); + String keyword=f.getOrDefault("keyword",""); + List filtered=all.stream().filter(c->{ + if("사용".equals(status) && !c.isActive()) return false; + if("미사용".equals(status) && c.isActive()) return false; + String ch=blank(c.getChannel())?"기타":c.getChannel(); + if(!eq(ch, channel)) return false; + String cat=blank(c.getCategory())?("SHOP".equals(c.getType())?"판매점":"출고처"):c.getCategory(); + if(!eq(cat, category)) return false; + if(!blank(keyword)){ + String q=keyword.trim(); + return switch(searchType){ + case "P코드" -> like(c.getPcode(), q); + case "담당자" -> like(c.getManagerName(), q); + case "영업담당" -> like(c.getSalesperson(), q); + case "상호" -> like(c.getBusinessName(), q); + case "사업자번호" -> like(c.getBusinessNumber(), q); + default -> like(c.getName(), q); + }; + } + return true; + }).sorted(outcompanyComparator(f.get("sort"))).toList(); + int sz=Math.min(Math.max(size,1),200); + int fromIdx=Math.min(Math.max(page,0)*sz, filtered.size()); + int toIdx=Math.min(fromIdx+sz, filtered.size()); + List> list=filtered.subList(fromIdx,toIdx).stream().map(this::outcompanyMap).toList(); + Map result=new LinkedHashMap<>(); + result.put("total", filtered.size()); + result.put("list", list); + return result; + } + private Comparator outcompanyComparator(String sort){ + String field=sort==null?"name,asc":sort; boolean asc=field.endsWith(",asc"); + Comparator c=switch(field.split(",")[0]){ + case "pcode"->Comparator.comparing(Company::getPcode,Comparator.nullsLast(String::compareTo)); + case "channel"->Comparator.comparing(x->blank(x.getChannel())?"기타":x.getChannel(),Comparator.nullsLast(String::compareTo)); + case "createdAt"->Comparator.comparing(Company::getCreatedAt,Comparator.nullsLast(Instant::compareTo)); + default->Comparator.comparing(Company::getName,Comparator.nullsLast(String::compareTo)); + }; + return (asc?c:c.reversed()).thenComparing(Company::getId); + } + private Map outcompanyMap(Company c){ + Map r=new LinkedHashMap<>(); + r.put("id", c.getId()); + r.put("status", c.isActive()?"사용":"미사용"); + r.put("active", c.isActive()); + r.put("channel", blank(c.getChannel())?"기타":c.getChannel()); + r.put("category", blank(c.getCategory())?("SHOP".equals(c.getType())?"판매점":"출고처"):c.getCategory()); + r.put("name", c.getName()); + r.put("pcode", c.getPcode()); + r.put("salesperson", c.getSalesperson()); + r.put("phone", c.getPhone()); + r.put("managerName", c.getManagerName()); + r.put("mobile", c.getMobile()); + r.put("businessName", c.getBusinessName()); + r.put("businessNumber", c.getBusinessNumber()); + r.put("bankAccount", c.getBankAccount()); + r.put("createdAt", c.getCreatedAt()==null?null:c.getCreatedAt().atZone(ZoneId.systemDefault()).toLocalDate().toString()); + r.put("memo", c.getMemo()); + r.put("type", c.getType()); + return r; + } + @GetMapping("/deliveries") ApiResponse deliveries(@AuthenticationPrincipal JwtUser u,@RequestParam Map f, + @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size){ + return ApiResponse.ok(deliveryData(u,f,page,size)); + } + @PostMapping("/deliveries") @Transactional ApiResponse deliveryCreate(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + String name=d.get("name")==null?"":String.valueOf(d.get("name")).trim(); + if(name.isBlank()) return ApiResponse.fail("업체명을 입력하세요."); + Company c=new Company(); + c.setGroupId(u.getGroupId()); + c.setType("DELIVERY"); + c.setName(name); + if(d.get("phone")!=null) c.setPhone(blank(String.valueOf(d.get("phone")))?null:String.valueOf(d.get("phone")).trim()); + if(d.get("memo")!=null) c.setMemo(blank(String.valueOf(d.get("memo")))?null:String.valueOf(d.get("memo")).trim()); + c.setActive(!"미사용".equals(String.valueOf(d.getOrDefault("status","")))); + if(d.get("active")==null && d.get("status")==null) c.setActive(true); + em.persist(c); + return ApiResponse.ok(deliveryMap(c)); + } + @PutMapping("/deliveries/{id}") @Transactional ApiResponse deliveryUpdate(@PathVariable Long id,@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Company c=em.find(Company.class,id); + if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId()) || !"DELIVERY".equals(c.getType())) return ApiResponse.fail("배송처를 찾을 수 없습니다."); + if(d.get("name")!=null){ + String name=String.valueOf(d.get("name")).trim(); + if(name.isBlank()) return ApiResponse.fail("업체명을 입력하세요."); + c.setName(name); + } + if(d.get("phone")!=null) c.setPhone(blank(String.valueOf(d.get("phone")))?null:String.valueOf(d.get("phone")).trim()); + if(d.get("memo")!=null) c.setMemo(blank(String.valueOf(d.get("memo")))?null:String.valueOf(d.get("memo")).trim()); + if(d.get("status")!=null) c.setActive(!"미사용".equals(String.valueOf(d.get("status")))); + if(d.get("active")!=null) c.setActive(Boolean.parseBoolean(String.valueOf(d.get("active"))) || "true".equalsIgnoreCase(String.valueOf(d.get("active"))) || "사용".equals(String.valueOf(d.get("active")))); + return ApiResponse.ok(deliveryMap(c)); + } + @PostMapping("/deliveries/status") @Transactional ApiResponse deliveryStatus(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Object raw=d.get("ids"); + if(!(raw instanceof List ids) || ids.isEmpty()) return ApiResponse.fail("항목을 선택하세요."); + boolean active=!"미사용".equals(String.valueOf(d.getOrDefault("status",""))); + int updated=0; + for(Object idObj:ids){ + Company c=em.find(Company.class, Long.valueOf(idObj.toString())); + if(c==null || !Objects.equals(c.getGroupId(),u.getGroupId()) || !"DELIVERY".equals(c.getType())) continue; + c.setActive(active); updated++; + } + return ApiResponse.ok(Map.of("updated", updated)); + } + @GetMapping(value="/deliveries/export", produces="text/csv;charset=UTF-8") ResponseEntity deliveryExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) deliveryData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF상태,업체명,연락처,비고,등록일\n"); + for(Map r:rows) csv.append(csv(r.get("status"))).append(',').append(csv(r.get("name"))).append(',') + .append(csv(r.get("phone"))).append(',').append(csv(r.get("memo"))).append(',').append(csv(r.get("createdAt"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=deliveries.csv").body(csv.toString()); + } + private Map deliveryData(JwtUser u, Map f, int page, int size){ + List all=em.createQuery("select c from Company c where c.groupId=:g and c.type='DELIVERY'",Company.class) + .setParameter("g",u.getGroupId()).getResultList(); + String searchType=f.getOrDefault("searchType","업체명"); + String keyword=f.getOrDefault("keyword",""); + List filtered=all.stream().filter(c->{ + if(!blank(keyword)){ + String q=keyword.trim(); + return switch(searchType){ + case "연락처" -> like(c.getPhone(), q); + case "비고" -> like(c.getMemo(), q); + default -> like(c.getName(), q); + }; + } + return true; + }).sorted(deliveryComparator(f.get("sort"))).toList(); + int sz=Math.min(Math.max(size,1),200); + int fromIdx=Math.min(Math.max(page,0)*sz, filtered.size()); + int toIdx=Math.min(fromIdx+sz, filtered.size()); + List> list=filtered.subList(fromIdx,toIdx).stream().map(this::deliveryMap).toList(); + Map result=new LinkedHashMap<>(); + result.put("total", filtered.size()); + result.put("list", list); + return result; + } + private Comparator deliveryComparator(String sort){ + String field=sort==null?"name,asc":sort; boolean asc=field.endsWith(",asc"); + Comparator c=switch(field.split(",")[0]){ + case "createdAt"->Comparator.comparing(Company::getCreatedAt,Comparator.nullsLast(Instant::compareTo)); + case "phone"->Comparator.comparing(Company::getPhone,Comparator.nullsLast(String::compareTo)); + default->Comparator.comparing(Company::getName,Comparator.nullsLast(String::compareTo)); + }; + return (asc?c:c.reversed()).thenComparing(Company::getId); + } + private Map deliveryMap(Company c){ + Map r=new LinkedHashMap<>(); + r.put("id", c.getId()); + r.put("status", c.isActive()?"사용":"미사용"); + r.put("active", c.isActive()); + r.put("name", c.getName()); + r.put("phone", c.getPhone()); + r.put("memo", c.getMemo()); + r.put("createdAt", c.getCreatedAt()==null?null:c.getCreatedAt().atZone(ZoneId.systemDefault()).toLocalDate().toString()); + return r; + } + @PostMapping("/staff") @Transactional ApiResponse staffCreate(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + String userid=d.get("userid")==null?"":String.valueOf(d.get("userid")).trim(); + String password=d.get("password")==null?"":String.valueOf(d.get("password")); + String name=d.get("name")==null?"":String.valueOf(d.get("name")).trim(); + if(userid.isBlank()) return ApiResponse.fail("아이디를 입력하세요."); + if(password.isBlank()) return ApiResponse.fail("비밀번호를 입력하세요."); + if(name.isBlank()) return ApiResponse.fail("이름을 입력하세요."); + if(!em.createQuery("select m from Member m where m.userid=:id",Member.class).setParameter("id",userid).getResultList().isEmpty()) + return ApiResponse.fail("이미 사용 중인 아이디입니다."); + Member m=new Member(); + m.setGroupId(u.getGroupId()); + m.setUserid(userid); + m.setPassword(encoder.encode(password)); + m.setPlainPassword(password); + m.setName(name); + m.setPhone(formatStaffPhone(String.valueOf(d.getOrDefault("phone","")))); + m.setRole("AGENT"); + m.setStaffPermission(blank(String.valueOf(d.getOrDefault("staffPermission","")))?"영업":String.valueOf(d.get("staffPermission"))); + m.setActive(!"중지".equals(String.valueOf(d.getOrDefault("status",""))) && !"false".equalsIgnoreCase(String.valueOf(d.getOrDefault("active","")))); + m.setLoginCount(0); + m.setAssignedOutNames(blank(String.valueOf(d.getOrDefault("assignedOutNames","")))?null:String.valueOf(d.get("assignedOutNames")).trim()); + m.setMemo(blank(String.valueOf(d.getOrDefault("memo","")))?null:String.valueOf(d.get("memo")).trim()); + em.persist(m); + return ApiResponse.ok(staffMap(m)); + } + @PutMapping("/staff/{id}") @Transactional ApiResponse staffUpdate(@PathVariable Long id,@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Member m=em.find(Member.class,id); + if(m==null || !Objects.equals(m.getGroupId(),u.getGroupId()) || !"AGENT".equals(m.getRole())) return ApiResponse.fail("직원을 찾을 수 없습니다."); + if(d.get("name")!=null){ + String name=String.valueOf(d.get("name")).trim(); + if(name.isBlank()) return ApiResponse.fail("이름을 입력하세요."); + m.setName(name); + } + if(d.get("phone")!=null) m.setPhone(formatStaffPhone(String.valueOf(d.get("phone")))); + if(d.get("staffPermission")!=null && !String.valueOf(d.get("staffPermission")).isBlank()) m.setStaffPermission(String.valueOf(d.get("staffPermission"))); + if(d.get("assignedOutNames")!=null) m.setAssignedOutNames(blank(String.valueOf(d.get("assignedOutNames")))?null:String.valueOf(d.get("assignedOutNames")).trim()); + if(d.get("memo")!=null) m.setMemo(blank(String.valueOf(d.get("memo")))?null:String.valueOf(d.get("memo")).trim()); + if(d.get("status")!=null) m.setActive(!"중지".equals(String.valueOf(d.get("status")))); + if(d.get("active")!=null) m.setActive(Boolean.parseBoolean(String.valueOf(d.get("active"))) || "true".equalsIgnoreCase(String.valueOf(d.get("active"))) || "사용".equals(String.valueOf(d.get("active")))); + if(d.get("password")!=null && !String.valueOf(d.get("password")).isBlank()){ + String password=String.valueOf(d.get("password")); + m.setPassword(encoder.encode(password)); + m.setPlainPassword(password); + } + return ApiResponse.ok(staffMap(m)); + } + @GetMapping(value="/staff/export", produces="text/csv;charset=UTF-8") ResponseEntity staffExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + @SuppressWarnings("unchecked") List> rows=(List>) staffData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF상태,권한,아이디,이름,휴대폰,담당출고처,접속수,최종접속,등록일,비고\n"); + for(Map r:rows) csv.append(csv(r.get("status"))).append(',').append(csv(r.get("staffPermission"))).append(',') + .append(csv(r.get("userid"))).append(',').append(csv(r.get("name"))).append(',').append(csv(r.get("phone"))).append(',') + .append(csv(r.get("assignedOutNames"))).append(',').append(csv(r.get("loginCount"))).append(',') + .append(csv(r.get("lastLoginAt"))).append(',').append(csv(r.get("createdAt"))).append(',').append(csv(r.get("memo"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=staff.csv").body(csv.toString()); + } + private Map staffData(JwtUser u, Map f, int page, int size){ + List all=em.createQuery("select m from Member m where m.groupId=:g and m.role='AGENT'",Member.class) + .setParameter("g",u.getGroupId()).getResultList(); + String status=f.getOrDefault("status",""); + String keyword=f.getOrDefault("keyword",""); + List filtered=all.stream().filter(m->{ + if("사용".equals(status) && !m.isActive()) return false; + if("중지".equals(status) && m.isActive()) return false; + if(!blank(keyword)){ + String q=keyword.trim().toLowerCase(); + boolean hit=(m.getName()!=null && m.getName().toLowerCase().contains(q)) + || (m.getUserid()!=null && m.getUserid().toLowerCase().contains(q)); + if(!hit) return false; + } + return true; + }).sorted(staffComparator(f.get("sort"))).toList(); + int sz=Math.min(Math.max(size,1),200); + int fromIdx=Math.min(Math.max(page,0)*sz, filtered.size()); + int toIdx=Math.min(fromIdx+sz, filtered.size()); + List> list=filtered.subList(fromIdx,toIdx).stream().map(this::staffMap).toList(); + Map result=new LinkedHashMap<>(); + result.put("total", filtered.size()); + result.put("list", list); + return result; + } + private Comparator staffComparator(String sort){ + String field=sort==null?"createdAt,asc":sort; boolean asc=field.endsWith(",asc"); + Comparator c=switch(field.split(",")[0]){ + case "staffPermission"->Comparator.comparing(m->blank(m.getStaffPermission())?"영업":m.getStaffPermission(),Comparator.nullsLast(String::compareTo)); + case "userid"->Comparator.comparing(Member::getUserid,Comparator.nullsLast(String::compareTo)); + case "name"->Comparator.comparing(Member::getName,Comparator.nullsLast(String::compareTo)); + case "loginCount"->Comparator.comparing(m->m.getLoginCount()==null?0:m.getLoginCount()); + case "lastLoginAt"->Comparator.comparing(Member::getLastLoginAt,Comparator.nullsLast(LocalDateTime::compareTo)); + default->Comparator.comparing(Member::getCreatedAt,Comparator.nullsLast(Instant::compareTo)); + }; + // 대표 first + Comparator ownerFirst=(a,b)->{ + boolean ao="대표".equals(a.getStaffPermission()); + boolean bo="대표".equals(b.getStaffPermission()); + if(ao==bo) return 0; + return ao?-1:1; + }; + return ownerFirst.thenComparing(asc?c:c.reversed()).thenComparing(Member::getId); + } + private Map staffMap(Member m){ + Map r=new LinkedHashMap<>(); + r.put("id", m.getId()); + r.put("status", m.isActive()?"사용":"중지"); + r.put("active", m.isActive()); + r.put("staffPermission", blank(m.getStaffPermission())?("demo".equals(m.getUserid())?"대표":"영업"):m.getStaffPermission()); + r.put("userid", m.getUserid()); + r.put("name", m.getName()); + r.put("phone", formatStaffPhone(m.getPhone())); + r.put("assignedOutNames", m.getAssignedOutNames()); + r.put("loginCount", m.getLoginCount()==null?0:m.getLoginCount()); + r.put("lastLoginAt", m.getLastLoginAt()==null?null:m.getLastLoginAt().format(java.time.format.DateTimeFormatter.ofPattern("MM-dd HH:mm"))); + r.put("createdAt", m.getCreatedAt()==null?null:m.getCreatedAt().atZone(ZoneId.systemDefault()).toLocalDate().toString()); + r.put("memo", m.getMemo()); + r.put("isOwner", "대표".equals(r.get("staffPermission")) || "demo".equals(m.getUserid())); + return r; + } + private String formatStaffPhone(String phone){ + if(phone==null || phone.isBlank()) return ""; + String p=phone.replaceAll("[^0-9]",""); + if(p.length()==11) return p.substring(0,3)+"-"+p.substring(3,7)+"-"+p.substring(7); + if(p.length()==10) return p.substring(0,3)+"-"+p.substring(3,6)+"-"+p.substring(6); + return phone; + } + @GetMapping("/settings/agentshop") ApiResponse agentShopSettingGet(@AuthenticationPrincipal JwtUser u){ + return ApiResponse.ok(loadAgentShopSetting(u.getGroupId())); + } + @PutMapping("/settings/agentshop") @Transactional ApiResponse agentShopSettingPut(@AuthenticationPrincipal JwtUser u,@RequestBody Map d){ + Map current=loadAgentShopSetting(u.getGroupId()); + current.putAll(d); + String json=writeSettingJson(current); + List rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='agentshop'",Setting.class).setParameter("g",u.getGroupId()).getResultList(); + if(rows.isEmpty()){ + Setting s=new Setting(); s.setGroupId(u.getGroupId()); s.setSettingKey("agentshop"); s.setValue(json); em.persist(s); + } else { + rows.getFirst().setValue(json); + } + return ApiResponse.ok(current); + } + private Map loadAgentShopSetting(String groupId){ + Map defaults=defaultAgentShopSetting(); + List rows=em.createQuery("select e from Setting e where e.groupId=:g and e.settingKey='agentshop'",Setting.class).setParameter("g",groupId).getResultList(); + if(rows.isEmpty()||blank(rows.getFirst().getValue())) return defaults; + try{ + @SuppressWarnings("unchecked") Map parsed=new com.fasterxml.jackson.databind.ObjectMapper().readValue(rows.getFirst().getValue(), Map.class); + defaults.putAll(parsed); + }catch(Exception ignored){} + return defaults; + } + private Map defaultAgentShopSetting(){ + Map m=new LinkedHashMap<>(); + m.put("access",true); + m.put("policy",true); m.put("pds",true); m.put("returnPhone",true); m.put("indoc",true); + m.put("scan",true); m.put("scanReceiveSms",false); m.put("scanReceivePhones",""); + m.put("scanSendSms",false); m.put("scanSenderPhone","1600-3903"); + m.put("scanCompleteMsg","스캔신청서가 완료 처리되었습니다."); + m.put("scanHoldMsg","스캔신청서가 보류 처리되었습니다."); + m.put("stock",true); m.put("stockPeriod","always"); + m.put("open",true); m.put("settle",true); m.put("farebox",true); + m.put("shopUrl","https://shop.bizcare.co.kr"); + return m; + } + private String writeSettingJson(Map m){ + try{ return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(m); } + catch(Exception e){ return "{}"; } + } + @DeleteMapping({"/scans/{id}","/indocs/{id}","/returns/{id}","/fareboxes/{id}","/accounts/{id}","/sms/{id}","/cs/{id}"}) ApiResponse commonDel(HttpServletRequest r,@PathVariable Long id,@AuthenticationPrincipal JwtUser u){return del(type(r.getRequestURI()),id,u);} + @PutMapping({"/schedules/{id}","/accounts/{id}","/sms/{id}"}) ApiResponse commonPut2(HttpServletRequest r,@PathVariable Long id,@RequestBody Mapd,@AuthenticationPrincipal JwtUser u){return put(type(r.getRequestURI()),id,d,u);} + @GetMapping("/basic/phones") ApiResponse phones(@AuthenticationPrincipal JwtUser u,@RequestParam(required=false,defaultValue="") String q){return list(PhoneModel.class,u,q.isBlank()?Map.of():Map.of("modelName",q),0,100);} + @GetMapping("/basic/usims") ApiResponse usims(@AuthenticationPrincipal JwtUser u,@RequestParam(required=false,defaultValue="") String q){return list(Stock.class,u,Map.of("gubun","유심"),0,100);} + @GetMapping("/basic/plans") ApiResponse plans(@AuthenticationPrincipal JwtUser u,@RequestParam(required=false,defaultValue="") String telecom){return list(PlanMaster.class,u,telecom==null||telecom.isBlank()?Map.of():Map.of("telecom",telecom),0,100);} + @GetMapping("/basic/petnames") ApiResponse petnames(@AuthenticationPrincipal JwtUser u,@RequestParam(required=false,defaultValue="") String q){return list(Company.class,u,q.isBlank()?Map.of():Map.of("name",q),0,100);} + @GetMapping("/stocks/have-own") ApiResponse haveOwn(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + return ApiResponse.ok(haveOwnData(u, f)); + } + @GetMapping(value="/stocks/have-own/export", produces="text/csv;charset=UTF-8") ResponseEntity haveOwnExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + Map data=haveOwnData(u,f); + @SuppressWarnings("unchecked") List models=(List) data.get("models"); + @SuppressWarnings("unchecked") List> rows=(List>) data.get("rows"); + StringBuilder csv=new StringBuilder("\uFEFF채널,영업사원,출고처,전체"); + for(String m:models) csv.append(',').append(csv(m)); + csv.append('\n'); + for(Map r:rows) { + csv.append(csv(r.get("channel"))).append(',').append(csv(r.get("salesperson"))).append(',') + .append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("total"))); + @SuppressWarnings("unchecked") Map counts=(Map) r.get("counts"); + for(String m:models) csv.append(',').append(csv(counts.getOrDefault(m,0)==0?"-":counts.get(m))); + csv.append('\n'); + } + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=have-own.csv").body(csv.toString()); + } + private Map haveOwnData(JwtUser u, Map f) { + Map companies=companyNames(u); + boolean includeColor="true".equalsIgnoreCase(f.getOrDefault("includeColor","false")); + List stocks=em.createQuery("select s from Stock s where s.groupId=:g",Stock.class).setParameter("g",u.getGroupId()).getResultList(); + List held=stocks.stream().filter(s->{ + if(!Set.of("IN","OUT","RECOVERY").contains(s.getState()==null?"":s.getState())) return false; + if(!eq(s.getGubun(),f.get("gubun"))) return false; + if(!eqId(s.getIncompanyId(),f.get("incompanyId"))) return false; + if(!eqId(s.getOutcompanyId(),f.get("outcompanyId"))) return false; + return true; + }).toList(); + LinkedHashSet modelSet=new LinkedHashSet<>(); + Map> rowMap=new LinkedHashMap<>(); + for(Stock s:held) { + String channel=haveOwnChannel(s); + String salesperson=blank(s.getSalesperson())?( "IN".equals(s.getState())?"본점":"-"):s.getSalesperson(); + String outName; + Long outId=s.getOutcompanyId(); + if(outId!=null) outName=companies.getOrDefault(outId,"-"); + else if("IN".equals(s.getState())) outName="본점"; + else outName="-"; + String modelKey=blank(s.getModel())?"-":s.getModel(); + if(includeColor) { + String color=blank(s.getColor())?"-":s.getColor(); + modelKey=modelKey+" / "+color; + } + modelSet.add(modelKey); + String rowKey=channel+"|"+salesperson+"|"+(outId==null?"0":outId)+"|"+outName; + Map row=rowMap.computeIfAbsent(rowKey,k->{ + Map r=new LinkedHashMap<>(); + r.put("channel",channel); r.put("salesperson",salesperson); + r.put("outcompanyId",outId); r.put("outcompanyName",outName); + r.put("total",0); r.put("counts",new LinkedHashMap()); + return r; + }); + @SuppressWarnings("unchecked") Map counts=(Map) row.get("counts"); + counts.put(modelKey, counts.getOrDefault(modelKey,0)+1); + row.put("total", ((Integer)row.get("total"))+1); + } + List models=modelSet.stream().sorted().toList(); + List> rows=new ArrayList<>(rowMap.values()); + rows.sort(Comparator + .comparing((Map r)->String.valueOf(r.get("channel"))) + .thenComparing(r->String.valueOf(r.get("salesperson"))) + .thenComparing(r->String.valueOf(r.get("outcompanyName")))); + // row spans + for(int i=0;i result=new LinkedHashMap<>(); + result.put("asOf", LocalDate.now().toString()); + result.put("title", LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy년 MM월 dd일"))+" 현재보유재고"); + result.put("models", models); + result.put("rows", rows); + result.put("totalQty", held.size()); + return result; + } + private String haveOwnChannel(Stock s) { + if("도매".equals(s.getOutCategory())) return "도매"; + if("IN".equals(s.getState()) && s.getOutcompanyId()==null) return "본점"; + if("본사".equals(s.getSource()) || "판매점".equals(s.getOutCategory())) return "본점"; + return blank(s.getOutCategory())?"본점":s.getOutCategory(); + } + @GetMapping("/stocks/have-model") ApiResponse haveModel(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + return ApiResponse.ok(haveModelData(u, f)); + } + @GetMapping(value="/stocks/have-model/export", produces="text/csv;charset=UTF-8") ResponseEntity haveModelExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + Map data=haveModelData(u,f); + boolean totalsOnly="true".equalsIgnoreCase(f.getOrDefault("totalsOnly","false")); + @SuppressWarnings("unchecked") List holders=(List) data.get("holders"); + @SuppressWarnings("unchecked") List> rows=(List>) data.get("rows"); + StringBuilder csv=new StringBuilder("\uFEFF종류,모델명,색상,전체,불량,본점"); + if(!totalsOnly) for(String h:holders) csv.append(',').append(csv(h)); + csv.append('\n'); + for(Map r:rows) { + csv.append(csv(r.get("gubun"))).append(',').append(csv(r.get("model"))).append(',') + .append(csv(r.get("color"))).append(',').append(csv(dashZero(r.get("total")))).append(',') + .append(csv(dashZero(r.get("defect")))).append(',').append(csv(dashZero(r.get("hq")))); + if(!totalsOnly) { + @SuppressWarnings("unchecked") Map counts=(Map) r.get("counts"); + for(String h:holders) csv.append(',').append(csv(dashZero(counts.getOrDefault(h,0)))); + } + csv.append('\n'); + } + String filename=totalsOnly?"have-model-totals.csv":"have-model.csv"; + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename="+filename).body(csv.toString()); + } + private Object dashZero(Object v){ if(v==null) return "-"; if(v instanceof Number n && n.intValue()==0) return "-"; return v; } + private Map haveModelData(JwtUser u, Map f) { + Map companies=companyNames(u); + List stocks=em.createQuery("select s from Stock s where s.groupId=:g",Stock.class).setParameter("g",u.getGroupId()).getResultList(); + List held=stocks.stream().filter(s->{ + if(!Set.of("IN","OUT","RECOVERY").contains(s.getState()==null?"":s.getState())) return false; + if(!eqId(s.getIncompanyId(),f.get("incompanyId"))) return false; + if(!like(s.getModel(),f.get("model"))) return false; + return true; + }).toList(); + LinkedHashSet holderSet=new LinkedHashSet<>(); + Map> rowMap=new LinkedHashMap<>(); + for(Stock s:held) { + String gubun=blank(s.getGubun())?"-":s.getGubun(); + String model=blank(s.getModel())?"-":s.getModel(); + String color=blank(s.getColor())?"-":s.getColor(); + String rowKey=gubun+"|"+model+"|"+color; + Map row=rowMap.computeIfAbsent(rowKey,k->{ + Map r=new LinkedHashMap<>(); + r.put("gubun",gubun); r.put("model",model); r.put("color",color); + r.put("total",0); r.put("defect",0); r.put("hq",0); + r.put("counts",new LinkedHashMap()); + return r; + }); + row.put("total", ((Integer)row.get("total"))+1); + if("불량".equals(s.getCond())) row.put("defect", ((Integer)row.get("defect"))+1); + boolean isHq="IN".equals(s.getState()) && s.getOutcompanyId()==null; + if(isHq) { + row.put("hq", ((Integer)row.get("hq"))+1); + } else { + String holder; + if(s.getOutcompanyId()!=null) holder=companies.getOrDefault(s.getOutcompanyId(),"-"); + else if(!blank(s.getSalesperson())) holder=s.getSalesperson(); + else holder="-"; + holderSet.add(holder); + @SuppressWarnings("unchecked") Map counts=(Map) row.get("counts"); + counts.put(holder, counts.getOrDefault(holder,0)+1); + } + } + List holders=holderSet.stream().sorted().toList(); + List> rows=new ArrayList<>(rowMap.values()); + rows.sort(Comparator + .comparing((Map r)->String.valueOf(r.get("gubun"))) + .thenComparing(r->String.valueOf(r.get("model"))) + .thenComparing(r->String.valueOf(r.get("color")))); + for(int i=0;i result=new LinkedHashMap<>(); + result.put("asOf", LocalDate.now().toString()); + result.put("title", LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy년 MM월 dd일"))+" 현재보유재고"); + result.put("holders", holders); + result.put("rows", rows); + result.put("totalQty", held.size()); + return result; + } + @GetMapping("/stocks/turnover") ApiResponse turnover(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + return ApiResponse.ok(turnoverData(u, f)); + } + @GetMapping(value="/stocks/turnover/export", produces="text/csv;charset=UTF-8") ResponseEntity turnoverExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + Map data=turnoverData(u,f); + String mode=String.valueOf(data.getOrDefault("mode","outcompany")); + @SuppressWarnings("unchecked") List> rows=(List>) data.get("rows"); + StringBuilder csv=new StringBuilder("\uFEFF순위,"); + if("model".equals(mode)) csv.append("모델명,개통수,현재 재고수,회전율,회전일,개통예상\n"); + else csv.append("출고처,개통수,현재 재고수,회전율,회전일,개통예상,영업담당자\n"); + for(Map r:rows) { + csv.append(csv(r.get("rank"))).append(','); + if("model".equals(mode)) { + csv.append(csv(r.get("model"))).append(',').append(csv(r.get("openCount"))).append(',') + .append(csv(r.get("stockCount"))).append(',').append(csv(r.get("turnoverRate"))).append(',') + .append(csv(r.get("turnoverDays"))).append(',').append(csv(r.get("expectedOpen"))).append('\n'); + } else { + csv.append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("openCount"))).append(',') + .append(csv(r.get("stockCount"))).append(',').append(csv(r.get("turnoverRate"))).append(',') + .append(csv(r.get("turnoverDays"))).append(',').append(csv(r.get("expectedOpen"))).append(',') + .append(csv(r.get("salesperson"))).append('\n'); + } + } + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=turnover.csv").body(csv.toString()); + } + private Map turnoverData(JwtUser u, Map f) { + String mode=f.getOrDefault("mode","outcompany"); + Map companies=companyNames(u); + List stocks=em.createQuery("select s from Stock s where s.groupId=:g",Stock.class).setParameter("g",u.getGroupId()).getResultList() + .stream().filter(s->!"유심".equals(s.getGubun())).toList(); + List opens=em.createQuery("select o from OpenRecord o where o.groupId=:g",OpenRecord.class).setParameter("g",u.getGroupId()).getResultList(); + Map agg=new LinkedHashMap<>(); + Map salesMap=new LinkedHashMap<>(); + for(OpenRecord o:opens) { + String key="model".equals(mode) + ? turnoverModelKey(o.getPmodel(), o.getPcolor()) + : (o.getOutcompanyId()==null?"본점":companies.getOrDefault(o.getOutcompanyId(),"-")); + int[] v=agg.computeIfAbsent(key,k->new int[]{0,0}); + v[0]++; + } + for(Stock s:stocks) { + if("OPEN".equals(s.getState())) { + String key="model".equals(mode) + ? turnoverModelKey(s.getModel(), s.getColor()) + : (s.getOutcompanyId()==null?"본점":companies.getOrDefault(s.getOutcompanyId(),"-")); + int[] v=agg.computeIfAbsent(key,k->new int[]{0,0}); + v[0]++; + if(!blank(s.getSalesperson())) salesMap.putIfAbsent(key, s.getSalesperson()); + } + if(Set.of("IN","OUT","RECOVERY").contains(s.getState()==null?"":s.getState())) { + String key="model".equals(mode) + ? turnoverModelKey(s.getModel(), s.getColor()) + : (s.getOutcompanyId()==null?"본점":companies.getOrDefault(s.getOutcompanyId(),"-")); + if(!"model".equals(mode) && "IN".equals(s.getState()) && s.getOutcompanyId()!=null) continue; + int[] v=agg.computeIfAbsent(key,k->new int[]{0,0}); + v[1]++; + if(!blank(s.getSalesperson())) salesMap.putIfAbsent(key, s.getSalesperson()); + } + } + List> rows=new ArrayList<>(); + for(var e:agg.entrySet()) { + int openCount=e.getValue()[0], stockCount=e.getValue()[1]; + if(openCount==0 && stockCount==0) continue; + double rate=stockCount>0 ? Math.round(openCount * 10000.0 / stockCount) / 100.0 : 0; + int days=openCount>0 ? (int)Math.round(stockCount * 10.0 / openCount) : 0; + Map row=new LinkedHashMap<>(); + if("model".equals(mode)) row.put("model", e.getKey()); + else row.put("outcompanyName", e.getKey()); + row.put("openCount", openCount); + row.put("stockCount", stockCount); + row.put("turnoverRate", rate); + row.put("turnoverDays", days); + // 10일 기준 개통수를 월(30일) 예상으로 환산 + row.put("expectedOpen", openCount == 0 ? 0 : openCount * 3); + row.put("salesperson", salesMap.getOrDefault(e.getKey(),"-")); + rows.add(row); + } + String sort=f.getOrDefault("sort","turnoverRate,desc"); + boolean asc=sort.endsWith(",asc"); + String field=sort.split(",")[0]; + rows.sort((a,b)->{ + int cmp=switch(field){ + case "outcompanyName"->String.valueOf(a.get("outcompanyName")).compareTo(String.valueOf(b.get("outcompanyName"))); + case "model"->String.valueOf(a.get("model")).compareTo(String.valueOf(b.get("model"))); + case "openCount"->Integer.compare((Integer)a.get("openCount"),(Integer)b.get("openCount")); + case "stockCount"->Integer.compare((Integer)a.get("stockCount"),(Integer)b.get("stockCount")); + case "turnoverDays"->Integer.compare((Integer)a.get("turnoverDays"),(Integer)b.get("turnoverDays")); + case "expectedOpen"->Integer.compare((Integer)a.get("expectedOpen"),(Integer)b.get("expectedOpen")); + case "salesperson"->String.valueOf(a.get("salesperson")).compareTo(String.valueOf(b.get("salesperson"))); + default->Double.compare(((Number)a.get("turnoverRate")).doubleValue(),((Number)b.get("turnoverRate")).doubleValue()); + }; + return asc?cmp:-cmp; + }); + for(int i=0;i result=new LinkedHashMap<>(); + result.put("mode", mode); + result.put("info", info); + result.put("rows", rows); + result.put("total", rows.size()); + return result; + } + private String turnoverModelKey(String model, String color) { + String m=blank(model)?"-":model.trim(); + if(blank(color)) return m; + return m+" "+color.trim(); + } + @GetMapping("/reports/mon-open-stats") ApiResponse monOpenStats(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + return ApiResponse.ok(monOpenStatsData(u,f)); + } + @GetMapping(value="/reports/mon-open-stats/export", produces="text/csv;charset=UTF-8") ResponseEntity monOpenStatsExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + Map data=monOpenStatsData(u,f); + String mode=String.valueOf(data.getOrDefault("mode","outcompany")); + String firstCol=switch(mode){ case "incompany"->"입고처"; case "model"->"모델명"; default->"출고처"; }; + @SuppressWarnings("unchecked") List> rows=(List>) data.get("rows"); + StringBuilder csv=new StringBuilder("\uFEFF").append(firstCol); + for(int m=1;m<=12;m++) csv.append(',').append(m).append("월"); + csv.append(",합계\n"); + for(Map r:rows){ + csv.append(csv(r.get("name"))); + @SuppressWarnings("unchecked") List months=(List) r.get("months"); + for(int m=0;m<12;m++) csv.append(',').append(csv(dashZero(months.get(m)))); + csv.append(',').append(csv(dashZero(r.get("total")))).append('\n'); + } + @SuppressWarnings("unchecked") Map totals=(Map) data.get("totals"); + if(totals!=null){ + csv.append(csv("합계")); + @SuppressWarnings("unchecked") List months=(List) totals.get("months"); + for(int m=0;m<12;m++) csv.append(',').append(csv(dashZero(months.get(m)))); + csv.append(',').append(csv(dashZero(totals.get("total")))).append('\n'); + } + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=mon-open-stats.csv").body(csv.toString()); + } + @GetMapping("/reports/open-charts") ApiResponse openCharts(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + return ApiResponse.ok(openChartsData(u,f)); + } + private Map openChartsData(JwtUser u, Map f){ + String mode=f.getOrDefault("mode","outcompany"); + return switch(mode){ + case "outcompany-annual"->openChartAnnual(u,f,"outcompany"); + case "sales-annual"->openChartSalesAnnual(u,f); + case "sales"->openChartPeriod(u,f,"sales"); + default->openChartPeriod(u,f,"outcompany"); + }; + } + private Map openChartPeriod(JwtUser u, Map f, String dim){ + LocalDate from=parseDate(f.get("from"), LocalDate.now().minusDays(29)); + LocalDate to=parseDate(f.get("to"), LocalDate.now()); + if(to.isBefore(from)){ LocalDate t=from; from=to; to=t; } + Map companies=companyNames(u); + Map counts=new LinkedHashMap<>(); + if("outcompany".equals(dim)){ + List outs=em.createQuery("select c from Company c where c.groupId=:g and c.type in ('OUT','SHOP') order by c.name",Company.class) + .setParameter("g",u.getGroupId()).getResultList(); + for(Company c:outs) counts.putIfAbsent(blank(c.getName())?"-":c.getName(),0); + } + List opens=em.createQuery("select o from OpenRecord o where o.groupId=:g and o.opendate between :a and :b",OpenRecord.class) + .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList(); + for(OpenRecord o:opens){ + if(!eq(o.getTelecom(),f.get("telecom"))) continue; + String key; + if("sales".equals(dim)){ + if(blank(o.getSalesperson())) continue; + key=o.getSalesperson().trim(); + } else { + key=o.getOutcompanyId()==null?"본점":companies.getOrDefault(o.getOutcompanyId(),"-"); + if("-".equals(key)) continue; + } + counts.put(key, counts.getOrDefault(key,0)+1); + } + if("sales".equals(dim) && counts.isEmpty()){ + // keep empty chart categories from known salespeople on stocks + List names=em.createQuery("select distinct s.salesperson from Stock s where s.groupId=:g and s.salesperson is not null and s.salesperson<>''",String.class) + .setParameter("g",u.getGroupId()).getResultList(); + for(String n:names) counts.putIfAbsent(n,0); + } + String sort=f.getOrDefault("sort","name"); + List> entries=new ArrayList<>(counts.entrySet()); + if("count".equals(sort) || "value".equals(sort)) + entries.sort((a,b)->{ + int c=Integer.compare(b.getValue(),a.getValue()); + return c!=0?c:a.getKey().compareTo(b.getKey()); + }); + else entries.sort(Map.Entry.comparingByKey()); + List> items=new ArrayList<>(); + for(var e:entries){ + Map row=new LinkedHashMap<>(); + row.put("name", e.getKey()); + row.put("value", e.getValue()); + items.add(row); + } + Map result=new LinkedHashMap<>(); + result.put("mode", "sales".equals(dim)?"sales":"outcompany"); + result.put("chartType", "bar"); + result.put("title", "sales".equals(dim)?"영업사원별 개통":"출고처별 개통"); + result.put("subtitle", "기간 : "+from+" ~ "+to); + result.put("from", from.toString()); + result.put("to", to.toString()); + result.put("items", items); + return result; + } + private Map openChartAnnual(JwtUser u, Map f, String dim){ + int year=parseInt(f.get("year"), LocalDate.now().getYear()); + LocalDate from=LocalDate.of(year,1,1); + LocalDate to=LocalDate.of(year,12,31); + Map companies=companyNames(u); + Map seriesMap=new LinkedHashMap<>(); + if("outcompany".equals(dim)){ + List outs=em.createQuery("select c from Company c where c.groupId=:g and c.type in ('OUT','SHOP') order by c.name",Company.class) + .setParameter("g",u.getGroupId()).getResultList(); + for(Company c:outs) seriesMap.putIfAbsent(blank(c.getName())?"-":c.getName(), new int[12]); + } + List opens=em.createQuery("select o from OpenRecord o where o.groupId=:g and o.opendate between :a and :b",OpenRecord.class) + .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList(); + for(OpenRecord o:opens){ + if(!eq(o.getTelecom(),f.get("telecom"))) continue; + String key=o.getOutcompanyId()==null?"본점":companies.getOrDefault(o.getOutcompanyId(),"-"); + if("-".equals(key)) continue; + int[] arr=seriesMap.computeIfAbsent(key,k->new int[12]); + arr[o.getOpendate().getMonthValue()-1]++; + } + List categories=new ArrayList<>(); + for(int m=1;m<=12;m++) categories.add(m+"월"); + List> series=new ArrayList<>(); + for(var e:seriesMap.entrySet()){ + Map s=new LinkedHashMap<>(); + s.put("name", e.getKey()); + List data=new ArrayList<>(12); + for(int v:e.getValue()) data.add(v); + s.put("data", data); + series.add(s); + } + series.sort(Comparator.comparing(s->String.valueOf(s.get("name")))); + Map result=new LinkedHashMap<>(); + result.put("mode", "outcompany-annual"); + result.put("chartType", "line"); + result.put("title", "출고처별 개통"); + result.put("subtitle", year+"년"); + result.put("year", year); + result.put("categories", categories); + result.put("series", series); + return result; + } + private Map openChartSalesAnnual(JwtUser u, Map f){ + int year=parseInt(f.get("year"), LocalDate.now().getYear()); + int month=parseInt(f.get("month"), LocalDate.now().getMonthValue()); + if(month<1||month>12) month=LocalDate.now().getMonthValue(); + LocalDate end=LocalDate.of(year, month, 1); + LocalDate start=end.minusMonths(11); + LocalDate from=start.withDayOfMonth(1); + LocalDate to=end.withDayOfMonth(end.lengthOfMonth()); + Map seriesMap=new LinkedHashMap<>(); + List opens=em.createQuery("select o from OpenRecord o where o.groupId=:g and o.opendate between :a and :b",OpenRecord.class) + .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList(); + for(OpenRecord o:opens){ + if(!eq(o.getTelecom(),f.get("telecom"))) continue; + if(blank(o.getSalesperson())) continue; + String key=o.getSalesperson().trim(); + int[] arr=seriesMap.computeIfAbsent(key,k->new int[12]); + int idx=monthIndexInWindow(o.getOpendate(), start); + if(idx>=0) arr[idx]++; + } + if(seriesMap.isEmpty()){ + List names=em.createQuery("select distinct s.salesperson from Stock s where s.groupId=:g and s.salesperson is not null and s.salesperson<>''",String.class) + .setParameter("g",u.getGroupId()).getResultList(); + for(String n:names) seriesMap.putIfAbsent(n, new int[12]); + } + List categories=new ArrayList<>(); + for(int i=0;i<12;i++){ + LocalDate d=start.plusMonths(i); + categories.add(d.getMonthValue()+"월"); + } + int[] sum=new int[12]; + int nSeries=Math.max(seriesMap.size(),1); + List> series=new ArrayList<>(); + List keys=new ArrayList<>(seriesMap.keySet()); + keys.sort(String::compareTo); + for(String key:keys){ + int[] arr=seriesMap.get(key); + Map s=new LinkedHashMap<>(); + s.put("name", key); + s.put("type", "bar"); + List data=new ArrayList<>(12); + for(int i=0;i<12;i++){ data.add(arr[i]); sum[i]+=arr[i]; } + s.put("data", data); + series.add(s); + } + List avg=new ArrayList<>(12); + for(int i=0;i<12;i++) avg.add(Math.round(sum[i]*100.0/nSeries)/100.0); + Map avgSeries=new LinkedHashMap<>(); + avgSeries.put("name", "평균"); + avgSeries.put("type", "line"); + avgSeries.put("data", avg); + series.add(avgSeries); + Map result=new LinkedHashMap<>(); + result.put("mode", "sales-annual"); + result.put("chartType", "combo"); + result.put("title", "영업사원별 개통"); + result.put("subtitle", year+"년 "+String.format("%02d", month)+"월"); + result.put("year", year); + result.put("month", month); + result.put("categories", categories); + result.put("series", series); + return result; + } + private int monthIndexInWindow(LocalDate date, LocalDate windowStart){ + if(date==null) return -1; + int idx=(date.getYear()-windowStart.getYear())*12+(date.getMonthValue()-windowStart.getMonthValue()); + return (idx>=0 && idx<12)?idx:-1; + } + private LocalDate parseDate(String v, LocalDate fallback){ + if(blank(v)) return fallback; + try{ return LocalDate.parse(v); } catch(Exception e){ return fallback; } + } + private int parseInt(String v, int fallback){ + if(blank(v)) return fallback; + try{ return Integer.parseInt(v); } catch(Exception e){ return fallback; } + } + private Map monOpenStatsData(JwtUser u, Map f){ + String mode=f.getOrDefault("mode","outcompany"); + int year; + try{ year=Integer.parseInt(f.getOrDefault("year",String.valueOf(LocalDate.now().getYear()))); } + catch(Exception e){ year=LocalDate.now().getYear(); } + LocalDate from=LocalDate.of(year,1,1); + LocalDate to=LocalDate.of(year,12,31); + Map companies=companyNames(u); + Map agg=new LinkedHashMap<>(); + if("outcompany".equals(mode)){ + List outs=em.createQuery("select c from Company c where c.groupId=:g and c.type in ('OUT','SHOP') order by c.name",Company.class) + .setParameter("g",u.getGroupId()).getResultList(); + for(Company c:outs) agg.putIfAbsent(c.getName()==null?"-":c.getName(), new int[12]); + } else if("incompany".equals(mode)){ + List ins=em.createQuery("select c from Company c where c.groupId=:g and c.type='IN' order by c.name",Company.class) + .setParameter("g",u.getGroupId()).getResultList(); + for(Company c:ins) agg.putIfAbsent(c.getName()==null?"-":c.getName(), new int[12]); + } + List opens=em.createQuery("select o from OpenRecord o where o.groupId=:g and o.opendate between :a and :b",OpenRecord.class) + .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList(); + for(OpenRecord o:opens){ + if(!eq(o.getTelecom(),f.get("telecom"))) continue; + if(!eq(o.getOutCategory(),f.get("outCategory"))) continue; + if(!eqId(o.getIncompanyId(),f.get("incompanyId"))) continue; + if(!eqId(o.getOutcompanyId(),f.get("outcompanyId"))) continue; + String key; + if("incompany".equals(mode)){ + if(o.getIncompanyId()==null) continue; + key=companies.getOrDefault(o.getIncompanyId(),"-"); + } else if("model".equals(mode)){ + if(blank(o.getPmodel())) continue; + key=o.getPmodel().trim(); + } else { + key=o.getOutcompanyId()==null?"본점":companies.getOrDefault(o.getOutcompanyId(),"-"); + } + if("-".equals(key)) continue; + int[] months=agg.computeIfAbsent(key,k->new int[12]); + int mi=o.getOpendate().getMonthValue()-1; + months[mi]++; + } + List> rows=new ArrayList<>(); + int[] totalMonths=new int[12]; + int grand=0; + List keys=new ArrayList<>(agg.keySet()); + keys.sort(String::compareTo); + long id=1; + for(String key:keys){ + int[] months=agg.get(key); + int rowTotal=0; + List monthList=new ArrayList<>(12); + for(int i=0;i<12;i++){ monthList.add(months[i]); rowTotal+=months[i]; totalMonths[i]+=months[i]; } + if("model".equals(mode) && rowTotal==0) continue; + grand+=rowTotal; + Map row=new LinkedHashMap<>(); + row.put("id", id++); + row.put("name", key); + row.put("months", monthList); + row.put("total", rowTotal); + rows.add(row); + } + List totalMonthList=new ArrayList<>(12); + for(int v:totalMonths) totalMonthList.add(v); + Map totals=new LinkedHashMap<>(); + totals.put("id", -1L); + totals.put("name", "합계"); + totals.put("months", totalMonthList); + totals.put("total", grand); + Map result=new LinkedHashMap<>(); + result.put("mode", mode); + result.put("year", year); + result.put("nameLabel", switch(mode){ case "incompany"->"입고처"; case "model"->"모델명"; default->"출고처"; }); + result.put("rows", rows); + result.put("totals", totals); + return result; + } + @GetMapping("/reports/open-stats") ApiResponse openStats(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + String mode=f.getOrDefault("mode","daily"); + if("monthly".equals(mode)) return ApiResponse.ok(openStatsMonthly(u,f)); + return ApiResponse.ok(openStatsDaily(u,f)); + } + @GetMapping(value="/reports/open-stats/export", produces="text/csv;charset=UTF-8") ResponseEntity openStatsExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + String mode=f.getOrDefault("mode","daily"); + Map data="monthly".equals(mode)?openStatsMonthly(u,f):openStatsDaily(u,f); + @SuppressWarnings("unchecked") List> rows=(List>) data.get("rows"); + String firstCol="monthly".equals(mode)?"월":"일자"; + StringBuilder csv=new StringBuilder("\uFEFF").append(firstCol).append(",신규,MNP,보상,기변,에이징,가개통,선불개통,합계,정산금액,후정산,후정산금액,철회,마진\n"); + for(Map r:rows) appendOpenStatCsv(csv,r,"monthly".equals(mode)?"label":"label"); + @SuppressWarnings("unchecked") Map totals=(Map) data.get("totals"); + if(totals!=null){ totals=new LinkedHashMap<>(totals); totals.put("label","합계"); appendOpenStatCsv(csv,totals,"label"); } + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=open-stats.csv").body(csv.toString()); + } + private void appendOpenStatCsv(StringBuilder csv,Map r,String labelKey){ + csv.append(csv(r.get(labelKey))).append(',').append(csv(r.get("newCount"))).append(',').append(csv(r.get("mnpCount"))).append(',') + .append(csv(r.get("rewardCount"))).append(',').append(csv(r.get("changeCount"))).append(',').append(csv(r.get("agingCount"))).append(',') + .append(csv(r.get("tempCount"))).append(',').append(csv(r.get("prepaidCount"))).append(',').append(csv(r.get("totalCount"))).append(',') + .append(csv(r.get("settleAmount"))).append(',').append(csv(r.get("afterCount"))).append(',').append(csv(r.get("afterAmount"))).append(',') + .append(csv(r.get("withdrawCount"))).append(',').append(csv(r.get("margin"))).append('\n'); + } + @GetMapping("/reports/home-stats") ApiResponse homeStats(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + String mode=f.getOrDefault("mode","daily"); + if("monthly".equals(mode)) return ApiResponse.ok(homeStatsMonthly(u,f)); + return ApiResponse.ok(homeStatsDaily(u,f)); + } + @GetMapping(value="/reports/home-stats/export", produces="text/csv;charset=UTF-8") ResponseEntity homeStatsExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + String mode=f.getOrDefault("mode","daily"); + Map data="monthly".equals(mode)?homeStatsMonthly(u,f):homeStatsDaily(u,f); + @SuppressWarnings("unchecked") List> rows=(List>) data.get("rows"); + String firstCol="monthly".equals(mode)?"월":"일자"; + StringBuilder csv=new StringBuilder("\uFEFF").append(firstCol).append(",인터넷,TV,집전화,인터넷전화,신용카드,홈IoT,동판,기타,후결합,재약정,소계,합계,정산금액\n"); + for(Map r:rows) appendHomeStatCsv(csv,r); + @SuppressWarnings("unchecked") Map totals=(Map) data.get("totals"); + if(totals!=null){ totals=new LinkedHashMap<>(totals); totals.put("label","합계"); appendHomeStatCsv(csv,totals); } + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=home-stats.csv").body(csv.toString()); + } + private void appendHomeStatCsv(StringBuilder csv,Map r){ + csv.append(csv(r.get("label"))).append(',').append(csv(r.get("internet"))).append(',').append(csv(r.get("tv"))).append(',') + .append(csv(r.get("homePhone"))).append(',').append(csv(r.get("voip"))).append(',').append(csv(r.get("creditCard"))).append(',') + .append(csv(r.get("homeIot"))).append(',').append(csv(r.get("dongpan"))).append(',').append(csv(r.get("etc"))).append(',') + .append(csv(r.get("postBind"))).append(',').append(csv(r.get("renew"))).append(',').append(csv(r.get("subtotal"))).append(',') + .append(csv(r.get("saleCount"))).append(',').append(csv(r.get("settleAmount"))).append('\n'); + } + private Map homeStatsDaily(JwtUser u,Map f){ + int year=Integer.parseInt(f.getOrDefault("year",String.valueOf(LocalDate.now().getYear()))); + int month=Integer.parseInt(f.getOrDefault("month",String.valueOf(LocalDate.now().getMonthValue()))); + LocalDate from=LocalDate.of(year,month,1); + LocalDate to=from.withDayOfMonth(from.lengthOfMonth()); + Map> byDay=new LinkedHashMap<>(); + for(int d=1;d<=to.getDayOfMonth();d++) byDay.put(d,emptyHomeStatRow(d+"일("+weekdayKo(from.withDayOfMonth(d))+")",d)); + for(HomeSale h:loadHomeSales(u,f,from,to)){ + Map target=byDay.get(h.getInstallDate().getDayOfMonth()); + if(target!=null) applyHomeSale(target,h); + } + List> rows=new ArrayList<>(byDay.values()); + Map out=new LinkedHashMap<>(); + out.put("mode","daily"); out.put("year",year); out.put("month",month); + out.put("title",year+"년 "+month+"월"); out.put("rows",rows); out.put("totals",sumHomeStatRows(rows)); + out.put("today",LocalDate.now().getYear()==year&&LocalDate.now().getMonthValue()==month?LocalDate.now().getDayOfMonth():null); + return out; + } + private Map homeStatsMonthly(JwtUser u,Map f){ + int year=Integer.parseInt(f.getOrDefault("year",String.valueOf(LocalDate.now().getYear()))); + LocalDate from=LocalDate.of(year,1,1); + LocalDate to=LocalDate.of(year,12,31); + Map> byMonth=new LinkedHashMap<>(); + for(int m=1;m<=12;m++) byMonth.put(m,emptyHomeStatRow(m+"월",m)); + for(HomeSale h:loadHomeSales(u,f,from,to)){ + Map target=byMonth.get(h.getInstallDate().getMonthValue()); + if(target!=null) applyHomeSale(target,h); + } + List> rows=new ArrayList<>(byMonth.values()); + Map out=new LinkedHashMap<>(); + out.put("mode","monthly"); out.put("year",year); + out.put("title",year+"년"); out.put("rows",rows); out.put("totals",sumHomeStatRows(rows)); + out.put("currentMonth",LocalDate.now().getYear()==year?LocalDate.now().getMonthValue():null); + return out; + } + private List loadHomeSales(JwtUser u,Map f,LocalDate from,LocalDate to){ + List all=em.createQuery("select h from HomeSale h where h.groupId=:g and h.installDate between :a and :b",HomeSale.class) + .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList(); + return all.stream().filter(h->{ + if(!"완료".equals(h.getStatus())&&!"판매완료".equals(h.getStatus())) return false; + if(!eq(h.getTelecom(),f.get("telecom"))) return false; + if(!eqId(h.getIncompanyId(),f.get("incompanyId"))) return false; + if(!eqId(h.getOutcompanyId(),f.get("outcompanyId"))) return false; + if(!eq(h.getOutCategory(),f.get("outCategory"))) return false; + return h.getInstallDate()!=null; + }).toList(); + } + private void applyHomeSale(Map target,HomeSale h){ + addInt(target,"saleCount",1); + addDec(target,"settleAmount",nvl(h.getSettleAmount())); + String products=h.getProducts()==null?"":h.getProducts(); + int productHits=0; + for(String raw:products.split("[,/|]")){ + String token=raw==null?"":raw.trim(); + if(token.isEmpty()) continue; + String bucket=homeProductBucket(token); + if(bucket==null) continue; + addInt(target,bucket,1); + productHits++; + } + if(productHits==0){ addInt(target,"etc",1); productHits=1; } + addInt(target,"subtotal",productHits); + } + private String homeProductBucket(String token){ + String t=token.replace(" ",""); + if(t.equalsIgnoreCase("인터넷")) return "internet"; + if(t.equalsIgnoreCase("TV")||t.equals("티비")) return "tv"; + if(t.equals("집전화")) return "homePhone"; + if(t.equals("인터넷전화")) return "voip"; + if(t.equals("신용카드")) return "creditCard"; + if(t.equalsIgnoreCase("홈IoT")||t.equalsIgnoreCase("홈IOT")||t.equals("홈아이오티")) return "homeIot"; + if(t.equals("동판")) return "dongpan"; + if(t.equals("후결합")) return "postBind"; + if(t.equals("재약정")) return "renew"; + return "etc"; + } + private Map emptyHomeStatRow(String label,int key){ + Map r=new LinkedHashMap<>(); + r.put("key",key); r.put("label",label); + r.put("internet",0); r.put("tv",0); r.put("homePhone",0); r.put("voip",0); r.put("creditCard",0); + r.put("homeIot",0); r.put("dongpan",0); r.put("etc",0); r.put("postBind",0); r.put("renew",0); + r.put("subtotal",0); r.put("saleCount",0); r.put("settleAmount",BigDecimal.ZERO); + return r; + } + private void mergeHomeStat(Map target,Map src){ + for(String k:List.of("internet","tv","homePhone","voip","creditCard","homeIot","dongpan","etc","postBind","renew","subtotal","saleCount")) + addInt(target,k,((Number)src.getOrDefault(k,0)).intValue()); + addDec(target,"settleAmount",nvl((BigDecimal)src.get("settleAmount"))); + } + private Map sumHomeStatRows(List> rows){ + Map t=emptyHomeStatRow("합계",0); + for(Map r:rows) mergeHomeStat(t,r); + return t; + } + @GetMapping("/reports/outcompany-stats") ApiResponse outcompanyStats(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + return ApiResponse.ok(outcompanyStatsData(u,f)); + } + @GetMapping(value="/reports/outcompany-stats/export", produces="text/csv;charset=UTF-8") ResponseEntity outcompanyStatsExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f){ + Map data=outcompanyStatsData(u,f); + @SuppressWarnings("unchecked") List> rows=(List>) data.get("rows"); + StringBuilder csv=new StringBuilder("\uFEFF지역,출고처,개통,개통금액,후정산,후정산금액,홈상품,홈상품금액,실정산금액,마진\n"); + for(Map r:rows){ + csv.append(csv(r.get("region"))).append(',').append(csv(r.get("name"))).append(',') + .append(csv(r.get("openCount"))).append(',').append(csv(r.get("openAmount"))).append(',') + .append(csv(r.get("afterCount"))).append(',').append(csv(r.get("afterAmount"))).append(',') + .append(csv(r.get("homeCount"))).append(',').append(csv(r.get("homeAmount"))).append(',') + .append(csv(r.get("realSettleAmount"))).append(',').append(csv(r.get("margin"))).append('\n'); + } + @SuppressWarnings("unchecked") Map totals=(Map) data.get("totals"); + if(totals!=null){ + csv.append(csv("합계")).append(',').append(csv("")).append(',') + .append(csv(totals.get("openCount"))).append(',').append(csv(totals.get("openAmount"))).append(',') + .append(csv(totals.get("afterCount"))).append(',').append(csv(totals.get("afterAmount"))).append(',') + .append(csv(totals.get("homeCount"))).append(',').append(csv(totals.get("homeAmount"))).append(',') + .append(csv(totals.get("realSettleAmount"))).append(',').append(csv(totals.get("margin"))).append('\n'); + } + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=outcompany-stats.csv").body(csv.toString()); + } + private Map outcompanyStatsData(JwtUser u,Map f){ + int year=Integer.parseInt(f.getOrDefault("year",String.valueOf(LocalDate.now().getYear()))); + int month=Integer.parseInt(f.getOrDefault("month",String.valueOf(LocalDate.now().getMonthValue()))); + LocalDate from=LocalDate.of(year,month,1); + LocalDate to=from.withDayOfMonth(from.lengthOfMonth()); + Map companies=companyNames(u); + Map> byOut=new LinkedHashMap<>(); + List opens=em.createQuery("select o from OpenRecord o where o.groupId=:g and o.opendate between :a and :b",OpenRecord.class) + .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList(); + for(OpenRecord o:opens){ + if(o.getOutcompanyId()==null) continue; + if(!eq(o.getTelecom(),f.get("telecom"))) continue; + if(!eqId(o.getIncompanyId(),f.get("incompanyId"))) continue; + if(!eq(o.getOutCategory(),f.get("outCategory"))) continue; + Map row=outcompanyStatRow(byOut,o.getOutcompanyId(),companies); + addInt(row,"openCount",1); + addDec(row,"openAmount",nvl(o.getSellprice())); + addDec(row,"margin",nvl(o.getDealermargin1()).add(nvl(o.getDealermargin2()))); + } + List afters=em.createQuery("select b from AfterBalance b where b.groupId=:g and b.basedate between :a and :b",AfterBalance.class) + .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList(); + for(AfterBalance b:afters){ + if(b.getOutcompanyId()==null) continue; + if(!eq(b.getTelecom(),f.get("telecom"))) continue; + if(!eq(b.getOutCategory(),f.get("outCategory"))) continue; + Map row=outcompanyStatRow(byOut,b.getOutcompanyId(),companies); + addInt(row,"afterCount",1); + addDec(row,"afterAmount",nvl(b.getAmount())); + } + List homes=em.createQuery("select h from HomeSale h where h.groupId=:g and h.installDate between :a and :b",HomeSale.class) + .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList(); + for(HomeSale h:homes){ + if(h.getOutcompanyId()==null) continue; + if(!"완료".equals(h.getStatus())&&!"판매완료".equals(h.getStatus())) continue; + if(!eq(h.getTelecom(),f.get("telecom"))) continue; + if(!eqId(h.getIncompanyId(),f.get("incompanyId"))) continue; + if(!eq(h.getOutCategory(),f.get("outCategory"))) continue; + Map row=outcompanyStatRow(byOut,h.getOutcompanyId(),companies); + addInt(row,"homeCount",1); + addDec(row,"homeAmount",nvl(h.getSettleAmount())); + } + List> rows=new ArrayList<>(); + for(Map r:byOut.values()){ + BigDecimal real=nvl((BigDecimal)r.get("openAmount")).add(nvl((BigDecimal)r.get("afterAmount"))).add(nvl((BigDecimal)r.get("homeAmount"))); + r.put("realSettleAmount",real); + rows.add(r); + } + rows.sort(Comparator + .comparing((Map r)->String.valueOf(r.get("region")),Comparator.nullsLast(String::compareTo)) + .thenComparing(r->String.valueOf(r.get("name")),Comparator.nullsLast(String::compareTo))); + Map totals=emptyOutcompanyStatRow("","합계",0L); + for(Map r:rows){ + addInt(totals,"openCount",((Number)r.getOrDefault("openCount",0)).intValue()); + addInt(totals,"afterCount",((Number)r.getOrDefault("afterCount",0)).intValue()); + addInt(totals,"homeCount",((Number)r.getOrDefault("homeCount",0)).intValue()); + addDec(totals,"openAmount",nvl((BigDecimal)r.get("openAmount"))); + addDec(totals,"afterAmount",nvl((BigDecimal)r.get("afterAmount"))); + addDec(totals,"homeAmount",nvl((BigDecimal)r.get("homeAmount"))); + addDec(totals,"realSettleAmount",nvl((BigDecimal)r.get("realSettleAmount"))); + addDec(totals,"margin",nvl((BigDecimal)r.get("margin"))); + } + Map out=new LinkedHashMap<>(); + out.put("year",year); out.put("month",month); + out.put("title",year+"년 "+month+"월"); + out.put("total",rows.size()); out.put("rows",rows); out.put("totals",totals); + return out; + } + private Map outcompanyStatRow(Map> byOut,Long outId,Map companies){ + return byOut.computeIfAbsent(outId,id->{ + String full=companies.getOrDefault(id,"-"); + String[] parts=splitRegionName(full); + return emptyOutcompanyStatRow(parts[0],parts[1],id); + }); + } + private Map emptyOutcompanyStatRow(String region,String name,Long id){ + Map r=new LinkedHashMap<>(); + r.put("id",id); r.put("region",region); r.put("name",name); + r.put("openCount",0); r.put("openAmount",BigDecimal.ZERO); + r.put("afterCount",0); r.put("afterAmount",BigDecimal.ZERO); + r.put("homeCount",0); r.put("homeAmount",BigDecimal.ZERO); + r.put("realSettleAmount",BigDecimal.ZERO); r.put("margin",BigDecimal.ZERO); + return r; + } + private String[] splitRegionName(String full){ + if(full==null||full.isBlank()) return new String[]{"-","-"}; + String t=full.trim(); + int sp=t.indexOf(' '); + if(sp>0){ + String left=t.substring(0,sp).trim(); + String right=t.substring(sp+1).trim(); + if(!left.isEmpty()&&!right.isEmpty()&&(left.matches(".*(시|군|구)$")||List.of("대구","경산","서울","부산","인천","광주","대전","울산","세종","수원","성남","용인","창원","청주","전주","포항","제주").contains(left))) + return new String[]{left,right}; + } + return new String[]{"-",t}; + } + private Map openStatsDaily(JwtUser u,Map f){ + int year=Integer.parseInt(f.getOrDefault("year",String.valueOf(LocalDate.now().getYear()))); + int month=Integer.parseInt(f.getOrDefault("month",String.valueOf(LocalDate.now().getMonthValue()))); + LocalDate from=LocalDate.of(year,month,1); + LocalDate to=from.withDayOfMonth(from.lengthOfMonth()); + Map> byDay=new LinkedHashMap<>(); + for(int d=1;d<=to.getDayOfMonth();d++) byDay.put(d,emptyOpenStatRow(d+"일("+weekdayKo(from.withDayOfMonth(d))+")",d)); + accumulateOpenStats(u,f,from,to,(date,row)->{ + Map target=byDay.get(date.getDayOfMonth()); + if(target!=null) mergeOpenStat(target,row); + },(date,afterCount,afterAmount)->{ + Map target=byDay.get(date.getDayOfMonth()); + if(target!=null){ addInt(target,"afterCount",afterCount); addDec(target,"afterAmount",afterAmount); } + },(date,withdrawCount)->{ + Map target=byDay.get(date.getDayOfMonth()); + if(target!=null) addInt(target,"withdrawCount",withdrawCount); + }); + List> rows=new ArrayList<>(byDay.values()); + Map totals=sumOpenStatRows(rows); + Map out=new LinkedHashMap<>(); + out.put("mode","daily"); out.put("year",year); out.put("month",month); + out.put("title",year+"년 "+month+"월"); out.put("rows",rows); out.put("totals",totals); + out.put("today",LocalDate.now().getYear()==year&&LocalDate.now().getMonthValue()==month?LocalDate.now().getDayOfMonth():null); + return out; + } + private Map openStatsMonthly(JwtUser u,Map f){ + int year=Integer.parseInt(f.getOrDefault("year",String.valueOf(LocalDate.now().getYear()))); + LocalDate from=LocalDate.of(year,1,1); + LocalDate to=LocalDate.of(year,12,31); + Map> byMonth=new LinkedHashMap<>(); + for(int m=1;m<=12;m++) byMonth.put(m,emptyOpenStatRow(m+"월",m)); + accumulateOpenStats(u,f,from,to,(date,row)->{ + Map target=byMonth.get(date.getMonthValue()); + if(target!=null) mergeOpenStat(target,row); + },(date,afterCount,afterAmount)->{ + Map target=byMonth.get(date.getMonthValue()); + if(target!=null){ addInt(target,"afterCount",afterCount); addDec(target,"afterAmount",afterAmount); } + },(date,withdrawCount)->{ + Map target=byMonth.get(date.getMonthValue()); + if(target!=null) addInt(target,"withdrawCount",withdrawCount); + }); + List> rows=new ArrayList<>(byMonth.values()); + Map totals=sumOpenStatRows(rows); + Map out=new LinkedHashMap<>(); + out.put("mode","monthly"); out.put("year",year); + out.put("title",year+"년"); out.put("rows",rows); out.put("totals",totals); + out.put("currentMonth",LocalDate.now().getYear()==year?LocalDate.now().getMonthValue():null); + return out; + } + private interface OpenDayConsumer { void accept(LocalDate date,Map row); } + private interface AfterDayConsumer { void accept(LocalDate date,int count,BigDecimal amount); } + private interface WithdrawDayConsumer { void accept(LocalDate date,int count); } + private void accumulateOpenStats(JwtUser u,Map f,LocalDate from,LocalDate to, + OpenDayConsumer openFn,AfterDayConsumer afterFn,WithdrawDayConsumer withdrawFn){ + Map companies=companyNames(u); + List opens=em.createQuery("select o from OpenRecord o where o.groupId=:g and o.opendate between :a and :b",OpenRecord.class) + .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList(); + for(OpenRecord o:opens){ + if(!matchesOpenStat(o,f,companies)) continue; + Map row=emptyOpenStatRow("",0); + String bucket=openStatBucket(o.getOpenhow()); + if(bucket!=null) addInt(row,bucket,1); + addInt(row,"totalCount",1); + addDec(row,"settleAmount",nvl(o.getSellprice())); + addDec(row,"margin",nvl(o.getDealermargin1()).add(nvl(o.getDealermargin2()))); + openFn.accept(o.getOpendate(),row); + } + List afters=em.createQuery("select b from AfterBalance b where b.groupId=:g and b.basedate between :a and :b",AfterBalance.class) + .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList(); + for(AfterBalance b:afters){ + if(b.getBasedate()==null) continue; + if(!eqId(b.getOutcompanyId(),f.get("outcompanyId"))) continue; + if(!eq(b.getTelecom(),f.get("telecom"))) continue; + if(!eq(b.getOutCategory(),f.get("outCategory"))) continue; + afterFn.accept(b.getBasedate(),1,nvl(b.getAmount())); + } + List withdraws=em.createQuery("select w from OpenWithdrawal w where w.groupId=:g and w.withdrawDate between :a and :b",OpenWithdrawal.class) + .setParameter("g",u.getGroupId()).setParameter("a",from).setParameter("b",to).getResultList(); + for(OpenWithdrawal w:withdraws){ + if(w.getWithdrawDate()==null) continue; + if(!eqId(w.getOutcompanyId(),f.get("outcompanyId"))) continue; + withdrawFn.accept(w.getWithdrawDate(),1); + } + } + private boolean matchesOpenStat(OpenRecord o,Map f,Map companies){ + if(!eq(o.getTelecom(),f.get("telecom"))) return false; + if(!eqId(o.getIncompanyId(),f.get("incompanyId"))) return false; + if(!eqId(o.getOutcompanyId(),f.get("outcompanyId"))) return false; + if(!eq(o.getOutCategory(),f.get("outCategory"))) return false; + return true; + } + private String openStatBucket(String openhow){ + if(blank(openhow)) return null; + return switch(openhow){ + case "신규"->"newCount"; + case "번호이동","MNP"->"mnpCount"; + case "보상"->"rewardCount"; + case "기변"->"changeCount"; + case "에이징","메이징"->"agingCount"; + case "가개통"->"tempCount"; + case "선불개통"->"prepaidCount"; + default->null; + }; + } + private Map emptyOpenStatRow(String label,int key){ + Map r=new LinkedHashMap<>(); + r.put("key",key); r.put("label",label); + r.put("newCount",0); r.put("mnpCount",0); r.put("rewardCount",0); r.put("changeCount",0); + r.put("agingCount",0); r.put("tempCount",0); r.put("prepaidCount",0); r.put("totalCount",0); + r.put("settleAmount",BigDecimal.ZERO); r.put("afterCount",0); r.put("afterAmount",BigDecimal.ZERO); + r.put("withdrawCount",0); r.put("margin",BigDecimal.ZERO); + return r; + } + private void mergeOpenStat(Map target,Map src){ + for(String k:List.of("newCount","mnpCount","rewardCount","changeCount","agingCount","tempCount","prepaidCount","totalCount","afterCount","withdrawCount")) + addInt(target,k,((Number)src.getOrDefault(k,0)).intValue()); + for(String k:List.of("settleAmount","afterAmount","margin")) + addDec(target,k,nvl((BigDecimal)src.get(k))); + } + private Map sumOpenStatRows(List> rows){ + Map t=emptyOpenStatRow("합계",0); + for(Map r:rows) mergeOpenStat(t,r); + return t; + } + private void addInt(Map m,String k,int v){ m.put(k,((Number)m.getOrDefault(k,0)).intValue()+v); } + private void addDec(Map m,String k,BigDecimal v){ m.put(k,nvl((BigDecimal)m.get(k)).add(nvl(v))); } + private String weekdayKo(LocalDate d){ + return switch(d.getDayOfWeek()){ + case MONDAY->"월"; case TUESDAY->"화"; case WEDNESDAY->"수"; case THURSDAY->"목"; + case FRIDAY->"금"; case SATURDAY->"토"; case SUNDAY->"일"; + }; + } + @GetMapping("/stocks/report-day") ApiResponse reportDay(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + if(blank(f.get("year")) || blank(f.get("month"))) return ApiResponse.fail("년월을 선택하세요."); + return ApiResponse.ok(reportDayData(u, f)); + } + @GetMapping(value="/stocks/report-day/export", produces="text/csv;charset=UTF-8") ResponseEntity reportDayExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + if(blank(f.get("year")) || blank(f.get("month"))) return ResponseEntity.badRequest().body("년월을 선택하세요."); + Map data=reportDayData(u,f); + @SuppressWarnings("unchecked") List> rows=(List>) data.get("rows"); + StringBuilder csv=new StringBuilder("\uFEFF일자,입고,출고,회수,반품,분실,개통,판매,합계\n"); + for(Map r:rows) { + csv.append(csv(r.get("day"))).append(',').append(csv(r.get("inCount"))).append(',') + .append(csv(r.get("outCount"))).append(',').append(csv(r.get("recoveryCount"))).append(',') + .append(csv(r.get("returnCount"))).append(',').append(csv(r.get("lossCount"))).append(',') + .append(csv(r.get("openCount"))).append(',').append(csv(r.get("sellCount"))).append(',') + .append(csv(r.get("total"))).append('\n'); + } + @SuppressWarnings("unchecked") Map totals=(Map) data.get("totals"); + if(totals!=null) { + csv.append(csv("합계")).append(',').append(csv(totals.get("inCount"))).append(',') + .append(csv(totals.get("outCount"))).append(',').append(csv(totals.get("recoveryCount"))).append(',') + .append(csv(totals.get("returnCount"))).append(',').append(csv(totals.get("lossCount"))).append(',') + .append(csv(totals.get("openCount"))).append(',').append(csv(totals.get("sellCount"))).append(',') + .append(csv(totals.get("total"))).append('\n'); + } + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=report-day.csv").body(csv.toString()); + } + private Map reportDayData(JwtUser u, Map f) { + int year=Integer.parseInt(f.get("year")); + int month=Integer.parseInt(f.get("month")); + LocalDate from=LocalDate.of(year, month, 1); + LocalDate to=from.withDayOfMonth(from.lengthOfMonth()); + LocalDate minAllowed=LocalDate.now().minusYears(1).withDayOfMonth(1); + if(from.isBefore(minAllowed)) from=minAllowed; + Map companies=companyNames(u); + Long inFilter=blank(f.get("incompanyId"))?null:Long.valueOf(f.get("incompanyId")); + List stocks=em.createQuery("select s from Stock s where s.groupId=:g",Stock.class).setParameter("g",u.getGroupId()).getResultList(); + Map days=new LinkedHashMap<>(); // [in,out,recovery,return,loss,open,sell] + for(int d=1;d<=to.getDayOfMonth();d++) days.put(d, new int[]{0,0,0,0,0,0,0}); + for(Stock s:stocks) { + if(inFilter!=null && !Objects.equals(s.getIncompanyId(), inFilter)) continue; + bumpDay(days, s.getIndate(), from, to, 0); + bumpDay(days, s.getOutdate(), from, to, 1); + if("RECOVERY".equals(s.getState())) bumpDay(days, s.getProcessDate()!=null?s.getProcessDate():s.getIndate(), from, to, 2); + bumpDay(days, s.getReturndate(), from, to, 3); + bumpDay(days, s.getLossdate(), from, to, 4); + bumpDay(days, s.getSelldate(), from, to, 6); + if("RETURN".equals(s.getState()) && s.getReturndate()==null) bumpDay(days, s.getProcessDate(), from, to, 3); + if("LOSS".equals(s.getState()) && s.getLossdate()==null) bumpDay(days, s.getProcessDate(), from, to, 4); + if("SELL".equals(s.getState()) && s.getSelldate()==null) bumpDay(days, s.getProcessDate(), from, to, 6); + if("OUT".equals(s.getState()) && s.getOutdate()==null) bumpDay(days, s.getProcessDate(), from, to, 1); + if("OPEN".equals(s.getState())) bumpDay(days, s.getProcessDate()!=null?s.getProcessDate():s.getIndate(), from, to, 5); + } + // OpenRecord는 stock OPEN과 중복될 수 있어 stock 기준만 사용 + List> rows=new ArrayList<>(); + int[] sum=new int[7]; + for(var e:days.entrySet()) { + int[] c=e.getValue(); + int total=c[0]+c[1]+c[2]+c[3]+c[4]+c[5]+c[6]; + if(total==0) continue; + for(int i=0;i<7;i++) sum[i]+=c[i]; + Map row=new LinkedHashMap<>(); + row.put("day", String.format("%04d-%02d-%02d", year, month, e.getKey())); + row.put("inCount", c[0]); row.put("outCount", c[1]); row.put("recoveryCount", c[2]); + row.put("returnCount", c[3]); row.put("lossCount", c[4]); row.put("openCount", c[5]); + row.put("sellCount", c[6]); row.put("total", total); + rows.add(row); + } + Map totals=new LinkedHashMap<>(); + totals.put("inCount", sum[0]); totals.put("outCount", sum[1]); totals.put("recoveryCount", sum[2]); + totals.put("returnCount", sum[3]); totals.put("lossCount", sum[4]); totals.put("openCount", sum[5]); + totals.put("sellCount", sum[6]); totals.put("total", sum[0]+sum[1]+sum[2]+sum[3]+sum[4]+sum[5]+sum[6]); + Map result=new LinkedHashMap<>(); + result.put("year", year); result.put("month", month); + result.put("title", year+"년 "+month+"월 일별이력통계"); + result.put("rows", rows); result.put("totals", totals); + result.put("incompanyName", inFilter==null?null:companies.get(inFilter)); + return result; + } + private void bumpDay(Map days, LocalDate date, LocalDate from, LocalDate to, int idx) { + if(date==null || date.isBefore(from) || date.isAfter(to)) return; + int[] c=days.get(date.getDayOfMonth()); + if(c!=null) c[idx]++; + } + @GetMapping("/stocks/history") ApiResponse stockHistory(@AuthenticationPrincipal JwtUser u, @RequestParam Map f, + @RequestParam(defaultValue="0") int page, @RequestParam(defaultValue="15") int size) { + if(blank(f.get("dateFrom")) || blank(f.get("dateTo"))) return ApiResponse.fail("날짜를 선택하세요."); + return ApiResponse.ok(stockHistoryData(u, f, page, size)); + } + @PostMapping("/stocks/history/delete") @Transactional ApiResponse stockHistoryDelete(@AuthenticationPrincipal JwtUser u, @RequestBody Map d) { + Object raw=d.get("ids"); + if(!(raw instanceof List ids) || ids.isEmpty()) return ApiResponse.fail("삭제할 이력을 선택하세요."); + int deleted=0; + for(Object idObj:ids) { + StockHistory h=em.find(StockHistory.class, Long.valueOf(idObj.toString())); + if(h==null || !Objects.equals(h.getGroupId(),u.getGroupId())) continue; + em.remove(h); deleted++; + } + return ApiResponse.ok(Map.of("deleted", deleted)); + } + @GetMapping(value="/stocks/history/export", produces="text/csv;charset=UTF-8") ResponseEntity stockHistoryExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + if(blank(f.get("dateFrom")) || blank(f.get("dateTo"))) return ResponseEntity.badRequest().body("날짜를 선택하세요."); + @SuppressWarnings("unchecked") List> rows=(List>) stockHistoryData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF처리일,현황,종류,일련번호,모델명,색상,상태,입고처,출고처,처리자,비고\n"); + for(Map r:rows) csv.append(csv(r.get("processDate"))).append(',').append(csv(r.get("statusLabel"))).append(',') + .append(csv(r.get("gubun"))).append(',').append(csv(r.get("serial"))).append(',').append(csv(r.get("model"))).append(',') + .append(csv(r.get("color"))).append(',').append(csv(r.get("cond"))).append(',').append(csv(r.get("incompanyName"))).append(',') + .append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("processor"))).append(',').append(csv(r.get("memo"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=stock-history.csv").body(csv.toString()); + } + private Map stockHistoryData(JwtUser u, Map f, int page, int size) { + Map companies=companyNames(u); + LocalDate from=LocalDate.parse(f.get("dateFrom")); + LocalDate to=LocalDate.parse(f.get("dateTo")); + List all=em.createQuery("select h from StockHistory h where h.groupId=:g",StockHistory.class).setParameter("g",u.getGroupId()).getResultList(); + String searchType=f.getOrDefault("searchType","일련번호"); + String keyword=f.get("keyword"); + List filtered=all.stream().filter(h->{ + if(h.getProcessDate()==null || h.getProcessDate().isBefore(from) || h.getProcessDate().isAfter(to)) return false; + if(keyword!=null && !keyword.isBlank()) { + boolean ok=switch(searchType){ + case "모델명"->like(h.getModel(),keyword); + case "색상"->like(h.getColor(),keyword); + case "펫네임"->like(h.getSalesperson(),keyword); + default->like(h.getSerial(),keyword); + }; + if(!ok) return false; + } + return true; + }).sorted((a,b)->{ + int cmp=Comparator.nullsLast(LocalDate::compareTo).compare(b.getProcessDate(), a.getProcessDate()); + return cmp!=0?cmp:Long.compare(b.getId(), a.getId()); + }).toList(); + int fromIdx=Math.min(Math.max(page,0)*Math.max(size,1), filtered.size()); + int toIdx=Math.min(fromIdx+Math.min(Math.max(size,1),200), filtered.size()); + List> list=filtered.subList(fromIdx,toIdx).stream().map(h->{ + Map r=new LinkedHashMap<>(); + r.put("id", h.getId()); r.put("stockId", h.getStockId()); + r.put("processDate", h.getProcessDate()); r.put("statusLabel", h.getStatusLabel()); + r.put("state", h.getState()); r.put("gubun", h.getGubun()); + r.put("serial", h.getSerial()); r.put("model", h.getModel()); r.put("color", h.getColor()); + r.put("cond", h.getCond()); r.put("memo", h.getMemo()); r.put("processor", h.getProcessor()); + r.put("salesperson", h.getSalesperson()); + r.put("incompanyName", companies.getOrDefault(h.getIncompanyId(),"-")); + r.put("outcompanyName", companies.getOrDefault(h.getOutcompanyId(),"-")); + return r; + }).toList(); + return Map.of("total", filtered.size(), "list", list); + } + private void writeStockHistory(Stock s, JwtUser u) { + StockHistory h=new StockHistory(); + h.setGroupId(u.getGroupId()); + h.setStockId(s.getId()); + h.setIncompanyId(s.getIncompanyId()); + h.setOutcompanyId(s.getOutcompanyId()); + h.setProcessDate(s.getProcessDate()!=null?s.getProcessDate():LocalDate.now()); + h.setGubun(s.getGubun()); h.setState(s.getState()); h.setStatusLabel(statusLabel(s.getState())); + h.setSerial(s.getSerial()); h.setModel(s.getModel()); h.setColor(s.getColor()); + h.setCond(s.getCond()); h.setMemo(s.getMemo()); + h.setProcessor(s.getProcessor()!=null?s.getProcessor():memberName(u)); + h.setSalesperson(s.getSalesperson()); + if("OUT".equals(s.getState()) && s.getOutcompanyId()!=null){ + Company oc=em.find(Company.class, s.getOutcompanyId()); + if(oc!=null){ + String label=oc.getName(); + String cat=s.getOutCategory()!=null&&!s.getOutCategory().isBlank()?s.getOutCategory():oc.getCategory(); + if(cat!=null&&!cat.isBlank()) label=label+" ("+cat+")"; + h.setCompanyLabel(label); + } + } else if("IN".equals(s.getState()) && s.getIncompanyId()!=null){ + Company ic=em.find(Company.class, s.getIncompanyId()); + if(ic!=null) h.setCompanyLabel(ic.getName()); + } + em.persist(h); + } + @GetMapping("/stocks/abooks") ApiResponse abooks(@AuthenticationPrincipal JwtUser u,@RequestParam Map f, + @RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="15") int size) { + return ApiResponse.ok(abookData(u,f,page,size)); + } + @PostMapping("/stocks/abooks") @Transactional ApiResponse abookCreate(@AuthenticationPrincipal JwtUser u,@RequestBody Map d) { + UsimLedger row=new UsimLedger(); + row.setGroupId(u.getGroupId()); + Long outId=null; + if(d.get("outcompanyId")!=null && !d.get("outcompanyId").toString().isBlank()) { + outId=Long.valueOf(d.get("outcompanyId").toString()); + } else { + String name=d.get("outcompanyName")==null?"":d.get("outcompanyName").toString().trim(); + if(name.isBlank()) return ApiResponse.fail("출고처명을 입력해주세요."); + List found=em.createQuery("select c from Company c where c.groupId=:g and c.name=:n",Company.class) + .setParameter("g",u.getGroupId()).setParameter("n",name).getResultList(); + if(!found.isEmpty()) outId=found.getFirst().getId(); + else { + Company c=new Company(); c.setGroupId(u.getGroupId()); c.setType("OUT"); c.setName(name); c.setActive(true); + em.persist(c); em.flush(); outId=c.getId(); + } + } + if(d.get("telecom")==null || d.get("telecom").toString().isBlank()) return ApiResponse.fail("통신사를 선택하세요."); + if(d.get("qty")==null || d.get("qty").toString().isBlank()) return ApiResponse.fail("수량을 입력하세요."); + if(d.get("amount")==null || d.get("amount").toString().isBlank()) return ApiResponse.fail("거래금액을 입력하세요."); + row.setOutcompanyId(outId); + row.setTradeDate(d.get("tradeDate")==null||d.get("tradeDate").toString().isBlank()?LocalDate.now():LocalDate.parse(d.get("tradeDate").toString())); + row.setTelecom(String.valueOf(d.get("telecom"))); + row.setQty(Integer.valueOf(d.get("qty").toString().replace(",",""))); + row.setAmount(new BigDecimal(d.get("amount").toString().replace(",",""))); + row.setDetail(d.get("detail")==null||String.valueOf(d.get("detail")).isBlank()?null:String.valueOf(d.get("detail"))); + row.setRegistrant(d.get("registrant")==null||String.valueOf(d.get("registrant")).isBlank()?u.getUserid():String.valueOf(d.get("registrant"))); + row.setStatus("정상거래"); + em.persist(row); + return ApiResponse.ok(abookMap(row, companyNames(u))); + } + @PostMapping("/stocks/abooks/delete") @Transactional ApiResponse abookDelete(@AuthenticationPrincipal JwtUser u,@RequestBody Map d) { + Object raw=d.get("ids"); + if(!(raw instanceof List ids) || ids.isEmpty()) return ApiResponse.fail("삭제할 항목을 선택하세요."); + int deleted=0; + for(Object idObj:ids) { + UsimLedger row=em.find(UsimLedger.class, Long.valueOf(idObj.toString())); + if(row==null || !Objects.equals(row.getGroupId(),u.getGroupId())) continue; + em.remove(row); deleted++; + } + return ApiResponse.ok(Map.of("deleted",deleted)); + } + @GetMapping(value="/stocks/abooks/export", produces="text/csv;charset=UTF-8") ResponseEntity abookExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f) { + @SuppressWarnings("unchecked") List> rows=(List>) abookData(u,f,0,10000).get("list"); + StringBuilder csv=new StringBuilder("\uFEFF거래일,출고처,통신사,수량,거래금액,거래명세,등록자\n"); + for(Map r:rows) csv.append(csv(r.get("tradeDate"))).append(',').append(csv(r.get("outcompanyName"))).append(',') + .append(csv(r.get("telecom"))).append(',').append(csv(r.get("qty"))).append(',').append(csv(r.get("amount"))).append(',') + .append(csv(r.get("detail"))).append(',').append(csv(r.get("registrant"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=abooks.csv").body(csv.toString()); + } + @GetMapping(value="/stocks/abooks/summary-export", produces="text/csv;charset=UTF-8") ResponseEntity abookSummaryExport(@AuthenticationPrincipal JwtUser u,@RequestParam Map f) { + @SuppressWarnings("unchecked") List> rows=(List>) abookData(u,f,0,10000).get("summary"); + StringBuilder csv=new StringBuilder("\uFEFF출고처명,거래,수량,금액\n"); + for(Map r:rows) csv.append(csv(r.get("outcompanyName"))).append(',').append(csv(r.get("trades"))).append(',') + .append(csv(r.get("qty"))).append(',').append(csv(r.get("amount"))).append('\n'); + return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=abooks-summary.csv").body(csv.toString()); + } + private Map companyNames(JwtUser u){ + Map companies=new HashMap<>(); + for(Company c:em.createQuery("select c from Company c where c.groupId=:g",Company.class).setParameter("g",u.getGroupId()).getResultList()) companies.put(c.getId(),c.getName()); + return companies; + } + private Map abookData(JwtUser u,Map f,int page,int size) { + Map companies=companyNames(u); + List all=em.createQuery("select e from UsimLedger e where e.groupId=:g",UsimLedger.class).setParameter("g",u.getGroupId()).getResultList(); + String status=f.getOrDefault("status","정상거래"); + Long outFilter=blank(f.get("outcompanyId"))?null:Long.valueOf(f.get("outcompanyId")); + LocalDate from=blank(f.get("dateFrom"))?null:LocalDate.parse(f.get("dateFrom")); + LocalDate to=blank(f.get("dateTo"))?null:LocalDate.parse(f.get("dateTo")); + List filtered=all.stream().filter(e->{ + if(!"전체".equals(status) && !status.equals(e.getStatus())) return false; + if(outFilter!=null && !Objects.equals(e.getOutcompanyId(),outFilter)) return false; + if(from!=null && (e.getTradeDate()==null || e.getTradeDate().isBefore(from))) return false; + return to==null || (e.getTradeDate()!=null && !e.getTradeDate().isAfter(to)); + }).sorted(abookComparator(f.get("sort"))).toList(); + Map qtyMap=new LinkedHashMap<>(); + Map amountMap=new LinkedHashMap<>(); + Map tradeMap=new LinkedHashMap<>(); + for(UsimLedger e:filtered) { + Long key=e.getOutcompanyId()==null?-1L:e.getOutcompanyId(); + tradeMap.put(key, tradeMap.getOrDefault(key,0)+1); + qtyMap.put(key, new int[]{qtyMap.getOrDefault(key,new int[]{0})[0]+(e.getQty()==null?0:e.getQty())}); + amountMap.put(key, amountMap.getOrDefault(key,BigDecimal.ZERO).add(e.getAmount()==null?BigDecimal.ZERO:e.getAmount())); + } + boolean summaryAsc=f.getOrDefault("summarySort","amount,desc").endsWith(",asc"); + String summaryField=f.getOrDefault("summarySort","amount,desc").split(",")[0]; + List> summary=tradeMap.keySet().stream().map(key->{ + Map row=new LinkedHashMap<>(); + row.put("outcompanyId", key<0?null:key); + row.put("outcompanyName", key<0?"-":companies.getOrDefault(key,"-")); + row.put("trades", tradeMap.get(key)); + row.put("qty", qtyMap.get(key)[0]); + row.put("amount", amountMap.get(key)); + return row; + }).sorted((a,b)->{ + int cmp=switch(summaryField){ + case "outcompanyName"->String.valueOf(a.get("outcompanyName")).compareTo(String.valueOf(b.get("outcompanyName"))); + case "trades"->Integer.compare((Integer)a.get("trades"),(Integer)b.get("trades")); + case "qty"->Integer.compare((Integer)a.get("qty"),(Integer)b.get("qty")); + default->((BigDecimal)a.get("amount")).compareTo((BigDecimal)b.get("amount")); + }; + return summaryAsc?cmp:-cmp; + }).toList(); + int tradeTotal=filtered.size(); + int qtyTotal=filtered.stream().mapToInt(e->e.getQty()==null?0:e.getQty()).sum(); + BigDecimal amountTotal=filtered.stream().map(e->e.getAmount()==null?BigDecimal.ZERO:e.getAmount()).reduce(BigDecimal.ZERO,BigDecimal::add); + int fromIdx=Math.min(Math.max(page,0)*Math.max(size,1),filtered.size()); + int toIdx=Math.min(fromIdx+Math.min(Math.max(size,1),200),filtered.size()); + List> list=filtered.subList(fromIdx,toIdx).stream().map(e->abookMap(e,companies)).toList(); + Map totals=new LinkedHashMap<>(); + totals.put("trades",tradeTotal); totals.put("qty",qtyTotal); totals.put("amount",amountTotal); + return Map.of("total",tradeTotal,"list",list,"summary",summary,"totals",totals); + } + private Comparator abookComparator(String sort){ + String field=sort==null?"tradeDate,desc":sort; boolean asc=field.endsWith(",asc"); + Comparator c=switch(field.split(",")[0]){ + case "telecom"->Comparator.comparing(UsimLedger::getTelecom,Comparator.nullsLast(String::compareTo)); + case "qty"->Comparator.comparing(UsimLedger::getQty,Comparator.nullsLast(Integer::compareTo)); + case "amount"->Comparator.comparing(UsimLedger::getAmount,Comparator.nullsLast(BigDecimal::compareTo)); + case "registrant"->Comparator.comparing(UsimLedger::getRegistrant,Comparator.nullsLast(String::compareTo)); + default->Comparator.comparing(UsimLedger::getTradeDate,Comparator.nullsLast(LocalDate::compareTo)); + }; + return (asc?c:c.reversed()).thenComparing(UsimLedger::getId); + } + private Map abookMap(UsimLedger e,Map companies){ + Map r=new LinkedHashMap<>(); + r.put("id",e.getId()); r.put("outcompanyId",e.getOutcompanyId()); + r.put("outcompanyName",companies.getOrDefault(e.getOutcompanyId(),"-")); + r.put("tradeDate",e.getTradeDate()); r.put("telecom",e.getTelecom()); + r.put("qty",e.getQty()); r.put("amount",e.getAmount()); r.put("detail",e.getDetail()); + r.put("registrant",e.getRegistrant()); r.put("status",e.getStatus()); + return r; + } + @SuppressWarnings("unchecked") private Class type(String uri){if(uri.contains("scans"))return (Class)ScanDoc.class;if(uri.contains("indocs"))return (Class)IncompleteDoc.class;if(uri.contains("returns"))return (Class)UnreturnedPhone.class;if(uri.contains("fareboxes"))return (Class)Farebox.class;if(uri.contains("accounts"))return (Class)AccountEntry.class;if(uri.contains("schedules"))return (Class)ScheduleItem.class;if(uri.contains("templates"))return (Class)SmsTemplate.class;if(uri.contains("sms"))return (Class)SmsMessage.class;if(uri.contains("alims"))return (Class)Alim.class;return (Class)CsInquiry.class;} + private BigDecimal nvl(BigDecimal v){return v==null?BigDecimal.ZERO:v;} +} diff --git a/backend/src/main/java/com/bizcare/agent/CareHomesController.java b/backend/src/main/java/com/bizcare/agent/CareHomesController.java new file mode 100644 index 0000000..3fa6271 --- /dev/null +++ b/backend/src/main/java/com/bizcare/agent/CareHomesController.java @@ -0,0 +1,3690 @@ +package com.bizcare.agent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.*; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/agent/homes") +@RequiredArgsConstructor +class CareHomesController { + private final CrudService crud; + private final EntityManager em; + private final PasswordEncoder encoder; + private final ObjectMapper mapper; + + // ---- Dashboard ---- + @GetMapping("/dashboard") + ApiResponse dashboard(@AuthenticationPrincipal JwtUser u, + @RequestParam(defaultValue = "0") int offset, + @RequestParam(defaultValue = "day") String period) { + return ApiResponse.ok(buildHomesDashboard(u, offset, period)); + } + + @GetMapping("/dashboard/postits") + ApiResponse homesPostitsGet(@AuthenticationPrincipal JwtUser u) { + return ApiResponse.ok(Map.of("list", loadHomesPostits(u.getGroupId()))); + } + + @PutMapping("/dashboard/postits/{slot}") + @Transactional + ApiResponse homesPostitPut(@PathVariable int slot, @AuthenticationPrincipal JwtUser u, + @RequestBody Map d) { + if (slot < 1 || slot > 8) return ApiResponse.fail("슬롯 번호가 올바르지 않습니다."); + List> list = loadHomesPostits(u.getGroupId()); + Map note = list.get(slot - 1); + if (d.get("contents") != null) note.put("contents", String.valueOf(d.get("contents"))); + if (d.get("authorName") != null) note.put("authorName", String.valueOf(d.get("authorName"))); + note.put("noteDate", LocalDate.now(ZoneId.of("Asia/Seoul")).format(java.time.format.DateTimeFormatter.ofPattern("yy-MM-dd"))); + note.put("title", "포스트잇 #" + slot); + saveHomesPostits(u.getGroupId(), list); + return ApiResponse.ok(note); + } + + private Map buildHomesDashboard(JwtUser u, int offset, String period) { + String g = u.getGroupId(); + ZoneId zone = ZoneId.of("Asia/Seoul"); + LocalDate today = LocalDate.now(zone); + YearMonth ym = YearMonth.from(today).minusMonths(Math.max(0, offset)); + LocalDate from = ym.atDay(1); + LocalDate to = ym.atEndOfMonth(); + String label = ym.getYear() + "년 " + String.format("%02d", ym.getMonthValue()) + "월"; + + long contracts = count(HomesContract.class, g); + long customers = count(HomesCustomer.class, g); + long pendingToday = em.createQuery( + "select count(p) from HomesPending p where p.groupId=:g and p.pendingDate=:d", Long.class) + .setParameter("g", g).setParameter("d", today).getSingleResult(); + long pendingOpen = em.createQuery( + "select count(p) from HomesPending p where p.groupId=:g and (p.status is null or p.status not in ('해결','완료','취소'))", Long.class) + .setParameter("g", g).getSingleResult(); + long bookingOpen = em.createQuery( + "select count(b) from HomesBooking b where b.groupId=:g and b.status='접수'", Long.class) + .setParameter("g", g).getSingleResult(); + long bookingTotal = count(HomesBooking.class, g); + BigDecimal abookMonth = em.createQuery( + "select coalesce(sum(a.amount),0) from HomesAbook a where a.groupId=:g and a.entryDate between :f and :t and a.entryType in ('입금','수입','카드입금')", BigDecimal.class) + .setParameter("g", g).setParameter("f", from).setParameter("t", to).getSingleResult(); + BigDecimal settleMonth = em.createQuery( + "select coalesce(sum(c.settleAmount),0) from HomesContract c where c.groupId=:g and c.contractDate between :f and :t", BigDecimal.class) + .setParameter("g", g).setParameter("f", from).setParameter("t", to).getSingleResult(); + + List monthContracts = em.createQuery( + "select c from HomesContract c where c.groupId=:g and c.contractDate between :f and :t", HomesContract.class) + .setParameter("g", g).setParameter("f", from).setParameter("t", to).getResultList(); + List internetMonth = monthContracts.stream().filter(c -> !"HOME".equals(c.getKind())).toList(); + List homeMonth = monthContracts.stream().filter(c -> "HOME".equals(c.getKind())).toList(); + + Map internet = contractPanel("인터넷접수", label, offset, internetMonth, from, to, period, false); + Map home = contractPanel("홈렌탈접수", label, offset, homeMonth, from, to, period, true); + + Map internetCats = categoryCounts(internetMonth, false); + Map homeCats = categoryCounts(homeMonth, true); + + List notices = em.createQuery( + "select n from HomesNotice n where n.groupId=:g order by n.id desc", HomesNotice.class) + .setParameter("g", g).setMaxResults(5).getResultList(); + List questions = em.createQuery( + "select q from HomesQuestion q where q.groupId=:g order by q.id desc", HomesQuestion.class) + .setParameter("g", g).setMaxResults(5).getResultList(); + + Map data = new LinkedHashMap<>(); + data.put("pending", Map.of( + "open", pendingOpen, "today", pendingToday, "total", Math.max(pendingOpen, pendingToday), + "spark", List.of(2, 4, 3, 5, 2, 6, 4, 3, 5, 4, 3, 4))); + data.put("booking", Map.of( + "open", bookingOpen, "total", Math.max(bookingTotal, bookingOpen), + "spark", List.of(1, 2, 2, 3, 1, 4, 2, 3, 2, 1, 3, 2))); + data.put("customer", Map.of( + "total", customers, "monthNew", customers > 0 ? Math.min(customers, 3) : 0, + "spark", List.of(3, 5, 4, 6, 5, 7, 4, 6, 5, 8, 6, 7))); + data.put("abook", Map.of( + "monthAmount", abookMonth, "settleMonth", settleMonth, + "spark", List.of(4, 3, 5, 2, 6, 4, 5, 3, 4, 5, 3, 4))); + data.put("contractTotal", contracts); + data.put("customerTotal", customers); + data.put("postits", loadHomesPostits(g).stream().limit(6).toList()); + data.put("internet", internet); + data.put("home", home); + data.put("internetStatus", Map.of("total", internetCats.values().stream().mapToLong(Long::longValue).sum(), "items", internetCats)); + data.put("homeStatus", Map.of("total", homeCats.values().stream().mapToLong(Long::longValue).sum(), "items", homeCats)); + data.put("notices", notices.stream().map(this::noticeMap).toList()); + data.put("questions", questions.stream().map(q -> { + Map m = new LinkedHashMap<>(); + m.put("id", q.getId()); + m.put("title", q.getTitle()); + m.put("createdDate", q.getCreatedAt() == null ? null : LocalDate.ofInstant(q.getCreatedAt(), zone).toString()); + return m; + }).toList()); + data.put("csPhone", "0000-0000"); + return data; + } + + private Map contractPanel(String title, String label, int offset, List list, + LocalDate from, LocalDate to, String period, boolean home) { + long apply = list.stream().filter(c -> "신청".equals(c.getStatus())).count(); + long accept = list.stream().filter(c -> "접수".equals(c.getStatus())).count(); + long done = list.stream().filter(c -> "완료".equals(c.getStatus())).count(); + long cancel = list.stream().filter(c -> "철회".equals(c.getStatus()) || "취소".equals(c.getStatus())).count(); + BigDecimal settle = list.stream().map(c -> c.getSettleAmount() == null ? BigDecimal.ZERO : c.getSettleAmount()) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + List> daily = new ArrayList<>(); + int days = to.getDayOfMonth(); + Map byDay = new LinkedHashMap<>(); + for (int d = 1; d <= days; d++) byDay.put(d, 0L); + for (HomesContract c : list) { + if (c.getContractDate() == null) continue; + int d = c.getContractDate().getDayOfMonth(); + byDay.put(d, byDay.getOrDefault(d, 0L) + 1); + } + for (Map.Entry e : byDay.entrySet()) { + daily.add(Map.of("day", e.getKey(), "value", e.getValue())); + } + // decorative demo when empty + if (list.isEmpty()) { + int[] demo = home + ? new int[]{0, 0, 1, 0, 2, 1, 0, 1, 0, 0, 1, 2, 0, 1, 0, 0, 1, 0, 2, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1} + : new int[]{0, 1, 0, 2, 1, 0, 0, 1, 0, 2, 0, 1, 0, 0, 1, 2, 0, 1, 0, 0, 1, 0, 2, 1, 0, 0, 1, 0, 0, 0, 1}; + daily.clear(); + for (int d = 1; d <= days; d++) { + daily.add(Map.of("day", d, "value", demo[Math.min(d - 1, demo.length - 1)])); + } + } + + Map m = new LinkedHashMap<>(); + m.put("title", title); + m.put("label", label); + m.put("offset", offset); + m.put("period", period); + m.put("apply", apply); + m.put("accept", accept); + m.put("done", done); + m.put("cancel", cancel); + m.put("settleAmount", settle); + m.put("count", list.size()); + m.put("daily", daily); + return m; + } + + private Map categoryCounts(List list, boolean home) { + String[] keys = home + ? new String[]{"정수기", "공기청정기", "비데", "매트리스", "가전", "기타"} + : new String[]{"인터넷", "TV", "전화", "와이파이", "스마트홈", "기타"}; + Map map = new LinkedHashMap<>(); + for (String k : keys) map.put(k, 0L); + for (HomesContract c : list) { + String products = c.getProducts(); + if (products == null || products.isBlank()) { + map.put("기타", map.get("기타") + 1); + continue; + } + boolean matched = false; + for (String k : keys) { + if (!"기타".equals(k) && products.contains(k)) { + map.put(k, map.get(k) + 1); + matched = true; + } + } + if (!matched) map.put("기타", map.get("기타") + 1); + } + // demo filler when all zero + long sum = map.values().stream().mapToLong(Long::longValue).sum(); + if (sum == 0) { + if (home) { + map.put("정수기", 2L); map.put("공기청정기", 1L); map.put("비데", 1L); + map.put("매트리스", 1L); map.put("가전", 0L); map.put("기타", 1L); + } else { + map.put("인터넷", 3L); map.put("TV", 2L); map.put("전화", 1L); + map.put("와이파이", 1L); map.put("스마트홈", 0L); map.put("기타", 1L); + } + } + return map; + } + + private List> loadHomesPostits(String groupId) { + List> defaults = defaultHomesPostits(); + List rows = em.createQuery( + "select e from Setting e where e.groupId=:g and e.settingKey='postits'", Setting.class) + .setParameter("g", groupId).getResultList(); + if (rows.isEmpty() || rows.getFirst().getValue() == null || rows.getFirst().getValue().isBlank()) return defaults; + try { + @SuppressWarnings("unchecked") + List> parsed = mapper.readValue(rows.getFirst().getValue(), List.class); + for (int i = 0; i < Math.min(8, parsed.size()); i++) { + Map d = defaults.get(i); + d.putAll(parsed.get(i)); + d.put("slot", i + 1); + d.put("title", "포스트잇 #" + (i + 1)); + } + } catch (Exception ignored) {} + return defaults; + } + + private void saveHomesPostits(String groupId, List> list) { + String json; + try { json = mapper.writeValueAsString(list); } catch (Exception e) { json = "[]"; } + List rows = em.createQuery( + "select e from Setting e where e.groupId=:g and e.settingKey='postits'", Setting.class) + .setParameter("g", groupId).getResultList(); + if (rows.isEmpty()) { + Setting s = new Setting(); + s.setGroupId(groupId); + s.setSettingKey("postits"); + s.setValue(json); + em.persist(s); + } else { + rows.getFirst().setValue(json); + } + } + + private List> defaultHomesPostits() { + Object[][] samples = { + {"신규 인터넷 접수 확인", "홍길동", "26-07-08"}, + {"홈렌탈 설치 일정 조율", "홍길동", "26-07-07"}, + {"약속 미결 처리 요청", "김대표", "26-07-05"}, + {"정산서 마감 체크", "홍길동", "26-06-28"}, + {"고객 분류 정리", "홍총괄", "26-06-20"}, + {"예약 접수 현황 점검", "홍길동", "26-06-12"}, + {"고객센터 휴무 안내", "김대표", "26-05-29"}, + {"월간 리포트 작성", "홍총괄", "26-05-15"}, + }; + List> list = new ArrayList<>(); + for (int i = 0; i < 8; i++) { + Map m = new LinkedHashMap<>(); + m.put("slot", i + 1); + m.put("title", "포스트잇 #" + (i + 1)); + m.put("contents", samples[i][0]); + m.put("authorName", samples[i][1]); + m.put("noteDate", samples[i][2]); + list.add(m); + } + return list; + } + + // ---- Products ---- + @GetMapping("/products") + ApiResponse products(@AuthenticationPrincipal JwtUser u, @RequestParam Map f, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) { + List all = listAll(HomesProduct.class, u); + String kind = blankToNull(f.get("kind")); + String category = blankToNull(f.get("category")); + if ("ALL".equalsIgnoreCase(category)) category = null; + String company = blankToNull(f.get("company")); + String q = qOf(f); + String cat = category; + List base = all.stream().filter(p -> kind == null || kind.equals(p.getKind())).toList(); + List filtered = base.stream().filter(p -> { + if (cat != null && !cat.equals(p.getCategory())) return false; + if (company != null && !company.equals(p.getCompany())) return false; + if (q != null) { + String hay = (nullToEmpty(p.getName()) + " " + nullToEmpty(p.getModel()) + " " + nullToEmpty(p.getCompany())).toLowerCase(); + if (!hay.contains(q.toLowerCase())) return false; + } + return true; + }).toList(); + Map counts = new LinkedHashMap<>(); + counts.put("ALL", (long) base.size()); + base.stream().collect(Collectors.groupingBy(p -> nullToEmpty(p.getCategory()), Collectors.counting())) + .forEach(counts::put); + return pageOk(filtered, page, size, this::productMap, counts); + } + + @GetMapping("/products/{id}") ApiResponse productGet(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + return ApiResponse.ok(productMap(crud.get(HomesProduct.class, id, u))); + } + @PostMapping("/products") @Transactional ApiResponse productPost(@RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + d.putIfAbsent("policyCount", 0); + HomesProduct p = crud.create(HomesProduct.class, d, u); + writeProductLog(u, p, "등록"); + return ApiResponse.ok(productMap(p)); + } + + @PostMapping("/products/sync") @Transactional + ApiResponse productSync(@AuthenticationPrincipal JwtUser u, @RequestBody(required = false) Map body) { + String kind = body != null && body.get("kind") != null ? String.valueOf(body.get("kind")) : "INTERNET"; + List existing = listAll(HomesProduct.class, u).stream() + .filter(p -> kind.equals(p.getKind())).toList(); + Set keys = existing.stream() + .map(p -> p.getCompany() + "|" + p.getCategory() + "|" + p.getName() + "|" + nullToEmpty(p.getModel())) + .collect(Collectors.toSet()); + int added = 0; + for (Object[] row : baseProductCatalog()) { + if (!kind.equals(String.valueOf(row[0]))) continue; + String model = row[4] == null ? "" : String.valueOf(row[4]); + String key = row[2] + "|" + row[1] + "|" + row[3] + "|" + model; + if (keys.contains(key)) continue; + HomesProduct p = new HomesProduct(); + p.setGroupId(u.getGroupId()); + p.setKind(String.valueOf(row[0])); + p.setCategory(String.valueOf(row[1])); + p.setCompany(String.valueOf(row[2])); + p.setName(String.valueOf(row[3])); + p.setModel(model.isBlank() ? null : model); + p.setMonths((Integer) row[5]); + p.setPolicyAmount(row[6] == null ? null : new BigDecimal(String.valueOf(row[6]))); + p.setPolicyCount(row[7] == null ? 0 : (Integer) row[7]); + em.persist(p); + added++; + } + String kindLabel = "HOME".equals(kind) ? "홈렌탈상품" : "인터넷상품"; + writeLog(u, "PRODUCT", "기본상품-동기화", kindLabel + " 기본상품 " + added + "건을 동기화하였습니다."); + return ApiResponse.ok(Map.of("added", added)); + } + + @PutMapping("/products/{id}") @Transactional ApiResponse productPut(@PathVariable Long id, @RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + HomesProduct p = crud.update(HomesProduct.class, id, d, u); + writeProductLog(u, p, "수정"); + return ApiResponse.ok(productMap(p)); + } + @DeleteMapping("/products/{id}") @Transactional ApiResponse productDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesProduct p = crud.get(HomesProduct.class, id, u); + em.createQuery("delete from HomesProductPolicy pol where pol.productId=:id and pol.groupId=:g") + .setParameter("id", id).setParameter("g", u.getGroupId()).executeUpdate(); + writeProductLog(u, p, "삭제"); + crud.delete(HomesProduct.class, id, u); + return ApiResponse.ok(null); + } + + @GetMapping("/products/{id}/policies") + ApiResponse productPolicies(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesProduct p = crud.get(HomesProduct.class, id, u); + List list = em.createQuery( + "select pol from HomesProductPolicy pol where pol.groupId=:g and pol.productId=:id order by pol.id", + HomesProductPolicy.class) + .setParameter("g", u.getGroupId()).setParameter("id", id).getResultList(); + return ApiResponse.ok(Map.of("product", productMap(p), "list", list.stream().map(this::productPolicyMap).toList())); + } + + @PostMapping("/products/{id}/policies") @Transactional + ApiResponse productPolicyPost(@PathVariable Long id, @RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + HomesProduct p = crud.get(HomesProduct.class, id, u); + d.put("productId", id); + HomesProductPolicy pol = crud.create(HomesProductPolicy.class, d, u); + refreshProductPolicyCount(p); + String kindLabel = productKindLabel(p); + String policyName = d.get("name") == null ? p.getName() : String.valueOf(d.get("name")); + writeLog(u, "PRODUCT", kindLabel + "-정책-등록", kindLabel + " 정책 [" + policyName + "] 등록하였습니다."); + return ApiResponse.ok(productPolicyMap(pol)); + } + + @DeleteMapping("/products/policies/{policyId}") @Transactional + ApiResponse productPolicyDel(@PathVariable Long policyId, @AuthenticationPrincipal JwtUser u) { + HomesProductPolicy pol = crud.get(HomesProductPolicy.class, policyId, u); + Long productId = pol.getProductId(); + crud.delete(HomesProductPolicy.class, policyId, u); + HomesProduct p = em.find(HomesProduct.class, productId); + if (p != null && Objects.equals(p.getGroupId(), u.getGroupId())) refreshProductPolicyCount(p); + return ApiResponse.ok(null); + } + + private void refreshProductPolicyCount(HomesProduct p) { + long c = em.createQuery("select count(pol) from HomesProductPolicy pol where pol.productId=:id", Long.class) + .setParameter("id", p.getId()).getSingleResult(); + p.setPolicyCount((int) c); + if (c > 0) { + BigDecimal amt = em.createQuery( + "select pol.amount from HomesProductPolicy pol where pol.productId=:id order by pol.id", BigDecimal.class) + .setParameter("id", p.getId()).setMaxResults(1).getResultList().stream().findFirst().orElse(p.getPolicyAmount()); + p.setPolicyAmount(amt); + } + } + + private static Object[][] baseProductCatalog() { + return new Object[][]{ + // kind, category, company, name, model, months, policyAmount, policyCount + {"INTERNET", "인터넷", "KT", "안심 인터넷 베이직 와이드", null, 36, "80000", 3}, + {"INTERNET", "인터넷", "KT", "인터넷 슬림", null, 36, "50000", 1}, + {"INTERNET", "인터넷", "KT", "기가인터넷 500M", null, 36, "80000", 0}, + {"INTERNET", "인터넷", "KT", "기가인터넷 1G", null, 36, "90000", 2}, + {"INTERNET", "인터넷", "KT", "인터넷 베이직 와이파이", null, 36, "60000", 1}, + {"INTERNET", "인터넷", "KT", "인터넷 에센스", null, 36, "70000", 0}, + {"INTERNET", "인터넷", "KT", "인터넷 베이직", null, 36, "55000", 0}, + {"INTERNET", "인터넷", "KT", "인터넷 프리미엄", null, 36, "100000", 3}, + {"INTERNET", "인터넷", "SKB", "B tv 인터넷", null, 36, "75000", 1}, + {"INTERNET", "인터넷", "LGU+", "U+ 인터넷", null, 36, "70000", 0}, + {"INTERNET", "TV", "KT", "올레tv 베이직", null, 36, "30000", 1}, + {"INTERNET", "TV", "KT", "올레tv 슬림", null, 36, "25000", 0}, + {"INTERNET", "TV", "KT", "지니 TV 프리미엄", null, 36, "40000", 2}, + {"INTERNET", "전화", "KT", "집전화 기본", null, 24, "10000", 0}, + {"INTERNET", "전화", "KT", "집전화 스마트", null, 24, "15000", 1}, + {"INTERNET", "와이파이", "KT", "와이파이 공유기", null, 24, "15000", 0}, + {"INTERNET", "와이파이", "SKB", "와이파이 메시", null, 24, "20000", 1}, + {"INTERNET", "스마트홈", "LGU+", "스마트홈 패키지", null, 36, "20000", 0}, + {"INTERNET", "스마트홈", "KT", "GiGA IoT 홈캠", null, 36, "25000", 1}, + {"INTERNET", "기타", "KT", "기타 부가상품", null, 12, "5000", 0}, + {"HOME", "정수기", "청호나이스", "450 (냉온정)", "WP-40C9500M", 60, "120000", 1}, + {"HOME", "정수기", "청호나이스", "550 (얼음냉온정)", "WI-55S9500M", 60, "150000", 0}, + {"HOME", "정수기", "청호나이스", "Compact (정)", "WP-CP1000", 60, "90000", 0}, + {"HOME", "정수기", "청호나이스", "700 (얼음냉온정)", "WI-70S9500M", 60, "180000", 1}, + {"HOME", "정수기", "코웨이", "아이콘 정수기", "CHP-7210N", 60, "130000", 0}, + {"HOME", "정수기", "코웨이", "냉온정수기", "CHP-5610L", 36, "110000", 1}, + {"HOME", "정수기", "SK매직", "스스로살균 정수기", "WPU-A110C", 60, "100000", 0}, + {"HOME", "정수기", "LG전자", "퓨리케어 오브제", "WD503AS", 60, "140000", 0}, + {"HOME", "공기청정기", "LG전자", "퓨리케어 360", "AS180DWFA", 36, "90000", 0}, + {"HOME", "공기청정기", "코웨이", "에어메가", "AP-2021A", 36, "85000", 1}, + {"HOME", "공기청정기", "삼성", "비스포크 큐브", "AX60A", 36, "95000", 0}, + {"HOME", "비데", "SK매직", "비데 기본형", "BIDET-01", 36, "50000", 0}, + {"HOME", "비데", "코웨이", "스스로케어 비데", "BAU-20", 36, "60000", 1}, + {"HOME", "비데", "청호나이스", "방수비데", "BA-17", 0, "45000", 0}, + {"HOME", "매트리스", "코웨이", "매트리스 퀸", "MT-Q", 60, "150000", 0}, + {"HOME", "매트리스", "코웨이", "매트리스 킹", "MT-K", 60, "180000", 1}, + {"HOME", "매트리스", "시몬스", "뷰티레스트", "BR-Q", 0, "200000", 0}, + {"HOME", "가전", "삼성", "김치냉장고", "RQ33A74", 36, "80000", 0}, + {"HOME", "가전", "LG전자", "디오스 냉장고", "M872GBB", 36, "100000", 0}, + {"HOME", "가전", "위닉스", "제습기", "DXAE100-KEK", 24, "40000", 1}, + {"HOME", "기타", "기타", "렌탈 기타상품", "ETC-01", 12, "30000", 0}, + }; + } + + // ---- Contracts ---- + @GetMapping("/contracts") + ApiResponse contracts(@AuthenticationPrincipal JwtUser u, @RequestParam Map f, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) { + List all = listAll(HomesContract.class, u); + String kind = blankToNull(f.get("kind")); + if ("ALL".equalsIgnoreCase(kind)) kind = null; + String status = blankToNull(f.get("status")); + if ("ALL".equalsIgnoreCase(status)) status = null; + LocalDate from = parseDate(f.get("dateFrom")); + LocalDate to = parseDate(f.get("dateTo")); + String agency = blankToNull(f.get("agency")); + String employee = blankToNull(f.get("employee")); + String searchType = blankToNull(f.get("searchType")); + String q = qOf(f); + String k = kind; String st = status; + List base = all.stream().filter(c -> k == null || k.equals(c.getKind())).toList(); + List filtered = base.stream().filter(c -> { + if (st != null && !st.equals(c.getStatus())) return false; + if (from != null && (c.getContractDate() == null || c.getContractDate().isBefore(from))) return false; + if (to != null && (c.getContractDate() == null || c.getContractDate().isAfter(to))) return false; + if (agency != null && !agency.equals(c.getAgency())) return false; + if (employee != null && !employee.equals(c.getEmployee())) return false; + if (q != null) { + String hay; + if ("거래처".equals(searchType)) hay = nullToEmpty(c.getAgency()); + else if ("직원".equals(searchType)) hay = nullToEmpty(c.getEmployee()); + else if ("연락처".equals(searchType)) hay = nullToEmpty(c.getPhone()); + else if ("가입상품".equals(searchType)) hay = nullToEmpty(c.getProducts()); + else hay = nullToEmpty(c.getCustomerName()) + " " + nullToEmpty(c.getPhone()) + " " + nullToEmpty(c.getProducts()); + if (!hay.toLowerCase().contains(q.toLowerCase())) return false; + } + return true; + }).toList(); + Map counts = new LinkedHashMap<>(); + counts.put("ALL", (long) base.size()); + for (String s : List.of("접수", "신청", "완료", "철회")) { + counts.put(s, base.stream().filter(c -> s.equals(c.getStatus())).count()); + } + Map data = new LinkedHashMap<>(); + int fromIdx = Math.max(0, page) * Math.max(1, size); + data.put("total", filtered.size()); + data.put("list", filtered.stream().skip(fromIdx).limit(Math.min(Math.max(size, 1), 200)).map(this::contractMap).toList()); + data.put("counts", counts); + data.put("agencies", base.stream().map(HomesContract::getAgency).filter(Objects::nonNull).filter(s -> !s.isBlank()).distinct().sorted().toList()); + data.put("employees", base.stream().map(HomesContract::getEmployee).filter(Objects::nonNull).filter(s -> !s.isBlank()).distinct().sorted().toList()); + BigDecimal settleSum = filtered.stream().map(HomesContract::getSettleAmount).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal marginSum = filtered.stream().map(HomesContract::getMargin).filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add); + data.put("settleSum", settleSum); + data.put("marginSum", marginSum); + return ApiResponse.ok(data); + } + + @GetMapping("/contracts/adjust") + ApiResponse contractsAdjust(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + LocalDate from = parseDate(f.get("dateFrom")); + LocalDate to = parseDate(f.get("dateTo")); + if (from == null || to == null) return ApiResponse.fail("계약일을 선택하세요."); + if (to.isBefore(from)) return ApiResponse.fail("날짜 범위가 올바르지 않습니다."); + if (java.time.temporal.ChronoUnit.DAYS.between(from, to) > 30) return ApiResponse.fail("최대 31일까지 조회할 수 있습니다."); + String kind = blankToNull(f.get("kind")); + String category = blankToNull(f.get("category")); + String product = blankToNull(f.get("product")); + String months = blankToNull(f.get("months")); + String status = blankToNull(f.get("status")); + String employee = blankToNull(f.get("employee")); + List list = listAll(HomesContract.class, u).stream().filter(c -> { + if (c.getContractDate() == null || c.getContractDate().isBefore(from) || c.getContractDate().isAfter(to)) return false; + if (kind != null && !kind.equals(c.getKind())) return false; + if (status != null && !status.equals(c.getStatus())) return false; + if (employee != null && !employee.equals(c.getEmployee())) return false; + if (months != null) { + Integer m = c.getMonths(); + if (m == null || !months.equals(String.valueOf(m))) return false; + } + String products = nullToEmpty(c.getProducts()); + if (product != null && !products.toLowerCase().contains(product.toLowerCase())) return false; + if (category != null) { + if (!products.contains(category) && !nullToEmpty(c.getMemo()).contains(category)) return false; + } + if (products.matches("(?is).*\\bx\\s*([2-9]|\\d{2,})\\b.*")) return false; + return true; + }).toList(); + Set employees = listAll(HomesContract.class, u).stream() + .map(HomesContract::getEmployee).filter(Objects::nonNull).filter(s -> !s.isBlank()).collect(Collectors.toCollection(TreeSet::new)); + Set products = listAll(HomesContract.class, u).stream() + .map(HomesContract::getProducts).filter(Objects::nonNull).flatMap(p -> Arrays.stream(p.split("[,/]"))) + .map(String::trim).filter(s -> !s.isBlank()).collect(Collectors.toCollection(TreeSet::new)); + Map data = new LinkedHashMap<>(); + data.put("total", list.size()); + data.put("list", list.stream().map(this::contractMap).toList()); + data.put("employees", employees); + data.put("products", products); + return ApiResponse.ok(data); + } + + @PostMapping("/contracts/adjust/batch") @Transactional + ApiResponse contractsAdjustBatch(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + Object raw = body.get("items"); + if (!(raw instanceof List items) || items.isEmpty()) return ApiResponse.fail("변경할 항목이 없습니다."); + int updated = 0; + for (Object item : items) { + if (!(item instanceof Map m)) continue; + Object idObj = m.get("id"); + if (idObj == null) continue; + HomesContract c = em.find(HomesContract.class, Long.valueOf(String.valueOf(idObj))); + if (c == null || !Objects.equals(c.getGroupId(), u.getGroupId())) continue; + if (m.containsKey("policySum") && m.get("policySum") != null) c.setPolicySum(new BigDecimal(String.valueOf(m.get("policySum")))); + if (m.containsKey("settleAmount") && m.get("settleAmount") != null) c.setSettleAmount(new BigDecimal(String.valueOf(m.get("settleAmount")))); + if (m.containsKey("margin") && m.get("margin") != null) c.setMargin(new BigDecimal(String.valueOf(m.get("margin")))); + writeLog(u, "CONTRACT", + ("HOME".equals(c.getKind()) ? "홈렌탈접수" : "인터넷접수") + "-정책", + ("HOME".equals(c.getKind()) ? "홈렌탈접수" : "인터넷접수") + " [" + nullToEmpty(c.getCustomerName()) + "] 정책금액을 변경하였습니다."); + updated++; + } + return ApiResponse.ok(Map.of("updated", updated)); + } + + @GetMapping("/contracts/{id}") ApiResponse contractGet(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + return ApiResponse.ok(contractMap(crud.get(HomesContract.class, id, u))); + } + @PostMapping("/contracts") @Transactional ApiResponse contractPost(@RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + d.putIfAbsent("status", "접수"); + HomesContract c = crud.create(HomesContract.class, d, u); + writeContractLog(u, c, "등록", null); + return ApiResponse.ok(contractMap(c)); + } + @PutMapping("/contracts/{id}") @Transactional ApiResponse contractPut(@PathVariable Long id, @RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + HomesContract c = crud.update(HomesContract.class, id, d, u); + writeContractLog(u, c, "수정", null); + return ApiResponse.ok(contractMap(c)); + } + @DeleteMapping("/contracts/{id}") @Transactional ApiResponse contractDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesContract c = crud.get(HomesContract.class, id, u); + writeContractLog(u, c, "삭제", null); + crud.delete(HomesContract.class, id, u); + return ApiResponse.ok(null); + } + @PostMapping("/contracts/delete") @Transactional + ApiResponse contractsBulkDelete(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + Object raw = body.get("ids"); + if (!(raw instanceof List list) || list.isEmpty()) return ApiResponse.fail("삭제할 계약을 선택하세요."); + int deleted = 0; + for (Object o : list) { + Long id = Long.valueOf(String.valueOf(o)); + HomesContract c = em.find(HomesContract.class, id); + if (c == null || !Objects.equals(c.getGroupId(), u.getGroupId())) continue; + writeContractLog(u, c, "삭제", null); + em.remove(c); + deleted++; + } + return ApiResponse.ok(Map.of("deleted", deleted)); + } + @PostMapping("/contracts/status") @Transactional + ApiResponse contractsBulkStatus(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + Object raw = body.get("ids"); + String status = body.get("status") == null ? null : String.valueOf(body.get("status")); + if (!(raw instanceof List list) || list.isEmpty()) return ApiResponse.fail("계약을 선택하세요."); + if (status == null || status.isBlank()) return ApiResponse.fail("상태를 선택하세요."); + int updated = 0; + for (Object o : list) { + Long id = Long.valueOf(String.valueOf(o)); + HomesContract c = em.find(HomesContract.class, id); + if (c == null || !Objects.equals(c.getGroupId(), u.getGroupId())) continue; + c.setStatus(status); + writeContractLog(u, c, "상태", status); + updated++; + } + return ApiResponse.ok(Map.of("updated", updated)); + } + + // ---- Balances ---- + @GetMapping("/balances") + ApiResponse balances(@AuthenticationPrincipal JwtUser u, @RequestParam Map f, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) { + List filtered = filterBalances(u, f); + List all = listAll(HomesBalance.class, u); + Map counts = new LinkedHashMap<>(); + counts.put("ALL", (long) all.size()); + counts.put("정산차감", all.stream().filter(b -> "정산차감".equals(normalizeBalanceType(b.getBalanceType()))).count()); + counts.put("정산추가", all.stream().filter(b -> "정산추가".equals(normalizeBalanceType(b.getBalanceType()))).count()); + Set employees = all.stream().filter(b -> "직원".equals(resolveTargetType(b))) + .map(this::resolveTargetName).filter(s -> !s.isBlank()).collect(Collectors.toCollection(TreeSet::new)); + Set agencies = all.stream().filter(b -> "하부점".equals(resolveTargetType(b))) + .map(this::resolveTargetName).filter(s -> !s.isBlank()).collect(Collectors.toCollection(TreeSet::new)); + Set reasons = all.stream().map(HomesBalance::getReason).filter(Objects::nonNull).filter(s -> !s.isBlank()) + .collect(Collectors.toCollection(TreeSet::new)); + // 옵션 보강: 멤버/거래처 + listAll(HomesAgency.class, u).stream().map(HomesAgency::getName).filter(Objects::nonNull).forEach(agencies::add); + em.createQuery("select m from Member m where m.groupId=:g and m.role='AGENT'", Member.class) + .setParameter("g", u.getGroupId()).getResultList().stream() + .map(Member::getName).filter(Objects::nonNull).filter(s -> !s.isBlank()).forEach(employees::add); + int fromIdx = Math.max(0, page) * Math.max(1, size); + List> list = filtered.stream().skip(fromIdx).limit(Math.max(size, 1)).map(this::balanceMap).toList(); + Map data = new LinkedHashMap<>(); + data.put("total", filtered.size()); + data.put("list", list); + data.put("counts", counts); + data.put("employees", employees); + data.put("agencies", agencies); + data.put("reasons", reasons); + return ApiResponse.ok(data); + } + @PostMapping("/balances") @Transactional ApiResponse balancePost(@RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + normalizeBalancePayload(d); + return ApiResponse.ok(balanceMap(crud.create(HomesBalance.class, d, u))); + } + @PutMapping("/balances/{id}") @Transactional ApiResponse balancePut(@PathVariable Long id, @RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + normalizeBalancePayload(d); + return ApiResponse.ok(balanceMap(crud.update(HomesBalance.class, id, d, u))); + } + @DeleteMapping("/balances/{id}") @Transactional ApiResponse balanceDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + crud.delete(HomesBalance.class, id, u); return ApiResponse.ok(null); + } + @PostMapping("/balances/delete") @Transactional + ApiResponse balancesBulkDelete(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + Object raw = body.get("ids"); + if (!(raw instanceof List list) || list.isEmpty()) return ApiResponse.fail("삭제할 항목을 선택하세요."); + int deleted = 0; + for (Object o : list) { + Long id = Long.valueOf(String.valueOf(o)); + HomesBalance b = em.find(HomesBalance.class, id); + if (b == null || !Objects.equals(b.getGroupId(), u.getGroupId())) continue; + em.remove(b); + deleted++; + } + return ApiResponse.ok(Map.of("deleted", deleted)); + } + @GetMapping(value = "/balances/export", produces = "text/csv;charset=UTF-8") + org.springframework.http.ResponseEntity balancesExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + List filtered = filterBalances(u, f); + StringBuilder csv = new StringBuilder("\uFEFF후정산일,대상,대상명,구분,사유,정산금액,비고\n"); + for (HomesBalance b : filtered) { + Map row = balanceMap(b); + csv.append(csvCell(row.get("baseDate"))).append(',') + .append(csvCell(row.get("targetType"))).append(',') + .append(csvCell(row.get("targetName"))).append(',') + .append(csvCell(row.get("balanceType"))).append(',') + .append(csvCell(row.get("reason"))).append(',') + .append(csvCell(row.get("amount"))).append(',') + .append(csvCell(row.get("memo"))).append('\n'); + } + return org.springframework.http.ResponseEntity.ok() + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=contract-balances.csv") + .body(csv.toString()); + } + + private List filterBalances(JwtUser u, Map f) { + List all = listAll(HomesBalance.class, u); + String tab = blankToNull(f.get("tab")); + if (tab == null) tab = blankToNull(f.get("balanceType")); + if ("ALL".equalsIgnoreCase(tab)) tab = null; + LocalDate from = parseDate(f.get("dateFrom")); + LocalDate to = parseDate(f.get("dateTo")); + String targetType = blankToNull(f.get("targetType")); + String targetName = blankToNull(f.get("targetName")); + String employee = blankToNull(f.get("employee")); + String agency = blankToNull(f.get("agency")); + String reason = blankToNull(f.get("reason")); + String q = qOf(f); + String t = tab; + return all.stream().filter(b -> { + String bt = normalizeBalanceType(b.getBalanceType()); + if (t != null && !t.equals(bt)) return false; + if (from != null && (b.getBaseDate() == null || b.getBaseDate().isBefore(from))) return false; + if (to != null && (b.getBaseDate() == null || b.getBaseDate().isAfter(to))) return false; + String tt = resolveTargetType(b); + String tn = resolveTargetName(b); + if (targetType != null && !targetType.equals(tt)) return false; + if (targetName != null && !targetName.equals(tn)) return false; + if (employee != null && (!"직원".equals(tt) || !employee.equals(tn))) return false; + if (agency != null && (!"하부점".equals(tt) || !agency.equals(tn))) return false; + if (reason != null && !reason.equals(nullToEmpty(b.getReason()))) return false; + if (q != null) { + String hay = (tn + " " + nullToEmpty(b.getReason()) + " " + nullToEmpty(b.getMemo()) + + " " + nullToEmpty(b.getCustomerName()) + " " + nullToEmpty(b.getAgency())).toLowerCase(); + if (!hay.contains(q.toLowerCase())) return false; + } + return true; + }).sorted(Comparator.comparing(HomesBalance::getBaseDate, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(HomesBalance::getId, Comparator.reverseOrder())).toList(); + } + + private void normalizeBalancePayload(Map d) { + if (d.get("balanceType") != null) d.put("balanceType", normalizeBalanceType(String.valueOf(d.get("balanceType")))); + if (d.get("targetType") != null) { + String tt = String.valueOf(d.get("targetType")); + d.put("targetType", tt); + if ("하부점".equals(tt) && d.get("targetName") != null) d.put("agency", d.get("targetName")); + if ("직원".equals(tt) && d.get("targetName") != null) d.put("customerName", d.get("targetName")); + } + if (d.get("amount") != null) { + BigDecimal amt = new BigDecimal(String.valueOf(d.get("amount"))); + String bt = normalizeBalanceType(d.get("balanceType") == null ? null : String.valueOf(d.get("balanceType"))); + if ("정산차감".equals(bt) && amt.signum() > 0) amt = amt.negate(); + if ("정산추가".equals(bt) && amt.signum() < 0) amt = amt.abs(); + d.put("amount", amt); + } + } + + private static String normalizeBalanceType(String t) { + if (t == null || t.isBlank()) return t; + if ("차감".equals(t) || "정산차감".equals(t)) return "정산차감"; + if ("추가".equals(t) || "정산추가".equals(t)) return "정산추가"; + return t; + } + + private String resolveTargetType(HomesBalance b) { + if (b.getTargetType() != null && !b.getTargetType().isBlank()) return b.getTargetType(); + if (b.getAgency() != null && !b.getAgency().isBlank() && (b.getCustomerName() == null || b.getCustomerName().isBlank())) return "하부점"; + return "직원"; + } + + private String resolveTargetName(HomesBalance b) { + if (b.getTargetName() != null && !b.getTargetName().isBlank()) return b.getTargetName(); + if ("하부점".equals(resolveTargetType(b))) return nullToEmpty(b.getAgency()); + if (b.getCustomerName() != null && !b.getCustomerName().isBlank()) return b.getCustomerName(); + return nullToEmpty(b.getAgency()); + } + + private static String csvCell(Object value) { + String s = String.valueOf(value == null ? "" : value); + return "\"" + s.replace("\"", "\"\"") + "\""; + } + + // ---- Invoices ---- + @GetMapping("/invoices") + ApiResponse invoices(@AuthenticationPrincipal JwtUser u, @RequestParam Map f, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) { + List all = listAll(HomesInvoice.class, u); + String scope = blankToNull(f.get("scope")); + String ym = blankToNull(f.get("yearMonth")); + String q = qOf(f); + List filtered = all.stream().filter(i -> { + if (scope != null && !scope.equals(i.getScope())) return false; + if (ym != null && !ym.equals(i.getYearMonth())) return false; + if ("MY".equals(scope)) { + YearMonth limit = YearMonth.now().minusMonths(11); + if (i.getYearMonth() != null) { + try { + if (YearMonth.parse(i.getYearMonth()).isBefore(limit)) return false; + } catch (Exception ignored) {} + } + } + if (q != null) { + String hay = (nullToEmpty(i.getEmployee()) + " " + nullToEmpty(i.getAgency()) + " " + nullToEmpty(i.getYearMonth())).toLowerCase(); + if (!hay.contains(q.toLowerCase())) return false; + } + return true; + }).toList(); + return pageOk(filtered, page, size, this::invoiceMap, null); + } + + @GetMapping("/invoices/my") + ApiResponse invoicesMy(@AuthenticationPrincipal JwtUser u) { + String myName = resolveMemberName(u); + YearMonth limit = YearMonth.now().minusMonths(11); + List list = listAll(HomesInvoice.class, u).stream() + .filter(i -> Boolean.TRUE.equals(i.getIssued()) || "발행".equals(i.getStatus()) || "확정".equals(i.getStatus())) + .filter(i -> { + if (i.getYearMonth() == null || i.getYearMonth().isBlank()) return false; + try { return !YearMonth.parse(i.getYearMonth()).isBefore(limit); } + catch (Exception e) { return false; } + }) + .filter(i -> myName == null || myName.isBlank() || myName.equals(i.getEmployee())) + .sorted(Comparator.comparing(HomesInvoice::getYearMonth, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(HomesInvoice::getId, Comparator.reverseOrder())) + .toList(); + Map data = new LinkedHashMap<>(); + data.put("total", list.size()); + data.put("employee", myName); + data.put("list", list.stream().map(this::invoiceMap).toList()); + return ApiResponse.ok(data); + } + + @GetMapping(value = "/invoices/{id}/export", produces = "text/csv;charset=UTF-8") + org.springframework.http.ResponseEntity invoiceExport(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesInvoice inv = crud.get(HomesInvoice.class, id, u); + Map row = invoiceMap(inv); + StringBuilder csv = new StringBuilder("\uFEFF기준월,직원,인터넷,인터넷금액,홈렌탈,홈렌탈금액,후정산,후정산금액,정산금액,상태\n"); + csv.append(csvCell(row.get("yearMonth"))).append(',') + .append(csvCell(row.get("employee"))).append(',') + .append(csvCell(row.get("internetCount"))).append(',') + .append(csvCell(row.get("internetAmount"))).append(',') + .append(csvCell(row.get("homeCount"))).append(',') + .append(csvCell(row.get("homeAmount"))).append(',') + .append(csvCell(row.get("balanceCount"))).append(',') + .append(csvCell(row.get("balanceAmount"))).append(',') + .append(csvCell(row.get("settleAmount"))).append(',') + .append(csvCell(row.get("status"))).append('\n'); + return org.springframework.http.ResponseEntity.ok() + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=invoice-" + nullToEmpty(inv.getYearMonth()) + ".csv") + .body(csv.toString()); + } + + private String resolveMemberName(JwtUser u) { + List members = em.createQuery("select m from Member m where m.groupId=:g and m.userid=:uid", Member.class) + .setParameter("g", u.getGroupId()).setParameter("uid", u.getUserid()).getResultList(); + if (members.isEmpty()) return null; + return members.getFirst().getName(); + } + + @GetMapping("/invoices/publish") + ApiResponse invoicesPublish(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + String ym = blankToNull(f.get("yearMonth")); + if (ym == null) ym = YearMonth.now().toString(); + final String yearMonthKey = ym; + YearMonth yearMonth; + try { yearMonth = YearMonth.parse(yearMonthKey); } + catch (Exception e) { return ApiResponse.fail("정산월이 올바르지 않습니다."); } + LocalDate from = yearMonth.atDay(1); + LocalDate to = yearMonth.atEndOfMonth(); + + // 완료 계약만 집계 + Map> byEmp = listAll(HomesContract.class, u).stream() + .filter(c -> "완료".equals(c.getStatus())) + .filter(c -> c.getContractDate() != null && !c.getContractDate().isBefore(from) && !c.getContractDate().isAfter(to)) + .filter(c -> c.getEmployee() != null && !c.getEmployee().isBlank()) + .collect(Collectors.groupingBy(HomesContract::getEmployee, LinkedHashMap::new, Collectors.toList())); + + Map> balByEmp = listAll(HomesBalance.class, u).stream() + .filter(b -> b.getBaseDate() != null && !b.getBaseDate().isBefore(from) && !b.getBaseDate().isAfter(to)) + .collect(Collectors.groupingBy(b -> { + String tn = resolveTargetName(b); + return tn.isBlank() ? "미지정" : tn; + }, LinkedHashMap::new, Collectors.toList())); + + Map issuedMap = listAll(HomesInvoice.class, u).stream() + .filter(i -> "PUBLISH".equals(i.getScope()) && yearMonthKey.equals(i.getYearMonth())) + .filter(i -> i.getEmployee() != null) + .collect(Collectors.toMap(HomesInvoice::getEmployee, i -> i, (a, b) -> a, LinkedHashMap::new)); + + Set names = new TreeSet<>(); + names.addAll(byEmp.keySet()); + // 후정산만 있는 직원도 포함 + for (String n : balByEmp.keySet()) { + if (byEmp.containsKey(n) || issuedMap.containsKey(n)) names.add(n); + } + names.addAll(issuedMap.keySet()); + + List> list = new ArrayList<>(); + for (String emp : names) { + List contracts = byEmp.getOrDefault(emp, List.of()); + List bals = balByEmp.getOrDefault(emp, List.of()); + int internetCount = (int) contracts.stream().filter(c -> "INTERNET".equals(c.getKind())).count(); + int homeCount = (int) contracts.stream().filter(c -> "HOME".equals(c.getKind())).count(); + BigDecimal internetAmount = contracts.stream().filter(c -> "INTERNET".equals(c.getKind())) + .map(c -> bd(c.getSettleAmount())).reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal homeAmount = contracts.stream().filter(c -> "HOME".equals(c.getKind())) + .map(c -> bd(c.getSettleAmount())).reduce(BigDecimal.ZERO, BigDecimal::add); + int balanceCount = bals.size(); + BigDecimal balanceAmount = bals.stream().map(b -> bd(b.getAmount())).reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal settleAmount = internetAmount.add(homeAmount).add(balanceAmount); + if (internetCount + homeCount + balanceCount == 0 && !issuedMap.containsKey(emp)) continue; + + HomesInvoice inv = issuedMap.get(emp); + Map row = new LinkedHashMap<>(); + row.put("employee", emp); + row.put("yearMonth", yearMonthKey); + row.put("internetCount", internetCount); + row.put("internetAmount", internetAmount); + row.put("homeCount", homeCount); + row.put("homeAmount", homeAmount); + row.put("balanceCount", balanceCount); + row.put("balanceAmount", balanceAmount); + row.put("settleAmount", settleAmount); + row.put("contractCount", internetCount + homeCount); + if (inv != null) { + row.put("id", inv.getId()); + row.put("issued", Boolean.TRUE.equals(inv.getIssued()) || "발행".equals(inv.getStatus()) || "확정".equals(inv.getStatus())); + row.put("status", inv.getStatus()); + row.put("consent", inv.getConsent() == null || inv.getConsent().isBlank() ? "-" : inv.getConsent()); + row.put("taxInvoice", inv.getTaxInvoice() == null || inv.getTaxInvoice().isBlank() ? "-" : inv.getTaxInvoice()); + row.put("withdrawStatus", inv.getWithdrawStatus() == null || inv.getWithdrawStatus().isBlank() ? "-" : inv.getWithdrawStatus()); + BigDecimal snap = inv.getSettleAmount() != null ? inv.getSettleAmount() : bd(inv.getAmount()); + row.put("mismatched", snap.compareTo(settleAmount) != 0); + } else { + row.put("id", null); + row.put("issued", false); + row.put("status", "미발행"); + row.put("consent", "-"); + row.put("taxInvoice", "-"); + row.put("withdrawStatus", "-"); + row.put("mismatched", false); + } + list.add(row); + } + + Map data = new LinkedHashMap<>(); + data.put("yearMonth", yearMonthKey); + data.put("total", list.size()); + data.put("list", list); + return ApiResponse.ok(data); + } + + @PostMapping("/invoices/publish/issue") @Transactional + ApiResponse invoicesPublishIssue(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + String ym = body.get("yearMonth") == null ? null : String.valueOf(body.get("yearMonth")); + String employee = body.get("employee") == null ? null : String.valueOf(body.get("employee")); + if (ym == null || ym.isBlank() || employee == null || employee.isBlank()) { + return ApiResponse.fail("정산월과 직원을 확인하세요."); + } + // 최신 집계 재계산 + Map f = Map.of("yearMonth", ym); + ApiResponse pub = invoicesPublish(u, f); + @SuppressWarnings("unchecked") + Map data = (Map) pub.data(); + @SuppressWarnings("unchecked") + List> rows = data == null ? List.of() : (List>) data.getOrDefault("list", List.of()); + Map src = rows.stream().filter(r -> employee.equals(String.valueOf(r.get("employee")))).findFirst().orElse(null); + if (src == null) return ApiResponse.fail("발행할 정산 자료가 없습니다. (완료 계약만 발행 가능)"); + + HomesInvoice inv = listAll(HomesInvoice.class, u).stream() + .filter(i -> "PUBLISH".equals(i.getScope()) && ym.equals(i.getYearMonth()) && employee.equals(i.getEmployee())) + .findFirst().orElse(null); + boolean created = false; + if (inv == null) { + inv = new HomesInvoice(); + inv.setGroupId(u.getGroupId()); + inv.setScope("PUBLISH"); + inv.setYearMonth(ym); + inv.setEmployee(employee); + created = true; + } + boolean reissue = !created && (Boolean.TRUE.equals(inv.getIssued()) || "발행".equals(inv.getStatus()) || "확정".equals(inv.getStatus())); + inv.setInternetCount(((Number) src.getOrDefault("internetCount", 0)).intValue()); + inv.setHomeCount(((Number) src.getOrDefault("homeCount", 0)).intValue()); + inv.setBalanceCount(((Number) src.getOrDefault("balanceCount", 0)).intValue()); + inv.setInternetAmount(bd(src.get("internetAmount"))); + inv.setHomeAmount(bd(src.get("homeAmount"))); + inv.setBalanceAmount(bd(src.get("balanceAmount"))); + inv.setSettleAmount(bd(src.get("settleAmount"))); + inv.setContractCount(((Number) src.getOrDefault("contractCount", 0)).intValue()); + inv.setAmount(bd(src.get("settleAmount"))); + inv.setIssued(true); + inv.setStatus("발행"); + if (inv.getConsent() == null || inv.getConsent().isBlank()) inv.setConsent("대기"); + if (inv.getTaxInvoice() == null || inv.getTaxInvoice().isBlank()) inv.setTaxInvoice("미발행"); + if (inv.getWithdrawStatus() == null || inv.getWithdrawStatus().isBlank()) inv.setWithdrawStatus("대기"); + if (created) em.persist(inv); + writeLog(u, "INVOICE", reissue ? "정산서-재발행" : "정산서-발행", + "정산서 [" + employee + "] " + (reissue ? "재발행" : "발행") + "하였습니다."); + return ApiResponse.ok(invoiceMap(inv)); + } + + @GetMapping(value = "/invoices/publish/export", produces = "text/csv;charset=UTF-8") + org.springframework.http.ResponseEntity invoicesPublishExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + ApiResponse res = invoicesPublish(u, f); + @SuppressWarnings("unchecked") + Map data = (Map) res.data(); + @SuppressWarnings("unchecked") + List> list = data == null ? List.of() : (List>) data.getOrDefault("list", List.of()); + StringBuilder csv = new StringBuilder("\uFEFF직원,인터넷,인터넷금액,홈렌탈,홈렌탈금액,후정산,후정산금액,정산금액,정산서발행,동의,계산서,출금상태\n"); + for (Map row : list) { + csv.append(csvCell(row.get("employee"))).append(',') + .append(csvCell(row.get("internetCount"))).append(',') + .append(csvCell(row.get("internetAmount"))).append(',') + .append(csvCell(row.get("homeCount"))).append(',') + .append(csvCell(row.get("homeAmount"))).append(',') + .append(csvCell(row.get("balanceCount"))).append(',') + .append(csvCell(row.get("balanceAmount"))).append(',') + .append(csvCell(row.get("settleAmount"))).append(',') + .append(csvCell(Boolean.TRUE.equals(row.get("issued")) ? "발행" : "미발행")).append(',') + .append(csvCell(row.get("consent"))).append(',') + .append(csvCell(row.get("taxInvoice"))).append(',') + .append(csvCell(row.get("withdrawStatus"))).append('\n'); + } + return org.springframework.http.ResponseEntity.ok() + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=invoice-publish.csv") + .body(csv.toString()); + } + + @PostMapping("/invoices") @Transactional ApiResponse invoicePost(@RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + HomesInvoice inv = crud.create(HomesInvoice.class, d, u); + writeLog(u, "INVOICE", "정산서-등록", + "정산서 [" + nullToEmpty(inv.getEmployee()) + "] 등록하였습니다."); + return ApiResponse.ok(invoiceMap(inv)); + } + @PutMapping("/invoices/{id}") @Transactional ApiResponse invoicePut(@PathVariable Long id, @RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + HomesInvoice inv = crud.update(HomesInvoice.class, id, d, u); + writeLog(u, "INVOICE", "정산서-수정", + "정산서 [" + nullToEmpty(inv.getEmployee()) + "] 수정하였습니다."); + return ApiResponse.ok(invoiceMap(inv)); + } + @DeleteMapping("/invoices/{id}") @Transactional ApiResponse invoiceDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesInvoice inv = crud.get(HomesInvoice.class, id, u); + writeLog(u, "INVOICE", "정산서-삭제", + "정산서 [" + nullToEmpty(inv.getEmployee()) + "] 삭제하였습니다."); + crud.delete(HomesInvoice.class, id, u); return ApiResponse.ok(null); + } + + // ---- Pendings ---- + @GetMapping("/pendings") + ApiResponse pendings(@AuthenticationPrincipal JwtUser u, @RequestParam Map f, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) { + List all = listAll(HomesPending.class, u); + String type = blankToNull(f.get("pendingType")); + if (type == null) type = blankToNull(f.get("tab")); + if ("ALL".equalsIgnoreCase(type)) type = null; + String status = blankToNull(f.get("status")); + if ("ALL".equalsIgnoreCase(status)) status = null; + String receiveEmployee = blankToNull(f.get("receiveEmployee")); + String processEmployee = blankToNull(f.get("processEmployee")); + String phoneTail = blankToNull(f.get("phoneTail")); + LocalDate from = parseDate(f.get("dateFrom")); + LocalDate to = parseDate(f.get("dateTo")); + String q = qOf(f); + String t = type; + String st = status; + List filtered = all.stream().filter(p -> { + if (t != null && !t.equals(p.getPendingType())) return false; + if (st != null && !normalizePendingStatus(p.getStatus()).equals(st)) return false; + String recv = resolveReceiveEmployee(p); + String proc = nullToEmpty(p.getProcessEmployee()); + if (receiveEmployee != null && !receiveEmployee.equals(recv)) return false; + if (processEmployee != null && !processEmployee.equals(proc)) return false; + if (phoneTail != null) { + String digits = nullToEmpty(p.getPhone()).replaceAll("\\D", ""); + if (!digits.endsWith(phoneTail)) return false; + } + if (from != null && (p.getPendingDate() == null || p.getPendingDate().isBefore(from))) return false; + if (to != null && (p.getPendingDate() == null || p.getPendingDate().isAfter(to))) return false; + if (q != null) { + String hay = (nullToEmpty(p.getCustomerName()) + " " + nullToEmpty(p.getContents()) + " " + recv + " " + proc).toLowerCase(); + if (!hay.contains(q.toLowerCase())) return false; + } + return true; + }).sorted(Comparator.comparing(HomesPending::getPendingDate, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(HomesPending::getId, Comparator.reverseOrder())).toList(); + + Map counts = new LinkedHashMap<>(); + counts.put("ALL", (long) all.size()); + List typeList = pendingTypeList(u); + for (String s : typeList) { + counts.put(s, all.stream().filter(p -> s.equals(p.getPendingType())).count()); + } + Set receiveEmployees = all.stream().map(this::resolveReceiveEmployee).filter(s -> !s.isBlank()) + .collect(Collectors.toCollection(TreeSet::new)); + Set processEmployees = all.stream().map(HomesPending::getProcessEmployee).filter(Objects::nonNull).filter(s -> !s.isBlank()) + .collect(Collectors.toCollection(TreeSet::new)); + em.createQuery("select m from Member m where m.groupId=:g and m.role='AGENT'", Member.class) + .setParameter("g", u.getGroupId()).getResultList().stream() + .map(Member::getName).filter(Objects::nonNull).filter(s -> !s.isBlank()) + .forEach(n -> { receiveEmployees.add(n); processEmployees.add(n); }); + + Set statuses = all.stream().map(p -> normalizePendingStatus(p.getStatus())).filter(s -> !s.isBlank()) + .collect(Collectors.toCollection(TreeSet::new)); + statuses.add("접수"); statuses.add("해결"); + + Map data = new LinkedHashMap<>(); + int fromIdx = Math.max(0, page) * Math.max(1, size); + data.put("total", filtered.size()); + data.put("list", filtered.stream().skip(fromIdx).limit(Math.max(size, 1)).map(this::pendingMap).toList()); + data.put("counts", counts); + data.put("types", typeList); + data.put("statuses", statuses); + data.put("receiveEmployees", receiveEmployees); + data.put("processEmployees", processEmployees); + return ApiResponse.ok(data); + } + + @GetMapping("/pendings/types") + ApiResponse pendingTypesGet(@AuthenticationPrincipal JwtUser u) { + return ApiResponse.ok(Map.of("types", pendingTypeList(u))); + } + + @PutMapping("/pendings/types") @Transactional + ApiResponse pendingTypesPut(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + Object raw = body.get("types"); + List types = new ArrayList<>(); + if (raw instanceof List list) { + for (Object o : list) { + if (o == null) continue; + String s = String.valueOf(o).trim(); + if (!s.isBlank() && !types.contains(s)) types.add(s); + } + } + if (types.isEmpty()) types = new ArrayList<>(List.of("일반", "약속1", "약속2")); + saveJsonSetting(u.getGroupId(), "homes.pendingTypes", Map.of("types", types)); + return ApiResponse.ok(Map.of("types", types)); + } + + @PostMapping("/pendings") @Transactional ApiResponse pendingPost(@RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + normalizePendingPayload(d, u); + HomesPending p = crud.create(HomesPending.class, d, u); + writeLog(u, "PENDING", "약속-등록", "약속 [" + nullToEmpty(p.getPendingType()) + "] 등록하였습니다."); + return ApiResponse.ok(pendingMap(p)); + } + @PutMapping("/pendings/{id}") @Transactional ApiResponse pendingPut(@PathVariable Long id, @RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + normalizePendingPayload(d, u); + HomesPending p = crud.update(HomesPending.class, id, d, u); + writeLog(u, "PENDING", "약속-수정", "약속 [" + nullToEmpty(p.getPendingType()) + "] 수정하였습니다."); + return ApiResponse.ok(pendingMap(p)); + } + @DeleteMapping("/pendings/{id}") @Transactional ApiResponse pendingDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesPending p = crud.get(HomesPending.class, id, u); + writeLog(u, "PENDING", "약속-삭제", "약속 [" + nullToEmpty(p.getPendingType()) + "] 삭제하였습니다."); + crud.delete(HomesPending.class, id, u); return ApiResponse.ok(null); + } + + @PostMapping("/pendings/process") @Transactional + ApiResponse pendingsProcess(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + Object raw = body.get("ids"); + if (!(raw instanceof List list) || list.isEmpty()) return ApiResponse.fail("처리할 약속을 선택하세요."); + String processEmployee = body.get("processEmployee") == null ? resolveMemberName(u) : String.valueOf(body.get("processEmployee")); + int updated = 0; + for (Object o : list) { + Long id = Long.valueOf(String.valueOf(o)); + HomesPending p = em.find(HomesPending.class, id); + if (p == null || !Objects.equals(p.getGroupId(), u.getGroupId())) continue; + p.setStatus("해결"); + p.setProcessEmployee(processEmployee); + p.setProcessDate(LocalDate.now()); + writeLog(u, "PENDING", "약속-해결", "약속 [" + nullToEmpty(p.getPendingType()) + "] 해결완료하였습니다."); + updated++; + } + return ApiResponse.ok(Map.of("updated", updated)); + } + + @PostMapping("/pendings/delete") @Transactional + ApiResponse pendingsBulkDelete(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + Object raw = body.get("ids"); + if (!(raw instanceof List list) || list.isEmpty()) return ApiResponse.fail("삭제할 약속을 선택하세요."); + int deleted = 0; + for (Object o : list) { + Long id = Long.valueOf(String.valueOf(o)); + HomesPending p = em.find(HomesPending.class, id); + if (p == null || !Objects.equals(p.getGroupId(), u.getGroupId())) continue; + writeLog(u, "PENDING", "약속-삭제", "약속 [" + nullToEmpty(p.getPendingType()) + "] 삭제하였습니다."); + em.remove(p); + deleted++; + } + return ApiResponse.ok(Map.of("deleted", deleted)); + } + + @GetMapping(value = "/pendings/export", produces = "text/csv;charset=UTF-8") + org.springframework.http.ResponseEntity pendingsExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + ApiResponse res = pendings(u, f, 0, 10000); + @SuppressWarnings("unchecked") + Map data = (Map) res.data(); + @SuppressWarnings("unchecked") + List> list = data == null ? List.of() : (List>) data.getOrDefault("list", List.of()); + StringBuilder csv = new StringBuilder("\uFEFF상태,일자,과목,내용,고객명,연락처,접수직원,처리일,처리직원,비고\n"); + for (Map row : list) { + csv.append(csvCell(row.get("status"))).append(',') + .append(csvCell(row.get("pendingDate"))).append(',') + .append(csvCell(row.get("pendingType"))).append(',') + .append(csvCell(row.get("contents"))).append(',') + .append(csvCell(row.get("customerName"))).append(',') + .append(csvCell(row.get("phone"))).append(',') + .append(csvCell(row.get("receiveEmployee"))).append(',') + .append(csvCell(row.get("processDate"))).append(',') + .append(csvCell(row.get("processEmployee"))).append(',') + .append(csvCell(row.get("memo"))).append('\n'); + } + return org.springframework.http.ResponseEntity.ok() + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=pendings.csv") + .body(csv.toString()); + } + + private List pendingTypeList(JwtUser u) { + Map saved = loadJsonSetting(u.getGroupId(), "homes.pendingTypes", Map.of()); + Object raw = saved.get("types"); + List types = new ArrayList<>(); + if (raw instanceof List list) { + for (Object o : list) { + if (o == null) continue; + String s = String.valueOf(o).trim(); + if (!s.isBlank() && !types.contains(s)) types.add(s); + } + } + if (types.isEmpty()) types.addAll(List.of("일반", "약속1", "약속2")); + return types; + } + + private void normalizePendingPayload(Map d, JwtUser u) { + if (d.get("status") != null) d.put("status", normalizePendingStatus(String.valueOf(d.get("status")))); + if (d.get("receiveEmployee") != null) { + d.put("employee", d.get("receiveEmployee")); + } else if (d.get("employee") == null) { + String name = resolveMemberName(u); + if (name != null) { + d.put("receiveEmployee", name); + d.put("employee", name); + } + } + if (d.containsKey("phone1") || d.containsKey("phone2") || d.containsKey("phone3")) { + String p1 = d.get("phone1") == null ? "" : String.valueOf(d.get("phone1")).trim(); + String p2 = d.get("phone2") == null ? "" : String.valueOf(d.get("phone2")).trim(); + String p3 = d.get("phone3") == null ? "" : String.valueOf(d.get("phone3")).trim(); + List parts = new ArrayList<>(); + if (!p1.isBlank()) parts.add(p1); + if (!p2.isBlank()) parts.add(p2); + if (!p3.isBlank()) parts.add(p3); + if (!parts.isEmpty()) d.put("phone", String.join("-", parts)); + } + } + + private static String normalizePendingStatus(String s) { + if (s == null || s.isBlank()) return "접수"; + if ("예정".equals(s) || "접수".equals(s)) return "접수"; + if ("완료".equals(s) || "해결".equals(s)) return "해결"; + if ("보류".equals(s)) return "보류"; + if ("취소".equals(s)) return "취소"; + return s; + } + + private String resolveReceiveEmployee(HomesPending p) { + if (p.getReceiveEmployee() != null && !p.getReceiveEmployee().isBlank()) return p.getReceiveEmployee(); + return nullToEmpty(p.getEmployee()); + } + + // ---- Bookings ---- + private static final List BOOKING_INTERNET_OPTIONS = List.of("인터넷", "TV", "전화", "와이파이", "스마트홈", "기타"); + private static final List BOOKING_RENTAL_OPTIONS = List.of("정수기", "공기청정기", "비데", "매트리스", "가전", "기타"); + + @GetMapping("/bookings") + ApiResponse bookings(@AuthenticationPrincipal JwtUser u, @RequestParam Map f, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) { + List all = listAll(HomesBooking.class, u); + String status = blankToNull(f.get("status")); + if ("ALL".equalsIgnoreCase(status)) status = null; + if (status == null) status = blankToNull(f.get("tab")); + if ("ALL".equalsIgnoreCase(status)) status = null; + LocalDate from = parseDate(f.get("dateFrom")); + LocalDate to = parseDate(f.get("dateTo")); + String employee = blankToNull(f.get("employee")); + if (employee == null) employee = blankToNull(f.get("receiveEmployee")); + String internetProduct = blankToNull(f.get("internetProduct")); + String rentalProduct = blankToNull(f.get("rentalProduct")); + String phoneTail = blankToNull(f.get("phoneTail")); + String q = qOf(f); + String st = status; + String emp = employee; + List filtered = all.stream().filter(b -> { + if (st != null && !st.equals(b.getStatus())) return false; + if (from != null && (b.getBookingDate() == null || b.getBookingDate().isBefore(from))) return false; + if (to != null && (b.getBookingDate() == null || b.getBookingDate().isAfter(to))) return false; + if (emp != null && !emp.equals(b.getEmployee())) return false; + if (internetProduct != null && !containsProductToken(resolveInternetProducts(b), internetProduct)) return false; + if (rentalProduct != null && !containsProductToken(resolveRentalProducts(b), rentalProduct)) return false; + if (phoneTail != null) { + String digits = nullToEmpty(b.getPhone()).replaceAll("\\D", ""); + if (digits.length() < 4 || !digits.endsWith(phoneTail)) return false; + } + if (q != null) { + String hay = (nullToEmpty(b.getCustomerName()) + " " + nullToEmpty(b.getPhone()) + " " + + nullToEmpty(b.getProducts()) + " " + nullToEmpty(b.getInternetProducts()) + " " + + nullToEmpty(b.getRentalProducts()) + " " + nullToEmpty(b.getAddress()) + " " + + nullToEmpty(b.getMemo())).toLowerCase(); + if (!hay.contains(q.toLowerCase())) return false; + } + return true; + }).toList(); + Map counts = new LinkedHashMap<>(); + counts.put("ALL", (long) all.size()); + for (String s : List.of("접수", "완료", "보류")) { + counts.put(s, all.stream().filter(b -> s.equals(b.getStatus())).count()); + } + List sorted = filtered.stream() + .sorted(Comparator.comparing(HomesBooking::getBookingDate, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(HomesBooking::getId, Comparator.reverseOrder())) + .toList(); + ApiResponse res = pageOk(sorted, page, size, this::bookingMap, counts); + @SuppressWarnings("unchecked") + Map body = (Map) res.data(); + if (body != null) { + body.put("employees", all.stream().map(HomesBooking::getEmployee).filter(Objects::nonNull) + .map(String::trim).filter(s -> !s.isBlank()).distinct().sorted().toList()); + body.put("internetOptions", BOOKING_INTERNET_OPTIONS); + body.put("rentalOptions", BOOKING_RENTAL_OPTIONS); + } + return res; + } + + @PostMapping("/bookings") @Transactional ApiResponse bookingPost(@RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + normalizeBookingPayload(d, u); + HomesBooking b = crud.create(HomesBooking.class, d, u); + ensureBookingCustomer(b, u); + writeLog(u, "BOOKING", "예약-등록", "예약 [" + nullToEmpty(b.getCustomerName()) + "] 등록하였습니다."); + return ApiResponse.ok(bookingMap(b)); + } + @PutMapping("/bookings/{id}") @Transactional ApiResponse bookingPut(@PathVariable Long id, @RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + HomesBooking before = crud.get(HomesBooking.class, id, u); + String prevStatus = before.getStatus(); + normalizeBookingPayload(d, u); + HomesBooking b = crud.update(HomesBooking.class, id, d, u); + String nextStatus = b.getStatus(); + if (nextStatus != null && !nextStatus.equals(prevStatus)) { + writeLog(u, "BOOKING", "예약-상태", + "예약 [" + nullToEmpty(b.getCustomerName()) + "] " + nextStatus + "(으)로 상태변경하였습니다."); + } else { + writeLog(u, "BOOKING", "예약-수정", "예약 [" + nullToEmpty(b.getCustomerName()) + "] 수정하였습니다."); + } + return ApiResponse.ok(bookingMap(b)); + } + @DeleteMapping("/bookings/{id}") @Transactional ApiResponse bookingDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesBooking b = crud.get(HomesBooking.class, id, u); + writeLog(u, "BOOKING", "예약-삭제", "예약 [" + nullToEmpty(b.getCustomerName()) + "] 삭제하였습니다."); + crud.delete(HomesBooking.class, id, u); return ApiResponse.ok(null); + } + + @PostMapping("/bookings/delete") @Transactional + ApiResponse bookingsBulkDelete(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + Object raw = body.get("ids"); + if (!(raw instanceof List list) || list.isEmpty()) return ApiResponse.fail("삭제할 예약을 선택하세요."); + int deleted = 0; + for (Object o : list) { + Long id = Long.valueOf(String.valueOf(o)); + HomesBooking b = em.find(HomesBooking.class, id); + if (b == null || !Objects.equals(b.getGroupId(), u.getGroupId())) continue; + writeLog(u, "BOOKING", "예약-삭제", "예약 [" + nullToEmpty(b.getCustomerName()) + "] 삭제하였습니다."); + em.remove(b); + deleted++; + } + return ApiResponse.ok(Map.of("deleted", deleted)); + } + + @GetMapping(value = "/bookings/export", produces = "text/csv;charset=UTF-8") + org.springframework.http.ResponseEntity bookingsExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + ApiResponse res = bookings(u, f, 0, 10000); + @SuppressWarnings("unchecked") + Map data = (Map) res.data(); + @SuppressWarnings("unchecked") + List> list = data == null ? List.of() : (List>) data.getOrDefault("list", List.of()); + StringBuilder csv = new StringBuilder("\uFEFF접수일,상태,고객명,연락처,인터넷상품,홈렌탈상품,접수직원,주소,비고\n"); + for (Map row : list) { + csv.append(csvCell(row.get("bookingDate"))).append(',') + .append(csvCell(row.get("status"))).append(',') + .append(csvCell(row.get("customerName"))).append(',') + .append(csvCell(row.get("phone"))).append(',') + .append(csvCell(row.get("internetProducts"))).append(',') + .append(csvCell(row.get("rentalProducts"))).append(',') + .append(csvCell(row.get("employee"))).append(',') + .append(csvCell(row.get("address"))).append(',') + .append(csvCell(row.get("memo"))).append('\n'); + } + return org.springframework.http.ResponseEntity.ok() + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=bookings.csv") + .body(csv.toString()); + } + + // ---- Customers ---- + private static final List DEFAULT_CUSTOMER_GRADES = List.of("단골", "단골1", "단골2", "단골3", "단골4"); + + @GetMapping("/customers") + ApiResponse customers(@AuthenticationPrincipal JwtUser u, @RequestParam Map f, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) { + List all = listAll(HomesCustomer.class, u); + String grade = blankToNull(f.get("grade")); + if (grade == null) grade = blankToNull(f.get("tab")); + if ("ALL".equalsIgnoreCase(grade)) grade = null; + String employee = blankToNull(f.get("employee")); + if (employee == null) employee = blankToNull(f.get("receiveEmployee")); + String customerType = blankToNull(f.get("customerType")); + String history = blankToNull(f.get("history")); + String searchType = blankToNull(f.get("searchType")); + String phoneTail = blankToNull(f.get("phoneTail")); + LocalDate from = parseDate(f.get("dateFrom")); + LocalDate to = parseDate(f.get("dateTo")); + String q = qOf(f); + String gr = grade; + String emp = employee; + Map contractCounts = countByCustomerPhone(u, HomesContract.class); + Map bookingCounts = countByCustomerPhone(u, HomesBooking.class); + + List filtered = all.stream().filter(c -> { + if (gr != null && !gr.equals(normalizeCustomerGrade(c.getGrade()))) return false; + if (emp != null && !emp.equals(c.getEmployee())) return false; + if (customerType != null && !customerType.equals(nullToEmpty(c.getCustomerType()).isBlank() ? "개인" : c.getCustomerType())) return false; + LocalDate reg = resolveCustomerRegisteredDate(c); + if (from != null && (reg == null || reg.isBefore(from))) return false; + if (to != null && (reg == null || reg.isAfter(to))) return false; + String digits = nullToEmpty(c.getPhone()).replaceAll("\\D", ""); + long contracts = contractCounts.getOrDefault(digits, 0L); + long bookings = bookingCounts.getOrDefault(digits, 0L); + if ("계약".equals(history) && contracts <= 0) return false; + if ("예약".equals(history) && bookings <= 0) return false; + if ("없음".equals(history) && (contracts > 0 || bookings > 0)) return false; + if (phoneTail != null) { + if (digits.length() < 4 || !digits.endsWith(phoneTail)) return false; + } + if (q != null) { + String needle = q.toLowerCase(); + if ("name".equals(searchType) || "고객명".equals(searchType)) { + if (!nullToEmpty(c.getName()).toLowerCase().contains(needle)) return false; + } else if ("phone".equals(searchType) || "연락처".equals(searchType) || "연락처(뒷4자리)".equals(searchType)) { + if (!digits.contains(q.replaceAll("\\D", "")) && !nullToEmpty(c.getPhone()).toLowerCase().contains(needle)) return false; + } else if ("address".equals(searchType) || "주소".equals(searchType)) { + if (!nullToEmpty(c.getAddress()).toLowerCase().contains(needle) + && !nullToEmpty(c.getAddressDetail()).toLowerCase().contains(needle)) return false; + } else { + String hay = (nullToEmpty(c.getName()) + " " + nullToEmpty(c.getPhone()) + " " + + nullToEmpty(c.getPhoneAlt()) + " " + nullToEmpty(c.getAddress()) + " " + + nullToEmpty(c.getMemo())).toLowerCase(); + if (!hay.contains(needle)) return false; + } + } + return true; + }).sorted(Comparator.comparing(this::resolveCustomerRegisteredDate, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(HomesCustomer::getId, Comparator.reverseOrder())).toList(); + + List grades = customerGradeList(u); + Map counts = new LinkedHashMap<>(); + counts.put("ALL", (long) all.size()); + for (String s : grades) { + counts.put(s, all.stream().filter(c -> s.equals(normalizeCustomerGrade(c.getGrade()))).count()); + } + ApiResponse res = pageOk(filtered, page, size, c -> customerMap(c, contractCounts, bookingCounts), counts); + @SuppressWarnings("unchecked") + Map body = (Map) res.data(); + if (body != null) { + body.put("grades", grades); + body.put("employees", all.stream().map(HomesCustomer::getEmployee).filter(Objects::nonNull) + .map(String::trim).filter(s -> !s.isBlank()).distinct().sorted().toList()); + } + return res; + } + + @GetMapping("/customers/grades") + ApiResponse customerGradesGet(@AuthenticationPrincipal JwtUser u) { + return ApiResponse.ok(Map.of("grades", customerGradeList(u))); + } + + @PutMapping("/customers/grades") @Transactional + ApiResponse customerGradesPut(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + Object raw = body.get("grades"); + List grades = new ArrayList<>(); + if (raw instanceof List list) { + for (Object o : list) { + if (o == null) continue; + String s = String.valueOf(o).trim(); + if (!s.isBlank() && !grades.contains(s) && !"분류없음".equals(s) && !"ALL".equalsIgnoreCase(s)) grades.add(s); + } + } + if (grades.isEmpty()) grades = new ArrayList<>(DEFAULT_CUSTOMER_GRADES); + saveJsonSetting(u.getGroupId(), "homes.customerGrades", Map.of("grades", grades)); + return ApiResponse.ok(Map.of("grades", grades)); + } + + @PostMapping("/customers") @Transactional ApiResponse customerPost(@RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + normalizeCustomerPayload(d, u); + HomesCustomer c = crud.create(HomesCustomer.class, d, u); + writeLog(u, "CUSTOMER", "고객-등록", "고객 [" + nullToEmpty(c.getName()) + "] 등록하였습니다."); + return ApiResponse.ok(customerMap(c, Map.of(), Map.of())); + } + @PutMapping("/customers/{id}") @Transactional ApiResponse customerPut(@PathVariable Long id, @RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + normalizeCustomerPayload(d, u); + HomesCustomer c = crud.update(HomesCustomer.class, id, d, u); + writeLog(u, "CUSTOMER", "고객-수정", "고객 [" + nullToEmpty(c.getName()) + "] 수정하였습니다."); + return ApiResponse.ok(customerMap(c, Map.of(), Map.of())); + } + @DeleteMapping("/customers/{id}") @Transactional ApiResponse customerDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesCustomer c = crud.get(HomesCustomer.class, id, u); + writeLog(u, "CUSTOMER", "고객-삭제", "고객 [" + nullToEmpty(c.getName()) + "] 삭제하였습니다."); + crud.delete(HomesCustomer.class, id, u); return ApiResponse.ok(null); + } + + @PostMapping("/customers/delete") @Transactional + ApiResponse customersBulkDelete(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + Object raw = body.get("ids"); + if (!(raw instanceof List list) || list.isEmpty()) return ApiResponse.fail("삭제할 고객을 선택하세요."); + int deleted = 0; + for (Object o : list) { + Long id = Long.valueOf(String.valueOf(o)); + HomesCustomer c = em.find(HomesCustomer.class, id); + if (c == null || !Objects.equals(c.getGroupId(), u.getGroupId())) continue; + writeLog(u, "CUSTOMER", "고객-삭제", "고객 [" + nullToEmpty(c.getName()) + "] 삭제하였습니다."); + em.remove(c); + deleted++; + } + return ApiResponse.ok(Map.of("deleted", deleted)); + } + + @PostMapping("/customers/grade") @Transactional + ApiResponse customersChangeGrade(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + Object raw = body.get("ids"); + if (!(raw instanceof List list) || list.isEmpty()) return ApiResponse.fail("분류를 변경할 고객을 선택하세요."); + String grade = body.get("grade") == null ? "분류없음" : String.valueOf(body.get("grade")).trim(); + if (grade.isBlank()) grade = "분류없음"; + int updated = 0; + for (Object o : list) { + Long id = Long.valueOf(String.valueOf(o)); + HomesCustomer c = em.find(HomesCustomer.class, id); + if (c == null || !Objects.equals(c.getGroupId(), u.getGroupId())) continue; + c.setGrade(grade); + writeLog(u, "CUSTOMER", "고객-분류", "고객 [" + nullToEmpty(c.getName()) + "] 분류수정하였습니다."); + updated++; + } + return ApiResponse.ok(Map.of("updated", updated)); + } + + @GetMapping(value = "/customers/export", produces = "text/csv;charset=UTF-8") + org.springframework.http.ResponseEntity customersExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + ApiResponse res = customers(u, f, 0, 10000); + @SuppressWarnings("unchecked") + Map data = (Map) res.data(); + @SuppressWarnings("unchecked") + List> list = data == null ? List.of() : (List>) data.getOrDefault("list", List.of()); + StringBuilder csv = new StringBuilder("\uFEFF등록일,분류,구분,고객명,연락처,보조연락처,주소,메모,계약,예약,등록직원\n"); + for (Map row : list) { + csv.append(csvCell(row.get("registeredDate"))).append(',') + .append(csvCell(row.get("grade"))).append(',') + .append(csvCell(row.get("customerType"))).append(',') + .append(csvCell(row.get("name"))).append(',') + .append(csvCell(row.get("phone"))).append(',') + .append(csvCell(row.get("phoneAlt"))).append(',') + .append(csvCell(row.get("address"))).append(',') + .append(csvCell(row.get("memo"))).append(',') + .append(csvCell(row.get("contractCount"))).append(',') + .append(csvCell(row.get("bookingCount"))).append(',') + .append(csvCell(row.get("employee"))).append('\n'); + } + return org.springframework.http.ResponseEntity.ok() + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=customers.csv") + .body(csv.toString()); + } + + // ---- Abooks ---- + private static final List DEFAULT_ABOOK_CATEGORIES = List.of("일반", "123"); + private static final List ABOOK_ENTRY_TYPES = List.of("입금", "출금", "카드입금", "카드출금"); + + @GetMapping("/abooks") + ApiResponse abooks(@AuthenticationPrincipal JwtUser u, @RequestParam Map f, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) { + List all = listAll(HomesAbook.class, u); + String entryType = blankToNull(f.get("entryType")); + if (entryType == null) entryType = blankToNull(f.get("tab")); + if ("ALL".equalsIgnoreCase(entryType)) entryType = null; + String category = blankToNull(f.get("category")); + if (category == null) category = blankToNull(f.get("subject")); + String employee = blankToNull(f.get("employee")); + String item = blankToNull(f.get("item")); + String yearMonth = blankToNull(f.get("yearMonth")); + Integer day = f.get("day") == null || f.get("day").isBlank() || "ALL".equalsIgnoreCase(f.get("day")) + ? null : parseInt(f.get("day"), 0); + if (day != null && day <= 0) day = null; + LocalDate from = parseDate(f.get("dateFrom")); + LocalDate to = parseDate(f.get("dateTo")); + if (yearMonth != null && from == null && to == null) { + try { + YearMonth ym = YearMonth.parse(yearMonth); + from = ym.atDay(1); + to = ym.atEndOfMonth(); + } catch (Exception ignored) {} + } + String q = qOf(f); + String et = entryType; + String cat = category; + LocalDate fromF = from; + LocalDate toF = to; + Integer dayF = day; + + List filtered = all.stream().filter(a -> { + if (et != null && !et.equals(normalizeAbookEntryType(a.getEntryType()))) return false; + if (cat != null && !cat.equals(a.getCategory())) return false; + if (employee != null && !employee.equals(a.getEmployee())) return false; + if (item != null && !item.equals(nullToEmpty(a.getItem()).isBlank() ? a.getAgency() : a.getItem())) return false; + if (fromF != null && (a.getEntryDate() == null || a.getEntryDate().isBefore(fromF))) return false; + if (toF != null && (a.getEntryDate() == null || a.getEntryDate().isAfter(toF))) return false; + if (dayF != null && (a.getEntryDate() == null || a.getEntryDate().getDayOfMonth() != dayF)) return false; + if (q != null) { + String hay = (nullToEmpty(a.getCategory()) + " " + nullToEmpty(a.getItem()) + " " + + nullToEmpty(a.getAgency()) + " " + nullToEmpty(a.getMemo()) + " " + + nullToEmpty(a.getEmployee())).toLowerCase(); + if (!hay.contains(q.toLowerCase())) return false; + } + return true; + }).sorted(Comparator.comparing(HomesAbook::getEntryDate, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(HomesAbook::getId, Comparator.reverseOrder())).toList(); + + List categories = abookCategoryList(u); + Map counts = new LinkedHashMap<>(); + // detailed tabs by entry type (within date range / month, ignoring entryType tab) + List baseForCounts = all.stream().filter(a -> { + if (fromF != null && (a.getEntryDate() == null || a.getEntryDate().isBefore(fromF))) return false; + if (toF != null && (a.getEntryDate() == null || a.getEntryDate().isAfter(toF))) return false; + if (dayF != null && (a.getEntryDate() == null || a.getEntryDate().getDayOfMonth() != dayF)) return false; + return true; + }).toList(); + counts.put("ALL", (long) baseForCounts.size()); + for (String t : ABOOK_ENTRY_TYPES) { + counts.put(t, baseForCounts.stream().filter(a -> t.equals(normalizeAbookEntryType(a.getEntryType()))).count()); + } + Map categoryCounts = new LinkedHashMap<>(); + categoryCounts.put("ALL", (long) baseForCounts.size()); + for (String c : categories) { + categoryCounts.put(c, baseForCounts.stream().filter(a -> c.equals(a.getCategory())).count()); + } + + // cash summary for month view + LocalDate carryCutoff = fromF; + if (carryCutoff == null) { + try { + if (yearMonth != null) carryCutoff = YearMonth.parse(yearMonth).atDay(1); + } catch (Exception ignored) {} + } + if (carryCutoff == null) carryCutoff = LocalDate.now().withDayOfMonth(1); + BigDecimal carryOver = BigDecimal.ZERO; + BigDecimal deposit = BigDecimal.ZERO; + BigDecimal withdraw = BigDecimal.ZERO; + for (HomesAbook a : all) { + if (a.getEntryDate() == null || a.getAmount() == null) continue; + String type = normalizeAbookEntryType(a.getEntryType()); + boolean inPeriod = (fromF == null || !a.getEntryDate().isBefore(fromF)) + && (toF == null || !a.getEntryDate().isAfter(toF)) + && (dayF == null || a.getEntryDate().getDayOfMonth() == dayF) + && (cat == null || cat.equals(a.getCategory())); + if (a.getEntryDate().isBefore(carryCutoff) && ("입금".equals(type) || "출금".equals(type))) { + if ("입금".equals(type)) carryOver = carryOver.add(a.getAmount()); + else carryOver = carryOver.subtract(a.getAmount()); + } + if (inPeriod) { + if ("입금".equals(type)) deposit = deposit.add(a.getAmount()); + else if ("출금".equals(type)) withdraw = withdraw.add(a.getAmount()); + } + } + BigDecimal cashTotal = carryOver.add(deposit).subtract(withdraw); + + ApiResponse res = pageOk(filtered, page, size, this::abookMap, counts); + @SuppressWarnings("unchecked") + Map body = (Map) res.data(); + if (body != null) { + body.put("categories", categories); + body.put("categoryCounts", categoryCounts); + body.put("employees", all.stream().map(HomesAbook::getEmployee).filter(Objects::nonNull) + .map(String::trim).filter(s -> !s.isBlank()).distinct().sorted().toList()); + body.put("items", all.stream().map(a -> { + String it = a.getItem(); + if (it == null || it.isBlank()) it = a.getAgency(); + return it; + }).filter(Objects::nonNull).map(String::trim).filter(s -> !s.isBlank()).distinct().sorted().toList()); + body.put("summary", Map.of( + "carryOver", carryOver, + "deposit", deposit, + "withdraw", withdraw, + "total", cashTotal + )); + } + return res; + } + + @GetMapping("/abooks/categories") + ApiResponse abookCategoriesGet(@AuthenticationPrincipal JwtUser u) { + return ApiResponse.ok(Map.of("categories", abookCategoryList(u))); + } + + @PutMapping("/abooks/categories") @Transactional + ApiResponse abookCategoriesPut(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + Object raw = body.get("categories"); + List categories = new ArrayList<>(); + if (raw instanceof List list) { + for (Object o : list) { + if (o == null) continue; + String s = String.valueOf(o).trim(); + if (!s.isBlank() && !categories.contains(s)) categories.add(s); + } + } + if (categories.isEmpty()) categories = new ArrayList<>(DEFAULT_ABOOK_CATEGORIES); + saveJsonSetting(u.getGroupId(), "homes.abookCategories", Map.of("categories", categories)); + return ApiResponse.ok(Map.of("categories", categories)); + } + + @PostMapping("/abooks") @Transactional ApiResponse abookPost(@RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + normalizeAbookPayload(d, u); + HomesAbook a = crud.create(HomesAbook.class, d, u); + writeLog(u, "ABOOKS", "간편장부-등록", abookLogContent(a, "등록하였습니다.")); + return ApiResponse.ok(abookMap(a)); + } + @PutMapping("/abooks/{id}") @Transactional ApiResponse abookPut(@PathVariable Long id, @RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + normalizeAbookPayload(d, u); + HomesAbook a = crud.update(HomesAbook.class, id, d, u); + writeLog(u, "ABOOKS", "간편장부-수정", abookLogContent(a, "수정하였습니다.")); + return ApiResponse.ok(abookMap(a)); + } + @DeleteMapping("/abooks/{id}") @Transactional ApiResponse abookDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesAbook a = crud.get(HomesAbook.class, id, u); + writeLog(u, "ABOOKS", "간편장부-삭제", abookLogContent(a, "삭제하였습니다.")); + crud.delete(HomesAbook.class, id, u); return ApiResponse.ok(null); + } + + @PostMapping("/abooks/delete") @Transactional + ApiResponse abooksBulkDelete(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + Object raw = body.get("ids"); + if (!(raw instanceof List list) || list.isEmpty()) return ApiResponse.fail("삭제할 장부를 선택하세요."); + int deleted = 0; + for (Object o : list) { + Long id = Long.valueOf(String.valueOf(o)); + HomesAbook a = em.find(HomesAbook.class, id); + if (a == null || !Objects.equals(a.getGroupId(), u.getGroupId())) continue; + writeLog(u, "ABOOKS", "간편장부-삭제", abookLogContent(a, "삭제하였습니다.")); + em.remove(a); + deleted++; + } + return ApiResponse.ok(Map.of("deleted", deleted)); + } + + @GetMapping(value = "/abooks/export", produces = "text/csv;charset=UTF-8") + org.springframework.http.ResponseEntity abooksExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + ApiResponse res = abooks(u, f, 0, 10000); + @SuppressWarnings("unchecked") + Map data = (Map) res.data(); + @SuppressWarnings("unchecked") + List> list = data == null ? List.of() : (List>) data.getOrDefault("list", List.of()); + StringBuilder csv = new StringBuilder("\uFEFF거래일,구분,과목,품목,금액,비고,직원\n"); + for (Map row : list) { + csv.append(csvCell(row.get("entryDate"))).append(',') + .append(csvCell(row.get("entryType"))).append(',') + .append(csvCell(row.get("category"))).append(',') + .append(csvCell(row.get("item"))).append(',') + .append(csvCell(row.get("amount"))).append(',') + .append(csvCell(row.get("memo"))).append(',') + .append(csvCell(row.get("employee"))).append('\n'); + } + return org.springframework.http.ResponseEntity.ok() + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=abooks.csv") + .body(csv.toString()); + } + + // ---- Agencies / Partners / Members ---- + @GetMapping("/agencies") ApiResponse agencies(@AuthenticationPrincipal JwtUser u, @RequestParam Map f, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) { + String q = qOf(f); + String status = blankToNull(f.get("status")); + if ("ALL".equalsIgnoreCase(status)) status = null; + String st = status; + List all = listAll(HomesAgency.class, u); + List filtered = all.stream().filter(a -> { + if ("사용".equals(st) && !a.isActive()) return false; + if ("정지".equals(st) && a.isActive()) return false; + if (q != null) { + String hay = (nullToEmpty(a.getName()) + " " + nullToEmpty(a.getManagerName()) + + " " + nullToEmpty(a.getPhone()) + " " + nullToEmpty(a.getMobile())).toLowerCase(); + if (!hay.contains(q.toLowerCase())) return false; + } + return true; + }).sorted(Comparator.comparing(HomesAgency::getName, Comparator.nullsLast(String::compareTo)) + .thenComparing(HomesAgency::getId, Comparator.reverseOrder())).toList(); + Map counts = new LinkedHashMap<>(); + counts.put("ALL", (long) all.size()); + counts.put("사용", all.stream().filter(HomesAgency::isActive).count()); + counts.put("정지", all.stream().filter(a -> !a.isActive()).count()); + return pageOk(filtered, page, size, this::agencyMap, counts); + } + @PostMapping("/agencies") @Transactional ApiResponse agencyPost(@RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + normalizeAgencyPayload(d); + HomesAgency a = crud.create(HomesAgency.class, d, u); + if (a.getRegisteredDate() == null) a.setRegisteredDate(LocalDate.now()); + writeLog(u, "AGENCY", "거래처-등록", "거래처 [" + nullToEmpty(a.getName()) + "] 등록하였습니다."); + return ApiResponse.ok(agencyMap(a)); + } + @PutMapping("/agencies/{id}") @Transactional ApiResponse agencyPut(@PathVariable Long id, @RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + normalizeAgencyPayload(d); + HomesAgency a = crud.update(HomesAgency.class, id, d, u); + writeLog(u, "AGENCY", "거래처-수정", "거래처 [" + nullToEmpty(a.getName()) + "] 수정하였습니다."); + return ApiResponse.ok(agencyMap(a)); + } + @DeleteMapping("/agencies/{id}") @Transactional ApiResponse agencyDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesAgency a = crud.get(HomesAgency.class, id, u); + writeLog(u, "AGENCY", "거래처-삭제", "거래처 [" + nullToEmpty(a.getName()) + "] 삭제하였습니다."); + crud.delete(HomesAgency.class, id, u); return ApiResponse.ok(null); + } + + @PostMapping("/agencies/status") @Transactional + ApiResponse agenciesStatus(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + Object raw = body.get("ids"); + if (!(raw instanceof List list) || list.isEmpty()) return ApiResponse.fail("거래처를 선택하세요."); + boolean active = !"정지".equals(String.valueOf(body.getOrDefault("status", "사용"))); + int updated = 0; + for (Object o : list) { + Long id = Long.valueOf(String.valueOf(o)); + HomesAgency a = em.find(HomesAgency.class, id); + if (a == null || !Objects.equals(a.getGroupId(), u.getGroupId())) continue; + a.setActive(active); + writeLog(u, "AGENCY", "거래처-상태", + "거래처 [" + nullToEmpty(a.getName()) + "] " + (active ? "사용" : "정지") + "(으)로 변경하였습니다."); + updated++; + } + return ApiResponse.ok(Map.of("updated", updated)); + } + + @GetMapping(value = "/agencies/export", produces = "text/csv;charset=UTF-8") + org.springframework.http.ResponseEntity agenciesExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + ApiResponse res = agencies(u, f, 0, 10000); + @SuppressWarnings("unchecked") + Map data = (Map) res.data(); + @SuppressWarnings("unchecked") + List> list = data == null ? List.of() : (List>) data.getOrDefault("list", List.of()); + StringBuilder csv = new StringBuilder("\uFEFF상태,업체명,전화,팩스,담당자,휴대폰,등록일,비고\n"); + for (Map row : list) { + csv.append(csvCell(row.get("status"))).append(',') + .append(csvCell(row.get("name"))).append(',') + .append(csvCell(row.get("phone"))).append(',') + .append(csvCell(row.get("fax"))).append(',') + .append(csvCell(row.get("managerName"))).append(',') + .append(csvCell(row.get("mobile"))).append(',') + .append(csvCell(row.get("registeredDate"))).append(',') + .append(csvCell(row.get("memo"))).append('\n'); + } + return org.springframework.http.ResponseEntity.ok() + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=agencies.csv") + .body(csv.toString()); + } + + @GetMapping("/partners") ApiResponse partners(@AuthenticationPrincipal JwtUser u, @RequestParam Map f, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) { + String level = blankToNull(f.get("partnerLevel")); + String status = blankToNull(f.get("status")); + if ("ALL".equalsIgnoreCase(status)) status = null; + String q = qOf(f); + String st = status; + List filtered = listAll(HomesPartner.class, u).stream().filter(p -> { + if (level != null && !level.equals(p.getPartnerLevel())) return false; + if (st != null && !st.equals(nullToEmpty(p.getStatus()).isBlank() ? "대기" : p.getStatus())) return false; + if (q != null) { + String hay = (nullToEmpty(p.getName()) + " " + nullToEmpty(p.getUserid()) + + " " + nullToEmpty(p.getManagerName()) + " " + nullToEmpty(p.getParentName())).toLowerCase(); + if (!hay.contains(q.toLowerCase())) return false; + } + return true; + }).sorted(Comparator.comparing(HomesPartner::getId, Comparator.reverseOrder())).toList(); + return pageOk(filtered, page, size, this::partnerMap, null); + } + @PostMapping("/partners") @Transactional ApiResponse partnerPost(@RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + normalizePartnerPayload(d); + HomesPartner p = crud.create(HomesPartner.class, d, u); + if (p.getRegisteredDate() == null) p.setRegisteredDate(LocalDate.now()); + if (p.getStatus() == null || p.getStatus().isBlank()) p.setStatus("대기"); + writeLog(u, "PARTNER", "상부점".equals(levelLabel(p)) ? "상부점-등록" : "하부점-등록", + levelLabel(p) + " [" + nullToEmpty(p.getName()) + "] 등록하였습니다."); + return ApiResponse.ok(partnerMap(p)); + } + @PutMapping("/partners/{id}") @Transactional ApiResponse partnerPut(@PathVariable Long id, @RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + normalizePartnerPayload(d); + HomesPartner p = crud.update(HomesPartner.class, id, d, u); + writeLog(u, "PARTNER", "상부점".equals(levelLabel(p)) ? "상부점-수정" : "하부점-수정", + levelLabel(p) + " [" + nullToEmpty(p.getName()) + "] 수정하였습니다."); + return ApiResponse.ok(partnerMap(p)); + } + @DeleteMapping("/partners/{id}") @Transactional ApiResponse partnerDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesPartner p = crud.get(HomesPartner.class, id, u); + writeLog(u, "PARTNER", "상부점".equals(levelLabel(p)) ? "상부점-삭제" : "하부점-삭제", + levelLabel(p) + " [" + nullToEmpty(p.getName()) + "] 삭제하였습니다."); + crud.delete(HomesPartner.class, id, u); + return ApiResponse.ok(null); + } + + private String levelLabel(HomesPartner p) { + return "2".equals(p.getPartnerLevel()) ? "하부점" : "상부점"; + } + + private void normalizePartnerPayload(Map d) { + if (d.get("partnerLevel") == null || String.valueOf(d.get("partnerLevel")).isBlank()) { + d.put("partnerLevel", "1"); + } + if (d.get("status") == null || String.valueOf(d.get("status")).isBlank()) { + d.put("status", "대기"); + } + if (d.get("registeredDate") == null || String.valueOf(d.get("registeredDate")).isBlank()) { + d.put("registeredDate", LocalDate.now().toString()); + } + } + + @GetMapping("/members") ApiResponse members(@AuthenticationPrincipal JwtUser u, @RequestParam Map f, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) { + String q = qOf(f); + if (q == null) q = blankToNull(f.get("keyword")); + String status = blankToNull(f.get("status")); + if ("ALL".equalsIgnoreCase(status)) status = null; + String st = status; + String qLower = q == null ? null : q.toLowerCase(); + List all = em.createQuery("select m from Member m where m.groupId=:g and m.role='AGENT'", Member.class) + .setParameter("g", u.getGroupId()).getResultList(); + List filtered = all.stream().filter(m -> { + if ("사용".equals(st) && !m.isActive()) return false; + if (("중지".equals(st) || "정지".equals(st)) && m.isActive()) return false; + if (qLower != null) { + String hay = (nullToEmpty(m.getUserid()) + " " + nullToEmpty(m.getName())).toLowerCase(); + if (!hay.contains(qLower)) return false; + } + return true; + }).sorted(memberComparator(f.get("sort"))).toList(); + Map counts = new LinkedHashMap<>(); + counts.put("ALL", (long) all.size()); + counts.put("사용", all.stream().filter(Member::isActive).count()); + counts.put("중지", all.stream().filter(m -> !m.isActive()).count()); + return pageOk(filtered, page, size, this::memberMap, counts); + } + @PostMapping("/members") @Transactional ApiResponse memberPost(@RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + String userid = d.get("userid") == null ? "" : String.valueOf(d.get("userid")).trim(); + if (userid.isBlank()) return ApiResponse.fail("아이디를 입력하세요."); + if (!em.createQuery("select m from Member m where m.userid=:id", Member.class).setParameter("id", userid).getResultList().isEmpty()) { + return ApiResponse.fail("이미 사용 중인 아이디입니다."); + } + Member m = new Member(); + m.setGroupId(u.getGroupId()); + m.setUserid(userid); + m.setName(String.valueOf(d.getOrDefault("name", "")).trim()); + m.setPhone(blankToNull(d.get("phone") == null ? null : String.valueOf(d.get("phone")))); + m.setAddress(blankToNull(d.get("address") == null ? null : String.valueOf(d.get("address")))); + m.setAccessLimit(blankToNull(d.get("accessLimit") == null ? null : String.valueOf(d.get("accessLimit")))); + m.setMemo(blankToNull(d.get("memo") == null ? null : String.valueOf(d.get("memo")))); + m.setStaffPermission(String.valueOf(d.getOrDefault("staffPermission", "사원"))); + m.setRole("AGENT"); + m.setActive(!"중지".equals(String.valueOf(d.getOrDefault("status", "사용"))) && !"정지".equals(String.valueOf(d.getOrDefault("status", "")))); + m.setLoginCount(0); + m.setMenuFlags(d.get("menuFlags") == null ? null : String.valueOf(d.get("menuFlags"))); + String pw = d.get("password") == null || String.valueOf(d.get("password")).isBlank() ? "demo123" : String.valueOf(d.get("password")); + m.setPassword(encoder.encode(pw)); + m.setPlainPassword(pw); + em.persist(m); + writeLog(u, "MEMBER", "직원-등록", "직원 [" + nullToEmpty(m.getName()) + "] 등록하였습니다."); + return ApiResponse.ok(memberMap(m)); + } + @PutMapping("/members/{id}") @Transactional ApiResponse memberPut(@PathVariable Long id, @RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + Member m = crud.get(Member.class, id, u); + if (d.get("name") != null) m.setName(String.valueOf(d.get("name")).trim()); + if (d.get("phone") != null) m.setPhone(blankToNull(String.valueOf(d.get("phone")))); + if (d.get("address") != null) m.setAddress(blankToNull(String.valueOf(d.get("address")))); + if (d.get("accessLimit") != null) m.setAccessLimit(blankToNull(String.valueOf(d.get("accessLimit")))); + if (d.get("memo") != null) m.setMemo(blankToNull(String.valueOf(d.get("memo")))); + if (d.get("staffPermission") != null && !String.valueOf(d.get("staffPermission")).isBlank()) { + m.setStaffPermission(String.valueOf(d.get("staffPermission"))); + } + if (d.get("status") != null) { + String st = String.valueOf(d.get("status")); + m.setActive(!"중지".equals(st) && !"정지".equals(st) && !"false".equalsIgnoreCase(st)); + } + if (d.containsKey("menuFlags")) { + m.setMenuFlags(d.get("menuFlags") == null || String.valueOf(d.get("menuFlags")).isBlank() + ? null : String.valueOf(d.get("menuFlags"))); + } + if (d.get("password") != null && !String.valueOf(d.get("password")).isBlank()) { + String pw = String.valueOf(d.get("password")); + m.setPassword(encoder.encode(pw)); + m.setPlainPassword(pw); + } + writeLog(u, "MEMBER", "직원-수정", "직원 [" + nullToEmpty(m.getName()) + "] 수정하였습니다."); + return ApiResponse.ok(memberMap(m)); + } + @DeleteMapping("/members/{id}") @Transactional ApiResponse memberDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + Member m = crud.get(Member.class, id, u); + if ("demo".equals(m.getUserid()) || "대표".equals(m.getStaffPermission())) { + return ApiResponse.fail("대표 계정은 삭제할 수 없습니다."); + } + writeLog(u, "MEMBER", "직원-삭제", "직원 [" + nullToEmpty(m.getName()) + "] 삭제하였습니다."); + em.remove(m); + return ApiResponse.ok(null); + } + + @PostMapping("/members/status") @Transactional + ApiResponse membersStatus(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + Object raw = body.get("ids"); + if (!(raw instanceof List list) || list.isEmpty()) return ApiResponse.fail("직원을 선택하세요."); + String status = String.valueOf(body.getOrDefault("status", "사용")); + boolean active = !"중지".equals(status) && !"정지".equals(status); + int updated = 0; + for (Object o : list) { + Long id = Long.valueOf(String.valueOf(o)); + Member m = em.find(Member.class, id); + if (m == null || !Objects.equals(m.getGroupId(), u.getGroupId()) || !"AGENT".equals(m.getRole())) continue; + if ("demo".equals(m.getUserid()) || "대표".equals(m.getStaffPermission())) continue; + m.setActive(active); + writeLog(u, "MEMBER", "직원-상태", + "직원 [" + nullToEmpty(m.getName()) + "] " + (active ? "사용" : "중지") + "(으)로 변경하였습니다."); + updated++; + } + return ApiResponse.ok(Map.of("updated", updated)); + } + + @GetMapping(value = "/members/export", produces = "text/csv;charset=UTF-8") + org.springframework.http.ResponseEntity membersExport(@AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + ApiResponse res = members(u, f, 0, 10000); + @SuppressWarnings("unchecked") + Map data = (Map) res.data(); + @SuppressWarnings("unchecked") + List> list = data == null ? List.of() : (List>) data.getOrDefault("list", List.of()); + StringBuilder csv = new StringBuilder("\uFEFF상태,권한,아이디,이름,휴대폰,주소,접속제한,접속수,최종접속,등록일,비고\n"); + for (Map row : list) { + csv.append(csvCell(row.get("status"))).append(',') + .append(csvCell(row.get("staffPermission"))).append(',') + .append(csvCell(row.get("userid"))).append(',') + .append(csvCell(row.get("name"))).append(',') + .append(csvCell(row.get("phone"))).append(',') + .append(csvCell(row.get("address"))).append(',') + .append(csvCell(row.get("accessLimit"))).append(',') + .append(csvCell(row.get("loginCount"))).append(',') + .append(csvCell(row.get("lastLoginAt"))).append(',') + .append(csvCell(row.get("registeredDate"))).append(',') + .append(csvCell(row.get("memo"))).append('\n'); + } + return org.springframework.http.ResponseEntity.ok() + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=members.csv") + .body(csv.toString()); + } + + // ---- Preference / Levels ---- + @GetMapping("/preference") ApiResponse preferenceGet(@AuthenticationPrincipal JwtUser u) { + return ApiResponse.ok(loadHomesPreference(u.getGroupId())); + } + @PutMapping("/preference") @Transactional ApiResponse preferencePut(@RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + Map current = loadHomesPreference(u.getGroupId()); + mergePreference(current, d); + saveJsonSetting(u.getGroupId(), "homes_preference", current); + writeLog(u, "SETTING", "기본설정-저장", "기본설정을 저장하였습니다."); + return ApiResponse.ok(current); + } + /** 상단 계정 팝오버용 프로필 요약 */ + @GetMapping("/account-profile") + ApiResponse accountProfile(@AuthenticationPrincipal JwtUser u) { + List users = em.createQuery("select m from Member m where m.userid=:id", Member.class) + .setParameter("id", u.getUserid()).getResultList(); + Member member = users.isEmpty() ? null : users.getFirst(); + String name = member == null || member.getName() == null || member.getName().isBlank() + ? u.getUserid() : member.getName(); + String perm = member == null ? null : member.getStaffPermission(); + if (perm == null || perm.isBlank()) { + perm = "demo".equals(u.getUserid()) ? "대표" : "사원"; + } + long memberCount = em.createQuery( + "select count(m) from Member m where m.groupId=:g and m.role='AGENT' and m.active=true", Long.class) + .setParameter("g", u.getGroupId()).getSingleResult(); + + Map sub = loadJsonSetting(u.getGroupId(), "homes_subscription", defaultSubscription()); + LocalDate expire = LocalDate.parse(String.valueOf(sub.getOrDefault("expireDate", "2030-12-31"))); + long remainDays = java.time.temporal.ChronoUnit.DAYS.between(LocalDate.now(ZoneId.of("Asia/Seoul")), expire); + + String orgLabel = String.valueOf(sub.getOrDefault("orgLabel", "")); + if (orgLabel.isBlank()) { + orgLabel = "demo".equals(u.getGroupId()) ? "데모" : u.getGroupId(); + } + + Map data = new LinkedHashMap<>(); + data.put("name", name); + data.put("userid", u.getUserid()); + data.put("staffPermission", perm); + data.put("orgLabel", orgLabel); + data.put("memberCount", memberCount); + data.put("expireDate", expire.toString()); + data.put("remainDays", remainDays); + return ApiResponse.ok(data); + } + + @PutMapping("/password") + @Transactional + ApiResponse changePassword(@AuthenticationPrincipal JwtUser u, @RequestBody Map body) { + String current = body.get("currentPassword") == null ? "" : String.valueOf(body.get("currentPassword")); + String next = body.get("newPassword") == null ? "" : String.valueOf(body.get("newPassword")).trim(); + if (current.isBlank()) return ApiResponse.fail("현재 비밀번호를 입력하세요."); + if (!next.matches("^[A-Za-z0-9]{4,20}$")) return ApiResponse.fail("새 비밀번호는 영문, 숫자 4자~20자입니다."); + List users = em.createQuery("select m from Member m where m.userid=:id", Member.class) + .setParameter("id", u.getUserid()).getResultList(); + if (users.isEmpty()) return ApiResponse.fail("사용자를 찾을 수 없습니다."); + Member m = users.getFirst(); + if (!encoder.matches(current, m.getPassword())) return ApiResponse.fail("현재 비밀번호가 올바르지 않습니다."); + m.setPassword(encoder.encode(next)); + m.setPlainPassword(next); + writeLog(u, "SETTING", "비밀번호변경", "비밀번호를 변경하였습니다."); + return ApiResponse.ok(Map.of("ok", true)); + } + + private Map defaultSubscription() { + Map m = new LinkedHashMap<>(); + m.put("orgLabel", "데모"); + m.put("expireDate", "2030-12-31"); + return m; + } + + @GetMapping("/levels") ApiResponse levelsGet(@AuthenticationPrincipal JwtUser u) { + return ApiResponse.ok(loadHomesLevels(u.getGroupId())); + } + + /** 현재 로그인 사용자에게 허용된 좌측 메뉴 섹션 id 목록 */ + @GetMapping("/my-menus") + ApiResponse myMenus(@AuthenticationPrincipal JwtUser u) { + List users = em.createQuery("select m from Member m where m.userid=:id", Member.class) + .setParameter("id", u.getUserid()).getResultList(); + Member member = users.isEmpty() ? null : users.getFirst(); + String perm = member == null ? null : member.getStaffPermission(); + if (perm == null || perm.isBlank()) { + perm = "demo".equals(u.getUserid()) ? "대표" : "사원"; + } + String menuFlagsRaw = member == null ? null : member.getMenuFlags(); + Map data = new LinkedHashMap<>(); + data.put("staffPermission", perm); + data.put("menus", resolveAllowedMenus(u.getGroupId(), u.getUserid(), perm, menuFlagsRaw)); + return ApiResponse.ok(data); + } + + @PutMapping("/levels") @Transactional ApiResponse levelsPut(@RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + if (u == null) return ApiResponse.fail("로그인이 필요합니다."); + try { + Map current = loadHomesLevels(u.getGroupId()); + if (d.get("modes") instanceof Map modes) { + @SuppressWarnings("unchecked") + Map dest = new LinkedHashMap<>((Map) current.get("modes")); + dest.putAll((Map) modes); + current.put("modes", dest); + } + if (d.get("custom") instanceof Map custom) { + @SuppressWarnings("unchecked") + Map dest = new LinkedHashMap<>((Map) current.get("custom")); + for (Map.Entry e : custom.entrySet()) { + dest.put(String.valueOf(e.getKey()), e.getValue()); + } + current.put("custom", dest); + } + // persist without embedding defaults (recomputed on get) + Map toSave = new LinkedHashMap<>(); + toSave.put("modes", current.get("modes")); + toSave.put("custom", current.get("custom")); + saveJsonSetting(u.getGroupId(), "homes_levels", toSave); + writeLog(u, "SETTING", "권한설정-저장", "권한설정을 저장하였습니다."); + return ApiResponse.ok(loadHomesLevels(u.getGroupId())); + } catch (Exception e) { + return ApiResponse.fail("권한설정 저장에 실패했습니다: " + e.getMessage()); + } + } + + // ---- Notices / Questions / BBS / Logs ---- + @GetMapping("/notices") ApiResponse notices(@AuthenticationPrincipal JwtUser u, @RequestParam Map f, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) { + String searchType = blankToNull(f.get("searchType")); + String q = qOf(f); + List filtered = listAll(HomesNotice.class, u).stream().filter(n -> { + if (q == null) return true; + String hay = switch (searchType == null ? "제목" : searchType) { + case "내용" -> nullToEmpty(n.getContents()); + case "작성자" -> nullToEmpty(n.getAuthorName()); + default -> nullToEmpty(n.getTitle()); + }; + return hay.toLowerCase().contains(q.toLowerCase()); + }).sorted(noticeComparator(f.get("sort"))).toList(); + int from = Math.max(0, page) * Math.max(1, size); + List> list = new ArrayList<>(); + int i = 0; + for (HomesNotice n : filtered.stream().skip(from).limit(Math.min(Math.max(size, 1), 200)).toList()) { + Map row = noticeMap(n); + // 번호: 최신순 목록에서의 순번 (전체 기준) + row.put("no", filtered.size() - from - i); + list.add(row); + i++; + } + Map data = new LinkedHashMap<>(); + data.put("total", filtered.size()); + data.put("list", list); + return ApiResponse.ok(data); + } + @GetMapping("/notices/{id}") @Transactional ApiResponse noticeGet(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesNotice n = crud.get(HomesNotice.class, id, u); + n.setViews((n.getViews() == null ? 0 : n.getViews()) + 1); + return ApiResponse.ok(noticeMap(n)); + } + @PostMapping("/notices") @Transactional ApiResponse noticePost(@RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + d.putIfAbsent("authorName", u.getUsername()); + d.putIfAbsent("views", 0); + return ApiResponse.ok(noticeMap(crud.create(HomesNotice.class, d, u))); + } + @PutMapping("/notices/{id}") @Transactional ApiResponse noticePut(@PathVariable Long id, @RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + return ApiResponse.ok(noticeMap(crud.update(HomesNotice.class, id, d, u))); + } + @DeleteMapping("/notices/{id}") @Transactional ApiResponse noticeDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + crud.delete(HomesNotice.class, id, u); return ApiResponse.ok(null); + } + + private Comparator noticeComparator(String sort) { + String field = sort == null || sort.isBlank() ? "id,desc" : sort; + boolean asc = field.endsWith(",asc"); + String key = field.split(",")[0]; + Comparator c = switch (key) { + case "views", "조회" -> Comparator.comparing(n -> n.getViews() == null ? 0 : n.getViews()); + case "createdDate", "createdAt", "등록일" -> Comparator.comparing(HomesNotice::getCreatedAt, Comparator.nullsLast(Instant::compareTo)); + case "no", "id", "번호" -> Comparator.comparing(HomesNotice::getId, Comparator.nullsLast(Long::compareTo)); + default -> Comparator.comparing(HomesNotice::getId, Comparator.nullsLast(Long::compareTo)); + }; + return (asc ? c : c.reversed()).thenComparing(HomesNotice::getId, Comparator.reverseOrder()); + } + + @GetMapping("/questions") ApiResponse questions(@AuthenticationPrincipal JwtUser u, @RequestParam Map f, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) { + String status = blankToNull(f.get("status")); + if ("ALL".equalsIgnoreCase(status)) status = null; + String searchType = blankToNull(f.get("searchType")); + String q = qOf(f); + String st = status; + List all = listAll(HomesQuestion.class, u); + List filtered = all.stream().filter(x -> { + if (st != null && !st.equals(x.getStatus())) return false; + if (q != null) { + String hay = switch (searchType == null ? "제목" : searchType) { + case "작성자" -> nullToEmpty(x.getAuthorName()); + case "내용" -> nullToEmpty(x.getContent()); + case "답변" -> nullToEmpty(x.getReply()) + " " + nullToEmpty(x.getStatus()); + default -> nullToEmpty(x.getTitle()); + }; + if (!hay.toLowerCase().contains(q.toLowerCase())) return false; + } + return true; + }).sorted(questionComparator(f.get("sort"))).toList(); + int from = Math.max(0, page) * Math.max(1, size); + List> list = new ArrayList<>(); + int i = 0; + for (HomesQuestion x : filtered.stream().skip(from).limit(Math.min(Math.max(size, 1), 200)).toList()) { + Map row = questionMap(x); + row.put("no", filtered.size() - from - i); + list.add(row); + i++; + } + Map data = new LinkedHashMap<>(); + data.put("total", filtered.size()); + data.put("list", list); + Map counts = new LinkedHashMap<>(); + counts.put("ALL", (long) all.size()); + counts.put("접수", all.stream().filter(x -> "접수".equals(x.getStatus())).count()); + counts.put("답변완료", all.stream().filter(x -> "답변완료".equals(x.getStatus())).count()); + data.put("counts", counts); + return ApiResponse.ok(data); + } + @GetMapping("/questions/{id}") ApiResponse questionGet(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + return ApiResponse.ok(questionMap(crud.get(HomesQuestion.class, id, u))); + } + @PostMapping("/questions") @Transactional ApiResponse questionPost(@RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + d.putIfAbsent("status", "접수"); + if (d.get("authorName") == null || String.valueOf(d.get("authorName")).isBlank()) { + d.put("authorName", u.getUsername()); + } + HomesQuestion q = crud.create(HomesQuestion.class, d, u); + writeLog(u, "CS", "사용문의-등록", "사용문의 [" + nullToEmpty(q.getTitle()) + "] 등록하였습니다."); + return ApiResponse.ok(questionMap(q)); + } + @PutMapping("/questions/{id}") @Transactional ApiResponse questionPut(@PathVariable Long id, @RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + if (d.get("reply") != null && !String.valueOf(d.get("reply")).isBlank()) { + d.putIfAbsent("status", "답변완료"); + } + HomesQuestion q = crud.update(HomesQuestion.class, id, d, u); + writeLog(u, "CS", "사용문의-수정", "사용문의 [" + nullToEmpty(q.getTitle()) + "] 수정하였습니다."); + return ApiResponse.ok(questionMap(q)); + } + @DeleteMapping("/questions/{id}") @Transactional ApiResponse questionDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesQuestion q = crud.get(HomesQuestion.class, id, u); + writeLog(u, "CS", "사용문의-삭제", "사용문의 [" + nullToEmpty(q.getTitle()) + "] 삭제하였습니다."); + crud.delete(HomesQuestion.class, id, u); + return ApiResponse.ok(null); + } + + private Comparator questionComparator(String sort) { + String field = sort == null || sort.isBlank() ? "id,desc" : sort; + boolean asc = field.endsWith(",asc"); + String key = field.split(",")[0]; + Comparator c = switch (key) { + case "answer", "답변", "status" -> Comparator.comparing(HomesQuestion::getStatus, Comparator.nullsLast(String::compareTo)); + case "authorName", "작성자" -> Comparator.comparing(HomesQuestion::getAuthorName, Comparator.nullsLast(String::compareTo)); + case "createdDate", "createdAt", "등록일" -> Comparator.comparing(HomesQuestion::getCreatedAt, Comparator.nullsLast(Instant::compareTo)); + case "title", "제목" -> Comparator.comparing(HomesQuestion::getTitle, Comparator.nullsLast(String::compareTo)); + default -> Comparator.comparing(HomesQuestion::getId, Comparator.nullsLast(Long::compareTo)); + }; + return (asc ? c : c.reversed()).thenComparing(HomesQuestion::getId, Comparator.reverseOrder()); + } + + @GetMapping("/bbs") ApiResponse bbs(@AuthenticationPrincipal JwtUser u, @RequestParam Map f, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "15") int size) { + String searchType = blankToNull(f.get("searchType")); + String q = qOf(f); + List filtered = listAll(HomesBbs.class, u).stream().filter(b -> { + if (q == null) return true; + String hay = switch (searchType == null ? "제목" : searchType) { + case "직원", "작성자" -> nullToEmpty(b.getAuthorName()); + case "내용" -> nullToEmpty(b.getContents()); + default -> nullToEmpty(b.getTitle()); + }; + return hay.toLowerCase().contains(q.toLowerCase()); + }).sorted(bbsComparator(f.get("sort"))).toList(); + int from = Math.max(0, page) * Math.max(1, size); + List> list = new ArrayList<>(); + int i = 0; + for (HomesBbs b : filtered.stream().skip(from).limit(Math.min(Math.max(size, 1), 200)).toList()) { + Map row = bbsMap(b); + row.put("no", filtered.size() - from - i); + list.add(row); + i++; + } + Map data = new LinkedHashMap<>(); + data.put("total", filtered.size()); + data.put("list", list); + return ApiResponse.ok(data); + } + @GetMapping("/bbs/{id}") @Transactional ApiResponse bbsGet(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesBbs b = crud.get(HomesBbs.class, id, u); + b.setViews((b.getViews() == null ? 0 : b.getViews()) + 1); + return ApiResponse.ok(bbsMap(b)); + } + @PostMapping("/bbs") @Transactional ApiResponse bbsPost(@RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + d.putIfAbsent("authorName", u.getUsername()); + d.putIfAbsent("views", 0); + HomesBbs b = crud.create(HomesBbs.class, d, u); + writeLog(u, "BBS", "사내게시판-등록", "게시물 [" + nullToEmpty(b.getTitle()) + "] 등록하였습니다."); + return ApiResponse.ok(bbsMap(b)); + } + @PutMapping("/bbs/{id}") @Transactional ApiResponse bbsPut(@PathVariable Long id, @RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + HomesBbs b = crud.update(HomesBbs.class, id, d, u); + writeLog(u, "BBS", "사내게시판-수정", "게시물 [" + nullToEmpty(b.getTitle()) + "] 수정하였습니다."); + return ApiResponse.ok(bbsMap(b)); + } + @DeleteMapping("/bbs/{id}") @Transactional ApiResponse bbsDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesBbs b = crud.get(HomesBbs.class, id, u); + writeLog(u, "BBS", "사내게시판-삭제", "게시물 [" + nullToEmpty(b.getTitle()) + "] 삭제하였습니다."); + crud.delete(HomesBbs.class, id, u); + return ApiResponse.ok(null); + } + + private Comparator bbsComparator(String sort) { + String field = sort == null || sort.isBlank() ? "id,desc" : sort; + boolean asc = field.endsWith(",asc"); + String key = field.split(",")[0]; + Comparator c = switch (key) { + case "views", "조회" -> Comparator.comparing(b -> b.getViews() == null ? 0 : b.getViews()); + case "authorName", "직원", "작성자" -> Comparator.comparing(HomesBbs::getAuthorName, Comparator.nullsLast(String::compareTo)); + case "createdDate", "createdAt", "등록일" -> Comparator.comparing(HomesBbs::getCreatedAt, Comparator.nullsLast(Instant::compareTo)); + case "title", "제목" -> Comparator.comparing(HomesBbs::getTitle, Comparator.nullsLast(String::compareTo)); + default -> Comparator.comparing(HomesBbs::getId, Comparator.nullsLast(Long::compareTo)); + }; + return (asc ? c : c.reversed()).thenComparing(HomesBbs::getId, Comparator.reverseOrder()); + } + + @GetMapping("/logs") ApiResponse logs(@AuthenticationPrincipal JwtUser u, @RequestParam Map f, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) { + String domain = blankToNull(f.get("domain")); + String action = blankToNull(f.get("action")); + String staff = blankToNull(f.get("staff")); + LocalDate from = parseDate(f.get("dateFrom")); + LocalDate to = parseDate(f.get("dateTo")); + String q = qOf(f); + List base = listAll(HomesLog.class, u).stream() + .filter(l -> domain == null || domain.equals(l.getDomain())) + .toList(); + List filtered = base.stream().filter(l -> { + if (action != null && !action.equals(l.getAction())) return false; + if (staff != null && !staff.equals(l.getStaff())) return false; + if (from != null && (l.getProcessedAt() == null || l.getProcessedAt().toLocalDate().isBefore(from))) return false; + if (to != null && (l.getProcessedAt() == null || l.getProcessedAt().toLocalDate().isAfter(to))) return false; + if (q != null && !nullToEmpty(l.getContent()).toLowerCase().contains(q.toLowerCase())) return false; + return true; + }).toList(); + Map pageData = new LinkedHashMap<>(); + int fromIdx = Math.max(0, page) * Math.max(1, size); + pageData.put("total", filtered.size()); + pageData.put("list", filtered.stream().skip(fromIdx).limit(Math.min(Math.max(size, 1), 200)).map(this::logMap).toList()); + pageData.put("actions", base.stream().map(HomesLog::getAction).filter(Objects::nonNull).distinct().sorted().toList()); + pageData.put("staffs", base.stream().map(HomesLog::getStaff).filter(Objects::nonNull).distinct().sorted().toList()); + return ApiResponse.ok(pageData); + } + @PostMapping("/logs") @Transactional ApiResponse logPost(@RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + if (d.get("processedAt") == null) d.put("processedAt", LocalDateTime.now().toString()); + if (d.get("staff") == null) d.put("staff", u.getUsername()); + return ApiResponse.ok(logMap(crud.create(HomesLog.class, d, u))); + } + @PutMapping("/logs/{id}") @Transactional ApiResponse logPut(@PathVariable Long id, @RequestBody Map d, @AuthenticationPrincipal JwtUser u) { + return ApiResponse.ok(logMap(crud.update(HomesLog.class, id, d, u))); + } + @DeleteMapping("/logs/{id}") @Transactional ApiResponse logDel(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + crud.delete(HomesLog.class, id, u); return ApiResponse.ok(null); + } + @PostMapping("/logs/delete") @Transactional + ApiResponse logsBulkDelete(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + Object raw = body.get("ids"); + if (!(raw instanceof List list) || list.isEmpty()) return ApiResponse.fail("삭제할 이력을 선택하세요."); + int deleted = 0; + for (Object o : list) { + Long id = Long.valueOf(String.valueOf(o)); + HomesLog log = em.find(HomesLog.class, id); + if (log == null || !Objects.equals(log.getGroupId(), u.getGroupId())) continue; + em.remove(log); + deleted++; + } + return ApiResponse.ok(Map.of("deleted", deleted)); + } + + // ---- Reports ---- + @GetMapping("/reports/{type}") + ApiResponse report(@PathVariable String type, @AuthenticationPrincipal JwtUser u, @RequestParam Map f) { + int year = parseInt(f.get("year"), LocalDate.now().getYear()); + Integer month = f.get("month") == null || f.get("month").isBlank() ? null : parseInt(f.get("month"), LocalDate.now().getMonthValue()); + List contracts = listAll(HomesContract.class, u).stream().filter(c -> { + if (c.getContractDate() == null || c.getContractDate().getYear() != year) return false; + return month == null || c.getContractDate().getMonthValue() == month; + }).toList(); + List> rows = new ArrayList<>(); + String title = year + "년" + (month == null ? "" : " " + month + "월") + " "; + switch (type) { + case "daily" -> { + LocalDate date = parseDate(f.get("date")); + if (date == null) date = LocalDate.now(); + return ApiResponse.ok(buildDailyReport(u, date)); + } + case "monthly" -> { + return ApiResponse.ok(buildMonthlyReport(u, year)); + } + case "internet" -> { + String mode = blankToNull(f.get("mode")); + if (mode == null) mode = "daily"; + int m = month == null ? LocalDate.now().getMonthValue() : month; + return ApiResponse.ok(buildKindPeriodReport(u, "INTERNET", "인터넷통계", mode, year, m)); + } + case "home" -> { + String mode = blankToNull(f.get("mode")); + if (mode == null) mode = "daily"; + int m = month == null ? LocalDate.now().getMonthValue() : month; + return ApiResponse.ok(buildKindPeriodReport(u, "HOME", "홈렌탈통계", mode, year, m)); + } + case "member" -> { + int m = month == null ? LocalDate.now().getMonthValue() : month; + return ApiResponse.ok(buildGroupedPeriodReport(u, "member", "직원별통계", year, m)); + } + case "agency" -> { + int m = month == null ? LocalDate.now().getMonthValue() : month; + return ApiResponse.ok(buildGroupedPeriodReport(u, "agency", "거래처별통계", year, m)); + } + case "incentive" -> { + title += "인센티브계산"; + Map pref = loadJsonSetting(u.getGroupId(), "homes_preference", defaultPreference()); + BigDecimal rate = bd(pref.getOrDefault("incentiveRate", 5)); + int key = 1; + for (Map base : groupByLabel(contracts, c -> nullToEmpty(c.getEmployee()).isBlank() ? "미지정" : c.getEmployee())) { + BigDecimal settle = bd(base.get("settleAmount")); + Map row = new LinkedHashMap<>(base); + row.put("key", key++); + row.put("rate", rate); + row.put("incentive", settle.multiply(rate).divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP)); + rows.add(row); + } + } + default -> title += "통계"; + } + Map data = new LinkedHashMap<>(); + data.put("title", title); + data.put("year", year); + data.put("month", month); + data.put("rows", rows); + return ApiResponse.ok(data); + } + + // ---- helpers ---- + private Map buildDailyReport(JwtUser u, LocalDate date) { + List dayContracts = listAll(HomesContract.class, u).stream() + .filter(c -> date.equals(c.getContractDate())).toList(); + List internet = dayContracts.stream().filter(c -> "INTERNET".equals(c.getKind())).toList(); + List home = dayContracts.stream().filter(c -> "HOME".equals(c.getKind())).toList(); + List pendings = listAll(HomesPending.class, u).stream() + .filter(p -> date.equals(p.getPendingDate())).toList(); + List bookings = listAll(HomesBooking.class, u).stream() + .filter(b -> date.equals(b.getBookingDate())).toList(); + + Map summary = new LinkedHashMap<>(); + summary.put("internetCount", internet.size()); + summary.put("homeCount", home.size()); + summary.put("pendingCount", pendings.size()); + summary.put("bookingCount", bookings.size()); + + Map data = new LinkedHashMap<>(); + data.put("date", date.toString()); + data.put("title", date.getYear() + "년 " + date.getMonthValue() + "월 " + date.getDayOfMonth() + "일 일일리포트"); + data.put("summary", summary); + data.put("internet", internet.stream().map(this::dailyContractItem).toList()); + data.put("home", home.stream().map(this::dailyContractItem).toList()); + data.put("pendings", pendings.stream().map(p -> { + Map m = new LinkedHashMap<>(); + m.put("id", p.getId()); + m.put("status", normalizePendingStatus(p.getStatus())); + m.put("pendingType", p.getPendingType()); + m.put("contents", p.getContents()); + m.put("customerName", p.getCustomerName()); + m.put("phone", p.getPhone()); + m.put("employee", resolveReceiveEmployee(p)); + return m; + }).toList()); + data.put("bookings", bookings.stream().map(b -> { + Map m = new LinkedHashMap<>(); + m.put("id", b.getId()); + m.put("status", b.getStatus()); + m.put("customerName", b.getCustomerName()); + m.put("phone", b.getPhone()); + m.put("internetProducts", b.getInternetProducts()); + m.put("rentalProducts", b.getRentalProducts()); + m.put("products", b.getProducts()); + m.put("employee", b.getEmployee()); + return m; + }).toList()); + return data; + } + + private Map dailyContractItem(HomesContract c) { + Map m = new LinkedHashMap<>(); + m.put("id", c.getId()); + m.put("status", c.getStatus()); + m.put("customerName", c.getCustomerName()); + m.put("phone", c.getPhone()); + m.put("products", c.getProducts()); + m.put("employee", c.getEmployee()); + m.put("agency", c.getAgency()); + m.put("settleAmount", c.getSettleAmount()); + m.put("margin", c.getMargin()); + return m; + } + + private Map buildMonthlyReport(JwtUser u, int year) { + List yearContracts = listAll(HomesContract.class, u).stream() + .filter(c -> c.getContractDate() != null && c.getContractDate().getYear() == year) + .toList(); + Map> byMon = yearContracts.stream() + .collect(Collectors.groupingBy(c -> c.getContractDate().getMonthValue(), TreeMap::new, Collectors.toList())); + Map pref = loadJsonSetting(u.getGroupId(), "homes_preference", defaultPreference()); + BigDecimal rebateRate = bd(pref.getOrDefault("incentiveRate", 5)); + + List> rows = new ArrayList<>(); + for (int m = 1; m <= 12; m++) { + List list = byMon.getOrDefault(m, List.of()); + Map row = reportRow(m, m + "월", list); + BigDecimal discount = list.stream().map(this::contractDiscount).reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal settle = bd(row.get("settleAmount")); + BigDecimal rebate = settle.multiply(rebateRate).divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP); + row.put("month", m); + row.put("discountSum", discount); + row.put("employeeRebate", rebate); + row.put("rebateRate", rebateRate); + rows.add(row); + } + + Map data = new LinkedHashMap<>(); + data.put("year", year); + data.put("title", year + "년 월간리포트"); + data.put("rows", rows); + return data; + } + + /** 인터넷/홈렌탈 일별·월별 통계 (철회 제외) */ + private Map buildKindPeriodReport(JwtUser u, String kind, String titleLabel, String mode, int year, int month) { + boolean daily = !"monthly".equalsIgnoreCase(mode); + List base = listAll(HomesContract.class, u).stream() + .filter(c -> kind.equals(c.getKind())) + .filter(c -> !"철회".equals(c.getStatus())) + .filter(c -> c.getContractDate() != null && c.getContractDate().getYear() == year) + .filter(c -> !daily || c.getContractDate().getMonthValue() == month) + .toList(); + + List> rows = new ArrayList<>(); + if (daily) { + YearMonth ym = YearMonth.of(year, month); + Map> byDay = base.stream() + .collect(Collectors.groupingBy(c -> c.getContractDate().getDayOfMonth(), TreeMap::new, Collectors.toList())); + for (int d = 1; d <= ym.lengthOfMonth(); d++) { + List list = byDay.getOrDefault(d, List.of()); + if (list.isEmpty()) continue; + Map row = reportRow(d, d + "일", list); + row.put("day", d); + rows.add(row); + } + } else { + Map> byMon = base.stream() + .collect(Collectors.groupingBy(c -> c.getContractDate().getMonthValue(), TreeMap::new, Collectors.toList())); + for (int m = 1; m <= 12; m++) { + List list = byMon.getOrDefault(m, List.of()); + if (list.isEmpty()) continue; + Map row = reportRow(m, m + "월", list); + row.put("month", m); + rows.add(row); + } + } + + Map totals = reportRow(0, "합계", base); + Map data = new LinkedHashMap<>(); + data.put("kind", kind); + data.put("mode", daily ? "daily" : "monthly"); + data.put("year", year); + data.put("month", daily ? month : null); + data.put("title", titleLabel); + data.put("rows", rows); + data.put("totals", totals); + data.put("excludeWithdrawn", true); + return data; + } + + /** 직원별/거래처별 월간 통계 (철회 제외) */ + private Map buildGroupedPeriodReport(JwtUser u, String groupBy, String titleLabel, int year, int month) { + List base = listAll(HomesContract.class, u).stream() + .filter(c -> !"철회".equals(c.getStatus())) + .filter(c -> c.getContractDate() != null + && c.getContractDate().getYear() == year + && c.getContractDate().getMonthValue() == month) + .toList(); + Function labelFn = "agency".equals(groupBy) + ? (c -> nullToEmpty(c.getAgency()).isBlank() ? "미지정" : c.getAgency()) + : (c -> nullToEmpty(c.getEmployee()).isBlank() ? "미지정" : c.getEmployee()); + List> rows = groupByLabel(base, labelFn); + Map totals = reportRow(0, "합계", base); + Map data = new LinkedHashMap<>(); + data.put("groupBy", groupBy); + data.put("year", year); + data.put("month", month); + data.put("title", titleLabel); + data.put("rows", rows); + data.put("totals", totals); + data.put("excludeWithdrawn", true); + return data; + } + + /** 할인합계: feeReduction 숫자값 또는 정책합-정산금액 */ + private BigDecimal contractDiscount(HomesContract c) { + String fr = c.getFeeReduction(); + if (fr != null && !fr.isBlank()) { + try { + String digits = fr.replaceAll("[^0-9.\\-]", ""); + if (!digits.isBlank()) return new BigDecimal(digits).abs(); + } catch (Exception ignored) {} + } + BigDecimal policy = bd(c.getPolicySum()); + BigDecimal settle = bd(c.getSettleAmount()); + BigDecimal diff = policy.subtract(settle); + return diff.compareTo(BigDecimal.ZERO) > 0 ? diff : BigDecimal.ZERO; + } + + private Map reportRow(int key, String label, List list) { + Map row = new LinkedHashMap<>(); + row.put("key", key); + row.put("label", label); + row.put("internetCount", list.stream().filter(c -> "INTERNET".equals(c.getKind())).count()); + row.put("homeCount", list.stream().filter(c -> "HOME".equals(c.getKind())).count()); + row.put("count", list.size()); + row.put("settleAmount", list.stream().map(c -> bd(c.getSettleAmount())).reduce(BigDecimal.ZERO, BigDecimal::add)); + row.put("margin", list.stream().map(c -> bd(c.getMargin())).reduce(BigDecimal.ZERO, BigDecimal::add)); + row.put("policySum", list.stream().map(c -> bd(c.getPolicySum())).reduce(BigDecimal.ZERO, BigDecimal::add)); + return row; + } + + private List> groupByLabel(List list, Function labelFn) { + Map> grouped = list.stream().collect(Collectors.groupingBy(labelFn, LinkedHashMap::new, Collectors.toList())); + List> rows = new ArrayList<>(); + int key = 1; + for (var e : grouped.entrySet()) rows.add(reportRow(key++, e.getKey(), e.getValue())); + return rows; + } + + private void writeContractLog(JwtUser u, HomesContract c, String verb, String status) { + String kindLabel = c != null && "HOME".equals(c.getKind()) ? "홈렌탈접수" : "인터넷접수"; + String name = c == null || c.getCustomerName() == null ? "" : c.getCustomerName(); + if ("상태".equals(verb)) { + writeLog(u, "CONTRACT", kindLabel + "-상태", + kindLabel + " [" + name + "] " + status + "(으)로 변경하였습니다."); + } else { + writeLog(u, "CONTRACT", kindLabel + "-" + verb, + kindLabel + " [" + name + "] " + verb + "하였습니다."); + } + } + + private void writeProductLog(JwtUser u, HomesProduct p, String verb) { + String kindLabel = productKindLabel(p); + writeLog(u, "PRODUCT", kindLabel + "-" + verb, kindLabel + " [" + p.getName() + "] " + verb + "하였습니다."); + } + + private static String productKindLabel(HomesProduct p) { + return p != null && "HOME".equals(p.getKind()) ? "홈렌탈상품" : "인터넷상품"; + } + + private void writeLog(JwtUser u, String domain, String action, String content) { + HomesLog log = new HomesLog(); + log.setGroupId(u.getGroupId()); + log.setDomain(domain); + log.setAction(action); + log.setContent(content); + String staff = resolveMemberName(u); + log.setStaff(staff == null || staff.isBlank() ? u.getUserid() : staff); + log.setProcessedAt(LocalDateTime.now()); + em.persist(log); + } + + private List listAll(Class type, JwtUser u) { + return em.createQuery("select e from " + type.getSimpleName() + " e where e.groupId=:g order by e.id desc", type) + .setParameter("g", u.getGroupId()).getResultList(); + } + + private long count(Class type, String g) { + return em.createQuery("select count(e) from " + type.getSimpleName() + " e where e.groupId=:g", Long.class) + .setParameter("g", g).getSingleResult(); + } + + private ApiResponse pageOk(List filtered, int page, int size, Function> map, Map counts) { + int fromIdx = Math.max(0, page) * Math.max(1, size); + List> list = filtered.stream().skip(fromIdx).limit(Math.min(Math.max(size, 1), 200)).map(map).toList(); + Map data = new LinkedHashMap<>(); + data.put("total", filtered.size()); + data.put("list", list); + if (counts != null) data.put("counts", counts); + return ApiResponse.ok(data); + } + + @SuppressWarnings("unchecked") + private Map loadJsonSetting(String groupId, String key, Map def) { + List rows = em.createQuery("select s from Setting s where s.groupId=:g and s.settingKey=:k", Setting.class) + .setParameter("g", groupId).setParameter("k", key).getResultList(); + if (rows.isEmpty() || rows.getFirst().getValue() == null || rows.getFirst().getValue().isBlank()) return def; + try { return mapper.readValue(rows.getFirst().getValue(), Map.class); } + catch (Exception e) { return def; } + } + + private void saveJsonSetting(String groupId, String key, Map value) { + List rows = em.createQuery("select s from Setting s where s.groupId=:g and s.settingKey=:k", Setting.class) + .setParameter("g", groupId).setParameter("k", key).getResultList(); + try { + String json = mapper.writeValueAsString(value); + if (rows.isEmpty()) { + Setting s = new Setting(); s.setGroupId(groupId); s.setSettingKey(key); s.setValue(json); em.persist(s); + } else { + rows.getFirst().setValue(json); + } + } catch (Exception e) { throw new IllegalArgumentException("설정 저장 실패"); } + } + + private Map loadHomesPreference(String groupId) { + Map result = defaultPreference(); + List rows = em.createQuery("select s from Setting s where s.groupId=:g and s.settingKey=:k", Setting.class) + .setParameter("g", groupId).setParameter("k", "homes_preference").getResultList(); + if (!rows.isEmpty() && rows.getFirst().getValue() != null && !rows.getFirst().getValue().isBlank()) { + try { + @SuppressWarnings("unchecked") + Map loaded = mapper.readValue(rows.getFirst().getValue(), Map.class); + mergePreference(result, loaded); + } catch (Exception ignored) { /* keep defaults */ } + } + // migrate legacy flat keys → manager + if (!(result.get("manager") instanceof Map)) { + Map manager = new LinkedHashMap<>(); + manager.put("name", "홍길순"); + manager.put("phone", "010-1234-5678"); + result.put("manager", manager); + } + @SuppressWarnings("unchecked") + Map manager = (Map) result.get("manager"); + if ((manager.get("name") == null || String.valueOf(manager.get("name")).isBlank()) && result.get("ceo") != null) { + manager.put("name", result.get("ceo")); + } + if ((manager.get("phone") == null || String.valueOf(manager.get("phone")).isBlank()) && result.get("phone") != null) { + manager.put("phone", result.get("phone")); + } + return result; + } + + @SuppressWarnings("unchecked") + private void mergePreference(Map target, Map patch) { + if (patch == null) return; + for (Map.Entry e : patch.entrySet()) { + Object val = e.getValue(); + if (val instanceof Map m && target.get(e.getKey()) instanceof Map) { + Map dest = new LinkedHashMap<>((Map) target.get(e.getKey())); + dest.putAll((Map) m); + target.put(e.getKey(), dest); + } else { + target.put(e.getKey(), val); + } + } + } + + private Map defaultPreference() { + Map manager = new LinkedHashMap<>(); + manager.put("name", "홍길순"); + manager.put("phone", "010-1234-5678"); + + Map loginBlock = new LinkedHashMap<>(); + loginBlock.put("mode", "none"); // none | block_staff + + Map accessIps = new LinkedHashMap<>(); + accessIps.put("enabled", false); + accessIps.put("ips", List.of()); + + Map contract = new LinkedHashMap<>(); + contract.put("autoRegisterCustomer", true); + contract.put("defaultMonthsInternet", 36); + contract.put("defaultMonthsHome", 60); + contract.put("requireAgency", false); + contract.put("autoCalcSettle", true); + contract.put("allowWithdraw", true); + + Map m = new LinkedHashMap<>(); + m.put("manager", manager); + m.put("loginBlock", loginBlock); + m.put("accessIps", accessIps); + m.put("contract", contract); + m.put("incentiveRate", 5); + m.put("autoInvoice", true); + return m; + } + + private static final Map MENU_TITLE_TO_ID; + static { + Map m = new LinkedHashMap<>(); + m.put("상품관리", "product"); + m.put("계약관리", "contract"); + m.put("접수관리", "reception"); + m.put("정산관리", "invoice"); + m.put("일정관리", "pending"); + m.put("예약관리", "booking"); + m.put("고객관리", "customer"); + m.put("장부관리", "abooks"); + m.put("통계관리", "report"); + m.put("환경설정", "setting"); + MENU_TITLE_TO_ID = Collections.unmodifiableMap(m); + } + + private static final List ALWAYS_MENU_IDS = List.of("home", "cs", "bbs", "sitemap"); + + @SuppressWarnings("unchecked") + private List resolveAllowedMenus(String groupId, String userid, String staffPermission, String menuFlagsRaw) { + LinkedHashSet allowed = new LinkedHashSet<>(); + boolean isOwner = "대표".equals(staffPermission) || "demo".equals(userid); + + Map exposure = parseMenuFlagsMap(menuFlagsRaw); + + if (isOwner) { + for (String id : List.of("home", "product", "contract", "reception", "invoice", "pending", "booking", + "customer", "abooks", "report", "setting", "cs", "bbs", "sitemap")) { + if (Boolean.FALSE.equals(exposure.get(id))) continue; + allowed.add(id); + } + return new ArrayList<>(allowed); + } + + // 권한설정(메뉴설정) 매트릭스 + String matrixRole = staffPermission; + if (!List.of("총괄", "팀장", "사원", "영업", "딜러", "개발자").contains(matrixRole)) { + matrixRole = "사원"; + } + Map levels = loadHomesLevels(groupId); + Object modesObj = levels.get("modes"); + Object modeRaw = modesObj instanceof Map modesMap ? modesMap.get("menu") : null; + String mode = String.valueOf(modeRaw == null ? "default" : modeRaw); + Map defaults = (Map) levels.get("defaults"); + Map custom = (Map) levels.get("custom"); + Map menuMatrixSrc = "custom".equals(mode) + ? (Map) custom.getOrDefault("menu", defaults.get("menu")) + : (Map) defaults.get("menu"); + Object roleFlagsObj = menuMatrixSrc == null ? null : menuMatrixSrc.get(matrixRole); + @SuppressWarnings("unchecked") + Map roleFlags = roleFlagsObj instanceof Map m + ? (Map) m + : Map.of(); + + for (Map.Entry e : MENU_TITLE_TO_ID.entrySet()) { + String title = e.getKey(); + String id = e.getValue(); + Object flag = roleFlags.get(title); + if (flag == null && "일정관리".equals(title)) { + flag = roleFlags.containsKey("약속관리") ? roleFlags.get("약속관리") : roleFlags.get("약속업무"); + } + if (flag == null && title.endsWith("관리")) { + String legacyTitle = title.substring(0, title.length() - 2) + "업무"; + flag = roleFlags.get(legacyTitle); + } + boolean levelOk = Boolean.TRUE.equals(flag) + || (flag != null && "true".equalsIgnoreCase(String.valueOf(flag))); + if (!levelOk) continue; + if (Boolean.FALSE.equals(exposure.get(id))) continue; + allowed.add(id); + } + + // 대시보드/고객센터/게시판/전체보기 — 권한매트릭스 외, 직원 노출설정만 적용 + for (String id : ALWAYS_MENU_IDS) { + if (Boolean.FALSE.equals(exposure.get(id))) continue; + allowed.add(id); + } + + if (allowed.isEmpty()) allowed.add("home"); + return new ArrayList<>(allowed); + } + + private Map parseMenuFlagsMap(String raw) { + Map flags = new LinkedHashMap<>(); + for (String id : List.of("home", "product", "contract", "reception", "invoice", "pending", "booking", + "customer", "abooks", "report", "cs", "bbs", "setting", "sitemap")) { + flags.put(id, true); + } + if (raw == null || raw.isBlank()) return flags; + try { + @SuppressWarnings("unchecked") + Map parsed = mapper.readValue(raw, Map.class); + for (Map.Entry e : parsed.entrySet()) { + Object v = e.getValue(); + boolean on = v instanceof Boolean b ? b : !"false".equalsIgnoreCase(String.valueOf(v)); + flags.put(e.getKey(), on); + } + } catch (Exception ignored) { /* keep defaults */ } + return flags; + } + + private Map loadHomesLevels(String groupId) { + Map defaults = defaultLevelMatrices(); + Map modes = new LinkedHashMap<>(); + Map custom = new LinkedHashMap<>(); + for (String tab : LEVEL_TABS) { + modes.put(tab, "default"); + custom.put(tab, deepCopyMatrix(defaults.get(tab))); + } + List rows = em.createQuery("select s from Setting s where s.groupId=:g and s.settingKey=:k", Setting.class) + .setParameter("g", groupId).setParameter("k", "homes_levels").getResultList(); + if (!rows.isEmpty() && rows.getFirst().getValue() != null && !rows.getFirst().getValue().isBlank()) { + try { + @SuppressWarnings("unchecked") + Map loaded = mapper.readValue(rows.getFirst().getValue(), Map.class); + // legacy flat role→menu map + if (loaded.containsKey("총괄") && !loaded.containsKey("modes") && !loaded.containsKey("custom")) { + custom.put("menu", loaded); + modes.put("menu", "custom"); + } else { + if (loaded.get("modes") instanceof Map m) { + for (Map.Entry e : m.entrySet()) modes.put(String.valueOf(e.getKey()), e.getValue()); + } + if (loaded.get("custom") instanceof Map c) { + for (Map.Entry e : c.entrySet()) custom.put(String.valueOf(e.getKey()), e.getValue()); + } + } + } catch (Exception ignored) { /* keep defaults */ } + } + // 메뉴 매트릭스 구키(ㅇㅇ업무) → ㅇㅇ관리 호환 + 신규 메뉴(접수관리) 보강 + custom.put("menu", migrateMenuMatrixKeys(custom.get("menu"))); + defaults.put("menu", migrateMenuMatrixKeys(defaults.get("menu"))); + for (String tab : LEVEL_TABS) { + Object defMat = defaults.get(tab); + custom.put(tab, syncMatrixRows(custom.get(tab), defMat, LEVEL_ROWS.get(tab))); + defaults.put(tab, syncMatrixRows(defMat, defMat, LEVEL_ROWS.get(tab))); + if (!modes.containsKey(tab) || modes.get(tab) == null) modes.put(tab, "default"); + } + + Map result = new LinkedHashMap<>(); + result.put("roles", List.of("총괄", "팀장", "사원", "영업", "딜러", "개발자")); + result.put("tabs", LEVEL_TAB_META); + result.put("rows", copyLevelRows()); + result.put("modes", modes); + result.put("custom", custom); + result.put("defaults", defaults); + return result; + } + + /** 저장된 매트릭스에 누락된 행(신규 메뉴/액션)을 기본값으로 채운다. */ + @SuppressWarnings("unchecked") + private Object syncMatrixRows(Object src, Object fallback, List rowLabels) { + Map base = deepCopyMatrix(src != null ? src : fallback); + if (rowLabels == null || rowLabels.isEmpty()) return base; + Map fb = deepCopyMatrix(fallback); + for (String role : List.of("총괄", "팀장", "사원", "영업", "딜러", "개발자")) { + Map flags = base.get(role) instanceof Map m + ? new LinkedHashMap<>((Map) m) + : new LinkedHashMap<>(); + Map fbFlags = fb.get(role) instanceof Map m + ? (Map) m + : Map.of(); + for (String label : rowLabels) { + if (!flags.containsKey(label)) { + Object v = fbFlags.get(label); + flags.put(label, v instanceof Boolean ? v : Boolean.FALSE); + } + } + base.put(role, flags); + } + return base; + } + + private static Map> copyLevelRows() { + Map> out = new LinkedHashMap<>(); + for (Map.Entry> e : LEVEL_ROWS.entrySet()) { + out.put(e.getKey(), List.copyOf(e.getValue())); + } + return out; + } + + /** 권한설정 메뉴행: ㅇㅇ업무 → ㅇㅇ관리 */ + @SuppressWarnings("unchecked") + private Object migrateMenuMatrixKeys(Object src) { + if (!(src instanceof Map roles)) return src; + Map out = new LinkedHashMap<>(); + for (Map.Entry roleEntry : roles.entrySet()) { + Object flagsObj = roleEntry.getValue(); + if (!(flagsObj instanceof Map flags)) { + out.put(String.valueOf(roleEntry.getKey()), flagsObj); + continue; + } + Map migrated = new LinkedHashMap<>(); + for (Map.Entry flag : flags.entrySet()) { + String key = String.valueOf(flag.getKey()); + if ("약속업무".equals(key) || "약속관리".equals(key)) { + key = "일정관리"; + } else if (key.endsWith("업무") && key.length() > 2) { + key = key.substring(0, key.length() - 2) + "관리"; + } + // 동일 키 중복 시 true 우선 + Object prev = migrated.get(key); + Object next = flag.getValue(); + if (prev == null) migrated.put(key, next); + else if (Boolean.TRUE.equals(next) || "true".equalsIgnoreCase(String.valueOf(next))) migrated.put(key, true); + } + out.put(String.valueOf(roleEntry.getKey()), migrated); + } + return out; + } + + private static final List LEVEL_TABS = List.of( + "menu", "product", "contract", "reception", "invoice", "pending", "booking", "customer", "abooks", "report"); + + private static final List> LEVEL_TAB_META = List.of( + Map.of("key", "menu", "label", "메뉴설정"), + Map.of("key", "product", "label", "상품설정"), + Map.of("key", "contract", "label", "계약설정"), + Map.of("key", "reception", "label", "접수설정"), + Map.of("key", "invoice", "label", "정산설정"), + Map.of("key", "pending", "label", "일정설정"), + Map.of("key", "booking", "label", "예약설정"), + Map.of("key", "customer", "label", "고객설정"), + Map.of("key", "abooks", "label", "장부설정"), + Map.of("key", "report", "label", "통계설정") + ); + + private static final Map> LEVEL_ROWS; + static { + Map> rows = new LinkedHashMap<>(); + rows.put("menu", List.of("상품관리", "계약관리", "접수관리", "정산관리", "일정관리", "예약관리", "고객관리", "장부관리", "통계관리", "환경설정")); + rows.put("product", List.of("목록조회", "상품등록", "상품수정", "상품삭제", "정책관리", "엑셀다운로드")); + rows.put("contract", List.of("목록조회", "계약등록", "계약수정", "상태변경", "엑셀다운로드")); + rows.put("reception", List.of("목록조회", "접수등록", "접수수정", "진행변경", "엑셀다운로드")); + rows.put("invoice", List.of("목록조회", "정산서발행", "정산서수정", "엑셀다운로드")); + rows.put("pending", List.of("목록조회", "일정등록", "약속수정", "약속삭제", "캘린더조회")); + rows.put("booking", List.of("목록조회", "예약등록", "예약수정", "예약삭제")); + rows.put("customer", List.of("목록조회", "고객등록", "고객수정", "고객삭제", "분류설정")); + rows.put("abooks", List.of("목록조회", "장부등록", "장부수정", "장부삭제", "엑셀다운로드")); + rows.put("report", List.of("일일리포트", "월간리포트", "상품통계", "직원통계", "거래처통계", "인센티브")); + LEVEL_ROWS = Collections.unmodifiableMap(rows); + } + + @SuppressWarnings("unchecked") + private Map deepCopyMatrix(Object src) { + if (!(src instanceof Map m)) return new LinkedHashMap<>(); + Map out = new LinkedHashMap<>(); + for (Map.Entry e : m.entrySet()) { + if (e.getValue() instanceof Map inner) { + out.put(String.valueOf(e.getKey()), new LinkedHashMap<>((Map) inner)); + } else { + out.put(String.valueOf(e.getKey()), e.getValue()); + } + } + return out; + } + + private Map defaultLevelMatrices() { + Map all = new LinkedHashMap<>(); + all.put("menu", defaultMenuMatrix()); + all.put("product", defaultActionMatrix(LEVEL_ROWS.get("product"), true, true, true, false, false)); + all.put("contract", defaultActionMatrix(LEVEL_ROWS.get("contract"), true, true, true, true, true)); + all.put("reception", defaultActionMatrix(LEVEL_ROWS.get("reception"), true, true, true, true, true)); + all.put("invoice", defaultActionMatrix(LEVEL_ROWS.get("invoice"), true, true, true, true, true)); + all.put("pending", defaultActionMatrix(LEVEL_ROWS.get("pending"), true, true, true, true, true)); + all.put("booking", defaultActionMatrix(LEVEL_ROWS.get("booking"), true, true, true, true, true)); + all.put("customer", defaultActionMatrix(LEVEL_ROWS.get("customer"), true, true, true, false, false)); + all.put("abooks", defaultActionMatrix(LEVEL_ROWS.get("abooks"), true, true, true, false, false)); + all.put("report", defaultActionMatrix(LEVEL_ROWS.get("report"), true, true, false, false, false)); + return all; + } + + /** Screenshot menu defaults */ + private Map defaultMenuMatrix() { + List menus = LEVEL_ROWS.get("menu"); + Map m = new LinkedHashMap<>(); + for (String role : List.of("총괄", "팀장", "사원", "영업", "딜러", "개발자")) { + Map flags = new LinkedHashMap<>(); + for (String menu : menus) { + boolean allow = switch (role) { + case "총괄", "팀장", "개발자" -> true; + case "사원" -> List.of("계약관리", "접수관리", "정산관리", "일정관리", "예약관리", "고객관리", "장부관리").contains(menu); + case "영업", "딜러" -> List.of("계약관리", "접수관리", "정산관리", "일정관리", "예약관리").contains(menu); + default -> false; + }; + flags.put(menu, allow); + } + m.put(role, flags); + } + return m; + } + + private Map defaultActionMatrix(List actions, + boolean 총괄, boolean 팀장, boolean 사원, boolean 영업, boolean 딜러) { + Map m = new LinkedHashMap<>(); + Map roleAllow = new LinkedHashMap<>(); + roleAllow.put("총괄", 총괄); + roleAllow.put("팀장", 팀장); + roleAllow.put("사원", 사원); + roleAllow.put("영업", 영업); + roleAllow.put("딜러", 딜러); + roleAllow.put("개발자", 총괄); // 개발자 = 총괄과 동일한 액션 권한 + for (String role : List.of("총괄", "팀장", "사원", "영업", "딜러", "개발자")) { + Map flags = new LinkedHashMap<>(); + boolean base = Boolean.TRUE.equals(roleAllow.get(role)); + for (String action : actions) { + boolean allow = base; + if (base && "사원".equals(role) && action.contains("삭제")) allow = false; + if (base && ("영업".equals(role) || "딜러".equals(role)) + && (action.contains("삭제") || action.contains("발행") || action.contains("정책"))) allow = false; + flags.put(action, allow); + } + m.put(role, flags); + } + return m; + } + + /** @deprecated kept for any leftover callers; use defaultLevelMatrices */ + private Map defaultLevels() { + return defaultMenuMatrix(); + } + + private Map productMap(HomesProduct p) { + Map m = new LinkedHashMap<>(); + m.put("id", p.getId()); m.put("kind", p.getKind()); m.put("category", p.getCategory()); + m.put("company", p.getCompany()); m.put("name", p.getName()); m.put("model", p.getModel()); + m.put("months", p.getMonths()); m.put("policyAmount", p.getPolicyAmount()); + m.put("policyCount", p.getPolicyCount() == null ? 0 : p.getPolicyCount()); + m.put("memo", p.getMemo()); + return m; + } + + private Map productPolicyMap(HomesProductPolicy p) { + Map m = new LinkedHashMap<>(); + m.put("id", p.getId()); m.put("productId", p.getProductId()); + m.put("name", p.getName()); m.put("amount", p.getAmount()); + return m; + } + private Map contractMap(HomesContract c) { + Map m = new LinkedHashMap<>(); + m.put("id", c.getId()); m.put("kind", c.getKind()); m.put("status", c.getStatus()); + m.put("customerType", c.getCustomerType()); m.put("customerName", c.getCustomerName()); + m.put("phone", c.getPhone()); m.put("address", c.getAddress()); m.put("products", c.getProducts()); + m.put("agency", c.getAgency()); m.put("employee", c.getEmployee()); m.put("payMethod", c.getPayMethod()); + m.put("contractDate", c.getContractDate()); m.put("installDate", c.getInstallDate()); + m.put("months", c.getMonths()); m.put("policySum", c.getPolicySum()); + m.put("settleAmount", c.getSettleAmount()); m.put("margin", c.getMargin()); m.put("memo", c.getMemo()); + m.put("email", c.getEmail()); m.put("gender", c.getGender()); m.put("nationality", c.getNationality()); + m.put("customerGrade", c.getCustomerGrade()); m.put("birthDate", c.getBirthDate()); + m.put("zipcode", c.getZipcode()); m.put("addressDetail", c.getAddressDetail()); + m.put("installPhone", c.getInstallPhone()); m.put("installZip", c.getInstallZip()); + m.put("installAddress", c.getInstallAddress()); m.put("installAddressDetail", c.getInstallAddressDetail()); + m.put("feeReduction", c.getFeeReduction()); m.put("invoiceType", c.getInvoiceType()); + m.put("sameContact", c.getSameContact()); + return m; + } + private Map balanceMap(HomesBalance b) { + Map m = new LinkedHashMap<>(); + String balanceType = normalizeBalanceType(b.getBalanceType()); + String targetType = resolveTargetType(b); + String targetName = resolveTargetName(b); + BigDecimal amount = b.getAmount() == null ? BigDecimal.ZERO : b.getAmount(); + if ("정산차감".equals(balanceType) && amount.signum() > 0) amount = amount.negate(); + m.put("id", b.getId()); + m.put("balanceType", balanceType); + m.put("targetType", targetType); + m.put("targetName", targetName); + m.put("baseDate", b.getBaseDate()); + m.put("customerName", b.getCustomerName()); + m.put("phone", b.getPhone()); + m.put("products", b.getProducts()); + m.put("amount", amount); + m.put("reason", b.getReason()); + m.put("agency", b.getAgency()); + m.put("memo", b.getMemo()); + m.put("contractId", b.getContractId()); + return m; + } + private Map invoiceMap(HomesInvoice i) { + Map m = new LinkedHashMap<>(); + m.put("id", i.getId()); m.put("scope", i.getScope()); m.put("yearMonth", i.getYearMonth()); + m.put("employee", i.getEmployee()); m.put("agency", i.getAgency()); m.put("contractCount", i.getContractCount()); + m.put("amount", i.getAmount()); m.put("status", i.getStatus()); m.put("memo", i.getMemo()); + m.put("internetCount", i.getInternetCount()); m.put("internetAmount", i.getInternetAmount()); + m.put("homeCount", i.getHomeCount()); m.put("homeAmount", i.getHomeAmount()); + m.put("balanceCount", i.getBalanceCount()); m.put("balanceAmount", i.getBalanceAmount()); + m.put("settleAmount", i.getSettleAmount() != null ? i.getSettleAmount() : i.getAmount()); + m.put("issued", Boolean.TRUE.equals(i.getIssued()) || "발행".equals(i.getStatus()) || "확정".equals(i.getStatus())); + m.put("consent", i.getConsent()); m.put("taxInvoice", i.getTaxInvoice()); m.put("withdrawStatus", i.getWithdrawStatus()); + return m; + } + private Map pendingMap(HomesPending p) { + Map m = new LinkedHashMap<>(); + m.put("id", p.getId()); + m.put("pendingType", p.getPendingType()); + m.put("pendingDate", p.getPendingDate()); + m.put("pendingTime", p.getPendingTime()); + m.put("customerName", p.getCustomerName()); + m.put("phone", p.getPhone()); + m.put("contents", p.getContents()); + m.put("employee", p.getEmployee()); + m.put("receiveEmployee", resolveReceiveEmployee(p)); + m.put("processEmployee", p.getProcessEmployee()); + m.put("processDate", p.getProcessDate()); + m.put("status", normalizePendingStatus(p.getStatus())); + m.put("memo", p.getMemo()); + return m; + } + private Map bookingMap(HomesBooking b) { + String internet = resolveInternetProducts(b); + String rental = resolveRentalProducts(b); + Map m = new LinkedHashMap<>(); + m.put("id", b.getId()); + m.put("status", b.getStatus()); + m.put("bookingDate", b.getBookingDate()); + m.put("customerName", b.getCustomerName()); + m.put("phone", b.getPhone()); + m.put("products", b.getProducts()); + m.put("internetProducts", internet); + m.put("rentalProducts", rental); + m.put("internetNote", b.getInternetNote()); + m.put("rentalNote", b.getRentalNote()); + m.put("zipcode", b.getZipcode()); + m.put("address", b.getAddress()); + m.put("addressDetail", b.getAddressDetail()); + m.put("employee", b.getEmployee()); + m.put("receiveEmployee", b.getEmployee()); + m.put("memo", b.getMemo()); + m.put("fileName", b.getFileName()); + return m; + } + + private void normalizeBookingPayload(Map d, JwtUser u) { + if (d.get("receiveEmployee") != null) d.put("employee", d.get("receiveEmployee")); + else if (d.get("employee") == null || String.valueOf(d.get("employee")).isBlank()) { + String name = resolveMemberName(u); + if (name != null) d.put("employee", name); + } + if (d.get("status") == null || String.valueOf(d.get("status")).isBlank()) d.put("status", "접수"); + if (d.containsKey("phone1") || d.containsKey("phone2") || d.containsKey("phone3")) { + String p1 = d.get("phone1") == null ? "" : String.valueOf(d.get("phone1")).trim(); + String p2 = d.get("phone2") == null ? "" : String.valueOf(d.get("phone2")).trim(); + String p3 = d.get("phone3") == null ? "" : String.valueOf(d.get("phone3")).trim(); + List parts = new ArrayList<>(); + if (!p1.isBlank()) parts.add(p1); + if (!p2.isBlank()) parts.add(p2); + if (!p3.isBlank()) parts.add(p3); + if (!parts.isEmpty()) d.put("phone", String.join("-", parts)); + } + String internet = joinProductTokens(d.get("internetProducts")); + String rental = joinProductTokens(d.get("rentalProducts")); + if (internet != null) d.put("internetProducts", internet); + if (rental != null) d.put("rentalProducts", rental); + List all = new ArrayList<>(); + if (internet != null && !internet.isBlank()) all.add(internet); + if (rental != null && !rental.isBlank()) all.add(rental); + if (!all.isEmpty()) d.put("products", String.join(", ", all)); + d.remove("phone1"); d.remove("phone2"); d.remove("phone3"); d.remove("receiveEmployee"); + } + + private void ensureBookingCustomer(HomesBooking b, JwtUser u) { + String name = nullToEmpty(b.getCustomerName()).trim(); + String phone = nullToEmpty(b.getPhone()).trim(); + if (name.isBlank() || phone.isBlank()) return; + String digits = phone.replaceAll("\\D", ""); + List existing = em.createQuery( + "select c from HomesCustomer c where c.groupId=:g", HomesCustomer.class) + .setParameter("g", u.getGroupId()).getResultList().stream() + .filter(c -> digits.equals(nullToEmpty(c.getPhone()).replaceAll("\\D", ""))) + .toList(); + if (!existing.isEmpty()) return; + HomesCustomer c = new HomesCustomer(); + c.setGroupId(u.getGroupId()); + c.setName(name); + c.setPhone(phone); + c.setAddress(b.getAddress()); + c.setZipcode(b.getZipcode()); + c.setAddressDetail(b.getAddressDetail()); + c.setEmployee(b.getEmployee()); + c.setGrade("분류없음"); + c.setCustomerType("개인"); + c.setRegisteredDate(LocalDate.now()); + em.persist(c); + writeLog(u, "CUSTOMER", "고객-등록", "고객 [" + name + "] 예약과 함께 등록하였습니다."); + } + + private static String joinProductTokens(Object raw) { + if (raw == null) return null; + if (raw instanceof List list) { + List parts = new ArrayList<>(); + for (Object o : list) { + if (o == null) continue; + String s = String.valueOf(o).trim(); + if (!s.isBlank() && !parts.contains(s)) parts.add(s); + } + return String.join(", ", parts); + } + String s = String.valueOf(raw).trim(); + return s.isBlank() ? null : s; + } + + private static boolean containsProductToken(String haystack, String needle) { + if (haystack == null || needle == null) return false; + for (String part : haystack.split("[,,/|]")) { + if (needle.equals(part.trim())) return true; + } + return haystack.contains(needle); + } + + private static String resolveInternetProducts(HomesBooking b) { + if (b.getInternetProducts() != null && !b.getInternetProducts().isBlank()) return b.getInternetProducts(); + return extractKnownProducts(b.getProducts(), BOOKING_INTERNET_OPTIONS); + } + + private static String resolveRentalProducts(HomesBooking b) { + if (b.getRentalProducts() != null && !b.getRentalProducts().isBlank()) return b.getRentalProducts(); + return extractKnownProducts(b.getProducts(), BOOKING_RENTAL_OPTIONS); + } + + private static String extractKnownProducts(String products, List options) { + if (products == null || products.isBlank()) return ""; + List found = new ArrayList<>(); + for (String opt : options) { + if (products.contains(opt) && !found.contains(opt)) found.add(opt); + } + return String.join(", ", found); + } + private Map customerMap(HomesCustomer c) { + return customerMap(c, Map.of(), Map.of()); + } + + private Map customerMap(HomesCustomer c, Map contractCounts, Map bookingCounts) { + String digits = nullToEmpty(c.getPhone()).replaceAll("\\D", ""); + Map m = new LinkedHashMap<>(); + m.put("id", c.getId()); + m.put("grade", normalizeCustomerGrade(c.getGrade())); + m.put("customerType", nullToEmpty(c.getCustomerType()).isBlank() ? "개인" : c.getCustomerType()); + m.put("name", c.getName()); + m.put("phone", c.getPhone()); + m.put("phoneAlt", c.getPhoneAlt()); + m.put("zipcode", c.getZipcode()); + m.put("address", c.getAddress()); + m.put("addressDetail", c.getAddressDetail()); + m.put("agency", c.getAgency()); + m.put("employee", c.getEmployee()); + m.put("memo", c.getMemo()); + m.put("email", c.getEmail()); + m.put("gender", c.getGender()); + m.put("nationality", c.getNationality()); + m.put("birthYear", c.getBirthYear()); + m.put("birthMonth", c.getBirthMonth()); + m.put("birthDay", c.getBirthDay()); + m.put("registeredDate", resolveCustomerRegisteredDate(c)); + m.put("contractCount", contractCounts.getOrDefault(digits, 0L)); + m.put("bookingCount", bookingCounts.getOrDefault(digits, 0L)); + return m; + } + + private List customerGradeList(JwtUser u) { + Map saved = loadJsonSetting(u.getGroupId(), "homes.customerGrades", Map.of()); + Object raw = saved.get("grades"); + List grades = new ArrayList<>(); + if (raw instanceof List list) { + for (Object o : list) { + if (o == null) continue; + String s = String.valueOf(o).trim(); + if (!s.isBlank() && !grades.contains(s)) grades.add(s); + } + } + if (grades.isEmpty()) grades.addAll(DEFAULT_CUSTOMER_GRADES); + return grades; + } + + private void normalizeCustomerPayload(Map d, JwtUser u) { + if (d.get("employee") == null || String.valueOf(d.get("employee")).isBlank()) { + String name = resolveMemberName(u); + if (name != null) d.put("employee", name); + } + if (d.get("customerType") == null || String.valueOf(d.get("customerType")).isBlank()) d.put("customerType", "개인"); + if (d.get("grade") == null || String.valueOf(d.get("grade")).isBlank()) d.put("grade", "분류없음"); + if (d.containsKey("phone1") || d.containsKey("phone2") || d.containsKey("phone3")) { + d.put("phone", joinPhoneParts(d.get("phone1"), d.get("phone2"), d.get("phone3"))); + } + if (d.containsKey("altPhone1") || d.containsKey("altPhone2") || d.containsKey("altPhone3")) { + String alt = joinPhoneParts(d.get("altPhone1"), d.get("altPhone2"), d.get("altPhone3")); + if (alt != null) d.put("phoneAlt", alt); + } + if (d.get("registeredDate") == null || String.valueOf(d.get("registeredDate")).isBlank()) { + d.put("registeredDate", LocalDate.now().toString()); + } + d.remove("phone1"); d.remove("phone2"); d.remove("phone3"); + d.remove("altPhone1"); d.remove("altPhone2"); d.remove("altPhone3"); + } + + private static String joinPhoneParts(Object p1, Object p2, Object p3) { + String a = p1 == null ? "" : String.valueOf(p1).trim(); + String b = p2 == null ? "" : String.valueOf(p2).trim(); + String c = p3 == null ? "" : String.valueOf(p3).trim(); + List parts = new ArrayList<>(); + if (!a.isBlank()) parts.add(a); + if (!b.isBlank()) parts.add(b); + if (!c.isBlank()) parts.add(c); + return parts.isEmpty() ? null : String.join("-", parts); + } + + private static String normalizeCustomerGrade(String grade) { + if (grade == null || grade.isBlank() || "일반".equals(grade)) return "분류없음"; + return grade; + } + + private LocalDate resolveCustomerRegisteredDate(HomesCustomer c) { + if (c.getRegisteredDate() != null) return c.getRegisteredDate(); + if (c.getCreatedAt() != null) return c.getCreatedAt().atZone(ZoneId.systemDefault()).toLocalDate(); + return null; + } + + private Map countByCustomerPhone(JwtUser u, Class type) { + Map map = new HashMap<>(); + if (type == HomesContract.class) { + for (HomesContract c : listAll(HomesContract.class, u)) { + String digits = nullToEmpty(c.getPhone()).replaceAll("\\D", ""); + if (digits.isBlank()) continue; + map.merge(digits, 1L, Long::sum); + } + } else if (type == HomesBooking.class) { + for (HomesBooking b : listAll(HomesBooking.class, u)) { + String digits = nullToEmpty(b.getPhone()).replaceAll("\\D", ""); + if (digits.isBlank()) continue; + map.merge(digits, 1L, Long::sum); + } + } + return map; + } + private Map abookMap(HomesAbook a) { + String type = normalizeAbookEntryType(a.getEntryType()); + String item = a.getItem(); + if (item == null || item.isBlank()) item = a.getAgency(); + String category = a.getCategory(); + if (category == null || category.isBlank()) category = "일반"; + Map m = new LinkedHashMap<>(); + m.put("id", a.getId()); + m.put("entryDate", a.getEntryDate()); + m.put("entryType", type); + m.put("category", category); + m.put("item", item); + m.put("amount", a.getAmount()); + m.put("deposit", "입금".equals(type) ? a.getAmount() : null); + m.put("withdraw", "출금".equals(type) ? a.getAmount() : null); + m.put("cardDeposit", "카드입금".equals(type) ? a.getAmount() : null); + m.put("cardWithdraw", "카드출금".equals(type) ? a.getAmount() : null); + m.put("agency", a.getAgency()); + m.put("employee", a.getEmployee()); + m.put("memo", a.getMemo()); + return m; + } + + private List abookCategoryList(JwtUser u) { + Map saved = loadJsonSetting(u.getGroupId(), "homes.abookCategories", Map.of()); + Object raw = saved.get("categories"); + List categories = new ArrayList<>(); + if (raw instanceof List list) { + for (Object o : list) { + if (o == null) continue; + String s = String.valueOf(o).trim(); + if (!s.isBlank() && !categories.contains(s)) categories.add(s); + } + } + if (categories.isEmpty()) categories.addAll(DEFAULT_ABOOK_CATEGORIES); + return categories; + } + + private void normalizeAbookPayload(Map d, JwtUser u) { + if (d.get("entryType") != null) d.put("entryType", normalizeAbookEntryType(String.valueOf(d.get("entryType")))); + if (d.get("employee") == null || String.valueOf(d.get("employee")).isBlank()) { + String name = resolveMemberName(u); + if (name != null) d.put("employee", name); + } + if (d.get("category") == null || String.valueOf(d.get("category")).isBlank()) d.put("category", "일반"); + if ((d.get("item") == null || String.valueOf(d.get("item")).isBlank()) && d.get("agency") != null) { + d.put("item", d.get("agency")); + } + } + + private static String normalizeAbookEntryType(String type) { + if (type == null || type.isBlank()) return "입금"; + return switch (type) { + case "수입" -> "입금"; + case "지출" -> "출금"; + default -> type; + }; + } + + private static String abookLogContent(HomesAbook a, String verb) { + String type = normalizeAbookEntryType(a.getEntryType()); + String category = nullToEmpty(a.getCategory()).isBlank() ? "일반" : a.getCategory(); + return "간편장부 [" + type + "/" + category + "] " + verb; + } + private Map agencyMap(HomesAgency a) { + Map m = new LinkedHashMap<>(); + m.put("id", a.getId()); + m.put("name", a.getName()); + m.put("managerName", a.getManagerName()); + m.put("phone", a.getPhone()); + m.put("fax", a.getFax()); + m.put("mobile", a.getMobile()); + m.put("agencyType", a.getAgencyType()); + m.put("address", a.getAddress()); + m.put("memo", a.getMemo()); + m.put("active", a.isActive()); + m.put("status", a.isActive() ? "사용" : "정지"); + LocalDate reg = a.getRegisteredDate(); + if (reg == null && a.getCreatedAt() != null) { + reg = a.getCreatedAt().atZone(ZoneId.of("Asia/Seoul")).toLocalDate(); + } + m.put("registeredDate", reg); + return m; + } + + private void normalizeAgencyPayload(Map d) { + if (d.containsKey("status")) { + String st = String.valueOf(d.get("status")); + d.put("active", !"정지".equals(st) && !"false".equalsIgnoreCase(st) && !"N".equalsIgnoreCase(st)); + d.remove("status"); + } + if (d.get("registeredDate") == null || String.valueOf(d.get("registeredDate")).isBlank()) { + d.put("registeredDate", LocalDate.now().toString()); + } + if (d.get("active") == null) d.put("active", true); + } + private Map partnerMap(HomesPartner p) { + Map m = new LinkedHashMap<>(); + m.put("id", p.getId()); + m.put("partnerLevel", p.getPartnerLevel()); + m.put("name", p.getName()); + m.put("userid", p.getUserid()); + m.put("parentName", p.getParentName()); + m.put("managerName", p.getManagerName()); + m.put("phone", p.getPhone()); + m.put("address", p.getAddress()); + m.put("memo", p.getMemo()); + String status = p.getStatus(); + if (status == null || status.isBlank()) status = "대기"; + m.put("status", status); + LocalDate reg = p.getRegisteredDate(); + if (reg == null && p.getCreatedAt() != null) { + reg = LocalDate.ofInstant(p.getCreatedAt(), ZoneId.of("Asia/Seoul")); + } + m.put("registeredDate", reg); + return m; + } + private Map memberMap(Member m) { + Map map = new LinkedHashMap<>(); + map.put("id", m.getId()); + map.put("userid", m.getUserid()); + map.put("name", m.getName()); + map.put("phone", m.getPhone()); + map.put("address", m.getAddress()); + map.put("accessLimit", m.getAccessLimit()); + map.put("staffPermission", m.getStaffPermission()); + map.put("active", m.isActive()); + map.put("status", m.isActive() ? "사용" : "중지"); + map.put("role", m.getRole()); + map.put("loginCount", m.getLoginCount() == null ? 0 : m.getLoginCount()); + map.put("lastLoginAt", m.getLastLoginAt() == null + ? "" + : m.getLastLoginAt().format(java.time.format.DateTimeFormatter.ofPattern("MM-dd HH:mm"))); + map.put("lastLoginAtFull", m.getLastLoginAt() == null + ? null + : m.getLastLoginAt().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))); + LocalDate reg = m.getCreatedAt() == null ? null : LocalDate.ofInstant(m.getCreatedAt(), ZoneId.of("Asia/Seoul")); + map.put("registeredDate", reg == null ? null : reg.toString()); + map.put("memo", m.getMemo()); + map.put("menuFlags", m.getMenuFlags()); + map.put("isOwner", "대표".equals(m.getStaffPermission()) || "demo".equals(m.getUserid())); + return map; + } + + private Comparator memberComparator(String sort) { + String field = sort == null || sort.isBlank() ? "id,asc" : sort; + boolean asc = !field.endsWith(",desc"); + String key = field.split(",")[0]; + Comparator c = switch (key) { + case "staffPermission", "권한" -> Comparator.comparing(Member::getStaffPermission, Comparator.nullsLast(String::compareTo)); + case "userid", "아이디" -> Comparator.comparing(Member::getUserid, Comparator.nullsLast(String::compareTo)); + case "name", "이름" -> Comparator.comparing(Member::getName, Comparator.nullsLast(String::compareTo)); + case "loginCount", "접속수" -> Comparator.comparing(m -> m.getLoginCount() == null ? 0 : m.getLoginCount()); + case "lastLoginAt", "최종접속" -> Comparator.comparing(Member::getLastLoginAt, Comparator.nullsLast(LocalDateTime::compareTo)); + case "registeredDate", "createdAt", "등록일" -> Comparator.comparing(Member::getCreatedAt, Comparator.nullsLast(Instant::compareTo)); + default -> Comparator.comparing(Member::getId, Comparator.nullsLast(Long::compareTo)); + }; + return (asc ? c : c.reversed()).thenComparing(Member::getId); + } + private Map noticeMap(HomesNotice n) { + Map m = new LinkedHashMap<>(); + m.put("id", n.getId()); m.put("title", n.getTitle()); m.put("authorName", n.getAuthorName()); + m.put("contents", n.getContents()); m.put("views", n.getViews()); + m.put("createdDate", n.getCreatedAt() == null ? null : LocalDate.ofInstant(n.getCreatedAt(), ZoneId.of("Asia/Seoul")).toString()); + return m; + } + private Map questionMap(HomesQuestion q) { + Map m = new LinkedHashMap<>(); + m.put("id", q.getId()); + m.put("title", q.getTitle()); + m.put("authorName", q.getAuthorName()); + m.put("content", q.getContent()); + m.put("reply", q.getReply()); + m.put("status", q.getStatus()); + boolean answered = "답변완료".equals(q.getStatus()) || (q.getReply() != null && !q.getReply().isBlank()); + m.put("answer", answered ? "답변완료" : ""); + m.put("createdDate", q.getCreatedAt() == null ? null : LocalDate.ofInstant(q.getCreatedAt(), ZoneId.of("Asia/Seoul")).toString()); + return m; + } + private Map bbsMap(HomesBbs b) { + Map m = new LinkedHashMap<>(); + m.put("id", b.getId()); m.put("title", b.getTitle()); m.put("authorName", b.getAuthorName()); + m.put("contents", b.getContents()); m.put("views", b.getViews()); + m.put("createdDate", b.getCreatedAt() == null ? null : LocalDate.ofInstant(b.getCreatedAt(), ZoneId.of("Asia/Seoul")).toString()); + return m; + } + private Map logMap(HomesLog l) { + Map m = new LinkedHashMap<>(); + m.put("id", l.getId()); m.put("domain", l.getDomain()); m.put("action", l.getAction()); + m.put("content", l.getContent()); m.put("staff", l.getStaff()); + m.put("processedAt", l.getProcessedAt() == null ? null + : l.getProcessedAt().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))); + return m; + } + + private static String qOf(Map f) { + String q = blankToNull(f.get("q")); + if (q == null) q = blankToNull(f.get("keyword")); + if (q == null) q = blankToNull(f.get("name")); + return q; + } + private static LocalDate parseDate(String v) { + if (v == null || v.isBlank()) return null; + try { return LocalDate.parse(v); } catch (Exception e) { return null; } + } + private static int parseInt(String v, int def) { + try { return Integer.parseInt(v); } catch (Exception e) { return def; } + } + private static String blankToNull(String v) { return v == null || v.isBlank() ? null : v; } + private static String nullToEmpty(String v) { return v == null ? "" : v; } + private static BigDecimal bd(Object v) { + if (v == null) return BigDecimal.ZERO; + if (v instanceof BigDecimal b) return b; + try { return new BigDecimal(String.valueOf(v)); } catch (Exception e) { return BigDecimal.ZERO; } + } +} diff --git a/backend/src/main/java/com/bizcare/agent/CareReceptionController.java b/backend/src/main/java/com/bizcare/agent/CareReceptionController.java new file mode 100644 index 0000000..b044221 --- /dev/null +++ b/backend/src/main/java/com/bizcare/agent/CareReceptionController.java @@ -0,0 +1,1241 @@ +package com.bizcare.agent; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.math.BigDecimal; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 접수관리 — billing_react /order(전체 리스트) 기능을 Care Homes 에 이식. + */ +@RestController +@RequestMapping("/api/agent/homes") +@RequiredArgsConstructor +class CareReceptionController { + private final EntityManager em; + private final FileStorageService fileStorage; + + static final List PROGRESS_STATUSES = List.of( + "1.접수완료", "2.고객통화", "3.서류검토", "4.개통완료", "5.사은품지급", "6.정산완료", "9.취소"); + static final List SETTLE_STATUSES = List.of("정산대기", "정산완료", "회수대기", "회수완료"); + static final List GIFT_STATUSES = List.of("미지급", "지급대기", "지급완료", "반송"); + static final List CARRIERS = List.of("KT", "SKB", "LGU+", "SKT", "기타"); + static final List REGIONS = List.of( + "서울", "경기", "인천", "강원", "충북", "충남", "대전", "전북", "전남", "광주", + "경북", "대구", "경남", "울산", "부산", "제주"); + + @GetMapping("/receptions") + ApiResponse list(@AuthenticationPrincipal JwtUser u, + @RequestParam Map f, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size) { + List filtered = filterReceptions(u, f); + Map counts = new LinkedHashMap<>(); + counts.put("ALL", (long) filtered.size()); + for (String st : PROGRESS_STATUSES) { + counts.put(st, filtered.stream().filter(r -> st.equals(r.getProgressStatus())).count()); + } + int fromIdx = Math.max(0, page) * Math.max(1, size); + int limit = Math.min(Math.max(size, 1), 200); + List> list = filtered.stream().skip(fromIdx).limit(limit) + .map(this::toMap).toList(); + Map data = new LinkedHashMap<>(); + data.put("total", filtered.size()); + data.put("list", list); + data.put("counts", counts); + data.put("progressStatuses", PROGRESS_STATUSES); + data.put("settleStatuses", SETTLE_STATUSES); + data.put("giftStatuses", GIFT_STATUSES); + data.put("carriers", distinct(filtered, HomesReception::getCarrier, CARRIERS)); + data.put("branches", distinct(filtered, HomesReception::getBranch, List.of())); + data.put("counselors", distinct(filtered, HomesReception::getCounselor, List.of())); + data.put("processors", distinct(filtered, HomesReception::getProcessor, List.of())); + data.put("regions", REGIONS); + data.put("giftCategories", distinct(filtered, HomesReception::getGiftCategory, List.of())); + data.put("productCategories", distinct(filtered, HomesReception::getProductCategory, List.of())); + return ApiResponse.ok(data); + } + + @GetMapping(value = "/receptions/excel", produces = "text/csv; charset=UTF-8") + org.springframework.http.ResponseEntity excel(@AuthenticationPrincipal JwtUser u, + @RequestParam Map f) { + List filtered = filterReceptions(u, f); + StringBuilder csv = new StringBuilder("\uFEFF"); + csv.append("접수번호,접수일,상담자,지점,통신사,사은품분류,상품분류,상품명,고객명,연락처,지역,진행상황,주문번호,입고정보,처리자,세부진행,지급구분,금액,정산상태,개통일,사은품일,사은품진행,지급처,약정,개통번호,메모\n"); + for (HomesReception r : filtered) { + csv.append(csvCell(r.getId())).append(',') + .append(csvCell(fmtDt(r.getOrderDate()))).append(',') + .append(csvCell(r.getCounselor())).append(',') + .append(csvCell(r.getBranch())).append(',') + .append(csvCell(r.getCarrier())).append(',') + .append(csvCell(r.getGiftCategory())).append(',') + .append(csvCell(r.getProductCategory())).append(',') + .append(csvCell(r.getProductName())).append(',') + .append(csvCell(r.getCustomerName())).append(',') + .append(csvCell(r.getPhone())).append(',') + .append(csvCell(r.getRegion())).append(',') + .append(csvCell(r.getProgressStatus())).append(',') + .append(csvCell(r.getOrderNumber())).append(',') + .append(csvCell(r.getInboundInfo())).append(',') + .append(csvCell(r.getProcessor())).append(',') + .append(csvCell(r.getDetailProgress())).append(',') + .append(csvCell(r.getPaymentType())).append(',') + .append(csvCell(r.getAmount())).append(',') + .append(csvCell(r.getSettleStatus())).append(',') + .append(csvCell(r.getOpenDate())).append(',') + .append(csvCell(r.getGiftDate())).append(',') + .append(csvCell(r.getGiftStatus())).append(',') + .append(csvCell(r.getGiftPlace())).append(',') + .append(csvCell(r.getAgreementName())).append(',') + .append(csvCell(r.getContractNumber())).append(',') + .append(csvCell(r.getMemo())).append('\n'); + } + return org.springframework.http.ResponseEntity.ok() + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=receptions.csv") + .body(csv.toString()); + } + + @GetMapping("/receptions/{id}") + ApiResponse get(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesReception r = findOwned(id, u); + if (r == null) return ApiResponse.fail("접수를 찾을 수 없습니다."); + return ApiResponse.ok(toProgressDetail(r, u)); + } + + /** 업무 진행내역 저장 — billing_react POST /order/{id}/progress */ + @PostMapping("/receptions/{id}/progress") + @Transactional + ApiResponse progress(@PathVariable Long id, @RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + HomesReception r = findOwned(id, u); + if (r == null) return ApiResponse.fail("접수를 찾을 수 없습니다."); + + if (body.containsKey("postIt")) r.setPostIt(str(body.get("postIt"))); + if (body.containsKey("postAdd")) r.setPostAdd(str(body.get("postAdd"))); + if (body.containsKey("contractNumber")) r.setContractNumber(str(body.get("contractNumber"))); + if (body.containsKey("installSchedule")) r.setInstallSchedule(str(body.get("installSchedule"))); + if (body.containsKey("customerLoginId") || body.containsKey("customerid")) { + Object v = body.containsKey("customerLoginId") ? body.get("customerLoginId") : body.get("customerid"); + r.setCustomerLoginId(str(v)); + } + if (body.containsKey("newTel")) r.setNewTel(str(body.get("newTel"))); + if (body.containsKey("multiLine")) r.setMultiLine(Boolean.TRUE.equals(body.get("multiLine")) || "true".equalsIgnoreCase(String.valueOf(body.get("multiLine")))); + if (body.containsKey("addPayer")) r.setAddPayer(Boolean.TRUE.equals(body.get("addPayer")) || "true".equalsIgnoreCase(String.valueOf(body.get("addPayer")))); + if (body.containsKey("record") || body.containsKey("recordFlag")) { + Object v = body.containsKey("recordFlag") ? body.get("recordFlag") : body.get("record"); + r.setRecordFlag(Boolean.TRUE.equals(v) || "true".equalsIgnoreCase(String.valueOf(v))); + } + if (body.containsKey("adminMemo")) r.setAdminMemo(str(body.get("adminMemo"))); + if (body.containsKey("openDate") || body.containsKey("opended")) { + Object v = body.containsKey("openDate") ? body.get("openDate") : body.get("opended"); + r.setOpenDate(parseDate(str(v))); + } + if (body.containsKey("cancelDate") || body.containsKey("canceled")) { + Object v = body.containsKey("cancelDate") ? body.get("cancelDate") : body.get("canceled"); + r.setCancelDate(parseDate(str(v))); + } + if (body.containsKey("giftPlace") || body.containsKey("place")) { + Object v = body.containsKey("giftPlace") ? body.get("giftPlace") : body.get("place"); + r.setGiftPlace(str(v)); + } + if (body.containsKey("giftRequested") || body.containsKey("requested")) { + Object v = body.containsKey("giftRequested") ? body.get("giftRequested") : body.get("requested"); + r.setGiftRequested(parseDate(str(v))); + } + if (body.containsKey("giftSended") || body.containsKey("sended")) { + Object v = body.containsKey("giftSended") ? body.get("giftSended") : body.get("sended"); + r.setGiftSended(parseDate(str(v))); + } + if (Boolean.TRUE.equals(body.get("changeOrderDate")) && body.get("orderDate") != null) { + r.setOrderDate(parseDateTime(str(body.get("orderDate")))); + } + + String newStatus = str(body.get("status")); + if (blank(newStatus)) newStatus = str(body.get("progressStatus")); + String progressMemo = str(body.get("memo")); + if (blank(progressMemo)) progressMemo = str(body.get("progressLog")); + boolean adminOnly = Boolean.TRUE.equals(body.get("adminOnly")) || "true".equalsIgnoreCase(String.valueOf(body.get("adminOnly"))); + + boolean statusChanged = false; + if (!blank(newStatus) && !Objects.equals(newStatus, r.getProgressStatus())) { + r.setProgressStatus(newStatus); + statusChanged = true; + if ("4.개통완료".equals(newStatus) && blank(r.getGiftStatus())) { + r.setGiftStatus("지급대기"); + } + if ("4.개통완료".equals(newStatus) && r.getOpenDate() == null) { + r.setOpenDate(LocalDate.now(ZoneId.of("Asia/Seoul"))); + } + } + + String giftStatus = str(body.get("giftStatus")); + boolean giftChanged = false; + if (!blank(giftStatus) && !Objects.equals(giftStatus, r.getGiftStatus())) { + r.setGiftStatus(giftStatus); + giftChanged = true; + appendGiftLog(r, giftStatus, str(body.get("giftProgressLog")), u); + if ("지급완료".equals(giftStatus) && "4.개통완료".equals(r.getProgressStatus())) { + r.setProgressStatus("6.정산완료"); + r.setSettleStatus("정산완료"); + statusChanged = true; + } + } else if (!blank(str(body.get("giftProgressLog")))) { + appendGiftLog(r, r.getGiftStatus(), str(body.get("giftProgressLog")), u); + } + + if (statusChanged || !blank(progressMemo)) { + appendLog(r, blank(newStatus) ? r.getProgressStatus() : newStatus, progressMemo, u, adminOnly); + } + + if (body.containsKey("processor") && !blank(str(body.get("processor")))) { + r.setProcessor(str(body.get("processor"))); + } else if (statusChanged) { + r.setProcessor(resolveMemberName(u)); + } + + writeLog(u, "RECEPTION", "접수-진행", "접수 #" + r.getId() + " [" + nullToEmpty(r.getCustomerName()) + "] 진행내역 저장"); + return ApiResponse.ok(toProgressDetail(r, u)); + } + + @DeleteMapping("/receptions/progress-log/{logId}") + @Transactional + ApiResponse deleteProgressLog(@PathVariable Long logId, @AuthenticationPrincipal JwtUser u) { + HomesReceptionProgressLog log = em.find(HomesReceptionProgressLog.class, logId); + if (log == null || !Objects.equals(log.getGroupId(), u.getGroupId())) return ApiResponse.fail("로그를 찾을 수 없습니다."); + em.remove(log); + return ApiResponse.ok(null); + } + + @DeleteMapping("/receptions/gift-progress-log/{logId}") + @Transactional + ApiResponse deleteGiftProgressLog(@PathVariable Long logId, @AuthenticationPrincipal JwtUser u) { + HomesReceptionGiftProgressLog log = em.find(HomesReceptionGiftProgressLog.class, logId); + if (log == null || !Objects.equals(log.getGroupId(), u.getGroupId())) return ApiResponse.fail("로그를 찾을 수 없습니다."); + em.remove(log); + return ApiResponse.ok(null); + } + + @PostMapping("/receptions") + @Transactional + ApiResponse create(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + HomesReception r = new HomesReception(); + r.setGroupId(u.getGroupId()); + apply(r, body); + if (r.getOrderDate() == null) r.setOrderDate(LocalDateTime.now(ZoneId.of("Asia/Seoul"))); + if (blank(r.getProgressStatus())) r.setProgressStatus("1.접수완료"); + if (blank(r.getSettleStatus())) r.setSettleStatus("정산대기"); + if (blank(r.getGiftStatus())) r.setGiftStatus("미지급"); + if (blank(r.getOrderNumber())) r.setOrderNumber(genOrderNumber()); + em.persist(r); + em.flush(); + appendLog(r, r.getProgressStatus(), "접수 등록", u); + writeLog(u, "RECEPTION", "접수-등록", "접수 [" + nullToEmpty(r.getCustomerName()) + "] 등록하였습니다."); + return ApiResponse.ok(toMap(r)); + } + + @PutMapping("/receptions/{id}") + @Transactional + ApiResponse update(@PathVariable Long id, @RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + HomesReception r = findOwned(id, u); + if (r == null) return ApiResponse.fail("접수를 찾을 수 없습니다."); + String prev = r.getProgressStatus(); + apply(r, body); + if (!Objects.equals(prev, r.getProgressStatus())) { + appendLog(r, r.getProgressStatus(), str(body.get("progressMemo")), u); + } + writeLog(u, "RECEPTION", "접수-수정", "접수 [" + nullToEmpty(r.getCustomerName()) + "] 수정하였습니다."); + return ApiResponse.ok(toMap(r)); + } + + @DeleteMapping("/receptions/{id}") + @Transactional + ApiResponse delete(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesReception r = findOwned(id, u); + if (r == null) return ApiResponse.fail("접수를 찾을 수 없습니다."); + writeLog(u, "RECEPTION", "접수-삭제", "접수 [" + nullToEmpty(r.getCustomerName()) + "] 삭제하였습니다."); + em.remove(r); + return ApiResponse.ok(null); + } + + @PostMapping("/receptions/delete") + @Transactional + ApiResponse bulkDelete(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + List ids = toIds(body.get("ids")); + int n = 0; + for (Long id : ids) { + HomesReception r = findOwned(id, u); + if (r == null) continue; + writeLog(u, "RECEPTION", "접수-삭제", "접수 [" + nullToEmpty(r.getCustomerName()) + "] 삭제하였습니다."); + em.remove(r); + n++; + } + return ApiResponse.ok(Map.of("count", n)); + } + + @PostMapping("/receptions/bulk-progress") + @Transactional + ApiResponse bulkProgress(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + List ids = toIds(body.get("ids")); + if (ids.isEmpty()) return ApiResponse.fail("리스트를 선택하세요."); + String status = str(body.get("status")); + if (blank(status)) return ApiResponse.fail("진행상황을 선택하세요."); + String memo = "[일괄처리] " + nullToEmpty(str(body.get("memo"))); + boolean adminOnly = Boolean.TRUE.equals(body.get("adminOnly")); + String processor = firstNonBlank(str(body.get("centerId")), str(body.get("processor")), str(body.get("centerName"))); + LocalDate opened = parseDate(str(body.containsKey("opended") ? body.get("opended") + : body.containsKey("opened") ? body.get("opened") : body.get("openDate"))); + LocalDate canceled = parseDate(str(body.containsKey("canceled") ? body.get("canceled") : body.get("cancelDate"))); + + List rows = new ArrayList<>(); + String baseStatus = null; + for (Long id : ids) { + HomesReception r = findOwned(id, u); + if (r == null) return ApiResponse.fail("접수 리스트가 올바르지 않습니다."); + if (baseStatus != null && !Objects.equals(baseStatus, r.getProgressStatus())) { + return ApiResponse.fail("진행상황이 다른 접수가 포함되어 있습니다."); + } + baseStatus = r.getProgressStatus(); + rows.add(r); + } + + int n = 0; + for (HomesReception r : rows) { + r.setProgressStatus(status); + if (!blank(processor)) r.setProcessor(processor); + if (opened != null) r.setOpenDate(opened); + if (canceled != null) r.setCancelDate(canceled); + if ("4.개통완료".equals(status) && blank(r.getGiftStatus())) r.setGiftStatus("지급대기"); + appendLog(r, status, memo, u, adminOnly); + n++; + } + writeLog(u, "RECEPTION", "접수-일괄진행", "접수 " + n + "건 진행상황을 [" + status + "]로 변경하였습니다."); + return ApiResponse.ok(Map.of("count", n)); + } + + @PostMapping("/receptions/bulk-gift") + @Transactional + ApiResponse bulkGift(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + List ids = toIds(body.get("ids")); + if (ids.isEmpty()) return ApiResponse.fail("리스트를 선택하세요."); + String status = str(body.get("status")); + if (blank(status)) return ApiResponse.fail("사은품진행상황을 선택하세요."); + String place = str(body.get("place")); + String giftMemo = "[일괄처리] " + nullToEmpty(str( + body.containsKey("giftProgressLog") ? body.get("giftProgressLog") : body.get("memo"))); + String deliveryCompany = str(body.get("deliveryCompany")); + String trackingNumber = str(body.get("trackingNumber")); + LocalDate requested = parseDate(str(body.containsKey("requested") ? body.get("requested") : body.get("giftRequested"))); + LocalDate sended = parseDate(str(body.containsKey("sended") ? body.get("sended") + : body.containsKey("giftSended") ? body.get("giftSended") : body.get("giftDate"))); + + List rows = new ArrayList<>(); + String basePlace = null; + for (Long id : ids) { + HomesReception r = findOwned(id, u); + if (r == null) return ApiResponse.fail("선택한 접수가 올바르지 않습니다."); + String p = r.getGiftPlace(); + if (basePlace == null) { + basePlace = p; + } else { + boolean baseSelf = "b02".equals(basePlace) || "영업점".equals(basePlace); + boolean curSelf = "b02".equals(p) || "영업점".equals(p); + if (baseSelf != curSelf) return ApiResponse.fail("사은품 지급처가 다른 접수가 포함되어 있습니다."); + } + rows.add(r); + } + + int n = 0; + for (HomesReception r : rows) { + r.setGiftStatus(status); + if (!blank(place)) r.setGiftPlace(place); + if (!blank(deliveryCompany)) r.setDeliveryCompany(deliveryCompany); + if (!blank(trackingNumber)) r.setTrackingNumber(trackingNumber); + if (requested != null) r.setGiftRequested(requested); + if (sended != null) { + r.setGiftSended(sended); + r.setGiftDate(sended); + } + appendGiftLog(r, status, giftMemo, u); + n++; + } + writeLog(u, "RECEPTION", "사은품-일괄", "접수 " + n + "건 사은품진행을 [" + status + "]로 변경하였습니다."); + return ApiResponse.ok(Map.of("count", n)); + } + + @PostMapping("/receptions/bulk-center-progress") + @Transactional + ApiResponse bulkCenterProgress(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + List ids = toIds(body.get("ids")); + if (ids.isEmpty()) return ApiResponse.fail("리스트를 선택하세요."); + + List rows = new ArrayList<>(); + for (Long id : ids) { + HomesReception r = findOwned(id, u); + if (r == null) return ApiResponse.fail("접수 리스트가 올바르지 않습니다."); + // 입력처 정산이 비어있거나 미지정인 건만 대상 + if (!blank(r.getSettleStatus()) && !"정산대기".equals(r.getSettleStatus()) + && !"미지정".equals(r.getSettleStatus())) { + return ApiResponse.fail("접수 리스트가 올바르지 않습니다."); + } + rows.add(r); + } + + int n = 0; + for (HomesReception r : rows) { + r.setSettleStatus("정산대기"); + appendLog(r, r.getProgressStatus(), "[일괄변경] 입력처 정산대기", u, false); + n++; + } + writeLog(u, "RECEPTION", "입력처-일괄", "접수 " + n + "건을 입력처 정산대기로 변경하였습니다."); + return ApiResponse.ok(Map.of("count", n)); + } + + /** 신규신청 통신사 목록 (billing_react GET /isp?onlyOn=true) */ + @GetMapping("/reception-isps") + ApiResponse isps(@AuthenticationPrincipal JwtUser u) { + List list = em.createQuery( + "select e from HomesReceptionIsp e where e.groupId=:g and (e.active is null or e.active=true) order by e.seq asc, e.id asc", + HomesReceptionIsp.class) + .setParameter("g", u.getGroupId()).getResultList(); + return ApiResponse.ok(list.stream().map(i -> { + Map m = new LinkedHashMap<>(); + m.put("id", i.getId()); + m.put("name", i.getName()); + m.put("telecom", i.getTelecom()); + m.put("seq", i.getSeq()); + m.put("isSktb", Boolean.TRUE.equals(i.getIsSktb())); + return m; + }).toList()); + } + + @GetMapping("/reception-isps/{ispId}/products") + ApiResponse ispProducts(@PathVariable Long ispId, @AuthenticationPrincipal JwtUser u) { + HomesReceptionIsp isp = findIsp(ispId, u); + if (isp == null) return ApiResponse.fail("통신사를 찾을 수 없습니다."); + List list = em.createQuery( + "select e from HomesReceptionIspProduct e where e.groupId=:g and e.ispId=:isp and (e.active is null or e.active=true) order by e.id", + HomesReceptionIspProduct.class) + .setParameter("g", u.getGroupId()).setParameter("isp", ispId).getResultList(); + return ApiResponse.ok(list.stream().map(p -> Map.of( + "id", p.getId(), + "type", nullToEmpty(p.getProductType()), + "name", nullToEmpty(p.getName()) + )).toList()); + } + + @GetMapping("/reception-isps/{ispId}/agreements") + ApiResponse ispAgreements(@PathVariable Long ispId, @AuthenticationPrincipal JwtUser u) { + HomesReceptionIsp isp = findIsp(ispId, u); + if (isp == null) return ApiResponse.fail("통신사를 찾을 수 없습니다."); + List list = em.createQuery( + "select e from HomesReceptionIspAgreement e where e.groupId=:g and e.ispId=:isp and (e.active is null or e.active=true) order by e.id", + HomesReceptionIspAgreement.class) + .setParameter("g", u.getGroupId()).setParameter("isp", ispId).getResultList(); + return ApiResponse.ok(list.stream().map(a -> Map.of( + "id", a.getId(), + "name", nullToEmpty(a.getName()) + )).toList()); + } + + /** + * 신규신청 등록 — billing_react POST /order 를 Care Homes reception 으로 매핑. + * body: { ispId, counselor, branch, customer:{type,name,phone,region,address,ssid,postIt}, payment:{method,name,...}, + * product:{productType,productId,agreementId,price,installDate,discount}, gift:{type,place,cashAmount,goods,giftDate}, memo } + */ + @PostMapping("/receptions/apply") + @Transactional + ApiResponse apply(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + Long ispId = toLong(body.get("ispId")); + HomesReceptionIsp isp = ispId == null ? null : findIsp(ispId, u); + if (isp == null) return ApiResponse.fail("통신사를 선택하세요."); + + @SuppressWarnings("unchecked") + Map customer = body.get("customer") instanceof Map m ? (Map) m : Map.of(); + @SuppressWarnings("unchecked") + Map payment = body.get("payment") instanceof Map m ? (Map) m : Map.of(); + @SuppressWarnings("unchecked") + Map product = body.get("product") instanceof Map m ? (Map) m : Map.of(); + @SuppressWarnings("unchecked") + Map gift = body.get("gift") instanceof Map m ? (Map) m : Map.of(); + + String customerName = str(customer.get("name")); + if (blank(customerName)) return ApiResponse.fail("고객명을 입력하세요."); + Long productId = toLong(product.get("productId")); + if (productId == null) return ApiResponse.fail("상품을 선택하세요."); + + HomesReceptionIspProduct prod = em.find(HomesReceptionIspProduct.class, productId); + if (prod == null || !Objects.equals(prod.getGroupId(), u.getGroupId()) || !Objects.equals(prod.getIspId(), ispId)) { + return ApiResponse.fail("상품이 올바르지 않습니다."); + } + String agreementName = null; + Long agreementId = toLong(product.get("agreementId")); + if (agreementId != null) { + HomesReceptionIspAgreement ag = em.find(HomesReceptionIspAgreement.class, agreementId); + if (ag != null && Objects.equals(ag.getGroupId(), u.getGroupId())) agreementName = ag.getName(); + } + + String productTypeLabel = switch (nullToEmpty(prod.getProductType())) { + case "e02" -> "전화"; + case "e03" -> "TV"; + case "e04" -> "렌탈"; + default -> "인터넷"; + }; + String giftType = str(gift.get("type")); + String giftCategory = switch (nullToEmpty(giftType)) { + case "a02" -> "현금"; + case "a03" -> "상품"; + case "a04" -> "현금+상품"; + case "a01" -> "없음"; + default -> blank(giftType) ? "상품권" : giftType; + }; + String giftPlaceCode = str(gift.get("place")); + String payMethod = str(payment.get("method")); + String paymentType = switch (nullToEmpty(payMethod)) { + case "card" -> "카드"; + case "paper" -> "지로"; + case "telephone" -> "전화납부"; + default -> "계좌"; + }; + + HomesReception r = new HomesReception(); + r.setGroupId(u.getGroupId()); + r.setOrderDate(LocalDateTime.now(ZoneId.of("Asia/Seoul"))); + r.setIspId(isp.getId()); + r.setIspName(isp.getName()); + r.setCarrier(isp.getTelecom()); + r.setCounselor(blank(str(body.get("counselor"))) ? resolveMemberName(u) : str(body.get("counselor"))); + r.setBranch(str(body.get("branch"))); + r.setBranchGroup(str(body.get("branchGroup"))); + r.setCustomerType(blank(str(customer.get("type"))) ? "public" : str(customer.get("type"))); + r.setCustomerName(customerName); + r.setPhone(str(customer.get("phone"))); + r.setTel(str(customer.get("tel"))); + r.setEmail(str(customer.get("email"))); + r.setZipcode(str(customer.get("zipcode"))); + r.setAddress(str(customer.get("address"))); + r.setAddressDetail(str(customer.get("addressDetail"))); + r.setRegion(str(customer.get("region"))); + r.setIdentifyNumber(str(customer.get("ssid"))); + r.setPostIt(str(customer.get("postIt"))); + r.setPostAdd(str(customer.get("postAdd"))); + r.setProductType(prod.getProductType()); + r.setProductCategory(productTypeLabel); + r.setProductName(prod.getName()); + r.setAgreementName(agreementName); + r.setAmount(toDecimal(product.get("price"))); + r.setOpenDate(parseDate(str(product.get("installDate")))); + r.setGiftCategory(giftCategory); + r.setGiftPlace(giftPlaceCode); + r.setGiftDate(parseDate(str(gift.get("giftDate")))); + r.setGiftStatus(blank(giftType) || "a01".equals(giftType) ? "미지급" : "지급대기"); + r.setGiftCashAmount(toDecimal(gift.get("cashAmount"))); + r.setGiftGoods(str(gift.get("goods"))); + r.setGiftGoodsPrice(toDecimal(gift.get("goodsPrice"))); + r.setGiftBank(str(gift.get("accountBank"))); + r.setGiftAccountNumber(str(gift.get("accountNumber"))); + r.setGiftAccountName(str(gift.get("accountName"))); + r.setGiftSsid(str(gift.get("ssid"))); + r.setPaymentMethod(blank(payMethod) ? "transfer" : payMethod); + r.setPaymentType(paymentType); + r.setPaymentName(str(payment.get("name"))); + r.setPaymentSsid(str(payment.get("ssid"))); + r.setPaymentTel(str(payment.get("tel"))); + r.setPaymentBank(str(payment.get("bank"))); + r.setPaymentAccountNumber(str(payment.get("accountNumber"))); + r.setPaymentCardCompany(str(payment.get("cardCompany"))); + r.setPaymentCardYear(str(payment.get("cardYear"))); + r.setPaymentCardMonth(str(payment.get("cardMonth"))); + r.setPaymentCardNumber(str(payment.get("cardNumber"))); + r.setProgressStatus("1.접수완료"); + r.setSettleStatus("정산대기"); + r.setOrderNumber(genOrderNumber()); + r.setProcessor(r.getCounselor()); + r.setDetailProgress("신규신청"); + r.setMemo(str(body.get("memo"))); + em.persist(r); + em.flush(); + appendLog(r, r.getProgressStatus(), "신규신청 [" + isp.getName() + "]", u, false); + writeLog(u, "RECEPTION", "신규신청", "신규신청 [" + isp.getName() + "] " + customerName + " 등록하였습니다."); + return ApiResponse.ok(toProgressDetail(r, u)); + } + + // ---- 첨부파일 (일반 file1~5) ---- + @PostMapping("/receptions/{id}/file/{slot}") + @Transactional + ApiResponse uploadFile(@PathVariable Long id, @PathVariable int slot, + @RequestParam("file") MultipartFile file, + @AuthenticationPrincipal JwtUser u) { + return uploadAttachment(id, slot, false, file, u); + } + + @GetMapping("/receptions/{id}/file/{slot}") + ResponseEntity downloadFile(@PathVariable Long id, @PathVariable int slot, + @AuthenticationPrincipal JwtUser u) { + return downloadAttachment(id, slot, false, u); + } + + @DeleteMapping("/receptions/{id}/file/{slot}") + @Transactional + ApiResponse deleteFile(@PathVariable Long id, @PathVariable int slot, + @AuthenticationPrincipal JwtUser u) { + return deleteAttachment(id, slot, false, u); + } + + // ---- 관리자 첨부파일 (admin_file1~3) ---- + @PostMapping("/receptions/{id}/admin-file/{slot}") + @Transactional + ApiResponse uploadAdminFile(@PathVariable Long id, @PathVariable int slot, + @RequestParam("file") MultipartFile file, + @AuthenticationPrincipal JwtUser u) { + return uploadAttachment(id, slot, true, file, u); + } + + @GetMapping("/receptions/{id}/admin-file/{slot}") + ResponseEntity downloadAdminFile(@PathVariable Long id, @PathVariable int slot, + @AuthenticationPrincipal JwtUser u) { + return downloadAttachment(id, slot, true, u); + } + + @DeleteMapping("/receptions/{id}/admin-file/{slot}") + @Transactional + ApiResponse deleteAdminFile(@PathVariable Long id, @PathVariable int slot, + @AuthenticationPrincipal JwtUser u) { + return deleteAttachment(id, slot, true, u); + } + + private ApiResponse uploadAttachment(Long id, int slot, boolean admin, MultipartFile file, JwtUser u) { + HomesReception r = findOwned(id, u); + int max = admin ? 3 : 5; + if (slot < 1 || slot > max) return ApiResponse.fail("첨부 슬롯이 올바르지 않습니다."); + if (file == null || file.isEmpty()) return ApiResponse.fail("업로드할 파일을 선택하세요."); + String old = getFile(r, slot, admin); + if (old != null && !old.isBlank()) fileStorage.delete(old); + String path = fileStorage.store("reception/" + id, file); + setFile(r, slot, admin, path); + writeLog(u, "RECEPTION", admin ? "관리자첨부" : "첨부", + "접수 #" + id + " " + (admin ? "관리자첨부" : "첨부") + slot + " 업로드"); + Map data = new LinkedHashMap<>(); + data.put("id", r.getId()); + data.put("slot", slot); + data.put("admin", admin); + data.put("path", path); + data.put("name", FileStorageService.displayName(path)); + return ApiResponse.ok(data); + } + + private ResponseEntity downloadAttachment(Long id, int slot, boolean admin, JwtUser u) { + HomesReception r = findOwned(id, u); + String rel = getFile(r, slot, admin); + if (rel == null || rel.isBlank()) { + return ResponseEntity.notFound().build(); + } + Resource resource = fileStorage.loadAsResource(rel); + String filename = URLEncoder.encode(FileStorageService.displayName(rel), StandardCharsets.UTF_8) + .replace("+", "%20"); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + filename) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(resource); + } + + private ApiResponse deleteAttachment(Long id, int slot, boolean admin, JwtUser u) { + HomesReception r = findOwned(id, u); + int max = admin ? 3 : 5; + if (slot < 1 || slot > max) return ApiResponse.fail("첨부 슬롯이 올바르지 않습니다."); + String old = getFile(r, slot, admin); + if (old != null && !old.isBlank()) fileStorage.delete(old); + setFile(r, slot, admin, null); + writeLog(u, "RECEPTION", admin ? "관리자첨부삭제" : "첨부삭제", + "접수 #" + id + " " + (admin ? "관리자첨부" : "첨부") + slot + " 삭제"); + return ApiResponse.ok(Map.of("id", id, "slot", slot, "admin", admin)); + } + + private static String getFile(HomesReception r, int slot, boolean admin) { + if (admin) { + return switch (slot) { + case 1 -> r.getAdminFile1(); + case 2 -> r.getAdminFile2(); + case 3 -> r.getAdminFile3(); + default -> null; + }; + } + return switch (slot) { + case 1 -> r.getFile1(); + case 2 -> r.getFile2(); + case 3 -> r.getFile3(); + case 4 -> r.getFile4(); + case 5 -> r.getFile5(); + default -> null; + }; + } + + private static void setFile(HomesReception r, int slot, boolean admin, String path) { + if (admin) { + switch (slot) { + case 1 -> r.setAdminFile1(path); + case 2 -> r.setAdminFile2(path); + case 3 -> r.setAdminFile3(path); + default -> { + } + } + return; + } + switch (slot) { + case 1 -> r.setFile1(path); + case 2 -> r.setFile2(path); + case 3 -> r.setFile3(path); + case 4 -> r.setFile4(path); + case 5 -> r.setFile5(path); + default -> { + } + } + } + + private HomesReceptionIsp findIsp(Long id, JwtUser u) { + HomesReceptionIsp isp = em.find(HomesReceptionIsp.class, id); + if (isp == null || !Objects.equals(isp.getGroupId(), u.getGroupId())) return null; + return isp; + } + + private static Long toLong(Object v) { + if (v == null || String.valueOf(v).isBlank()) return null; + try { return Long.valueOf(String.valueOf(v)); } catch (Exception e) { return null; } + } + + private List filterReceptions(JwtUser u, Map f) { + List all = em.createQuery( + "select e from HomesReception e where e.groupId=:g order by e.orderDate desc, e.id desc", + HomesReception.class) + .setParameter("g", u.getGroupId()).getResultList(); + + LocalDate from = parseDate(first(f, "dateFrom", "orderDateStart")); + LocalDate to = parseDate(first(f, "dateTo", "orderDateEnd")); + LocalDate openFrom = parseDate(f.get("openDateFrom")); + LocalDate openTo = parseDate(f.get("openDateTo")); + LocalDate opened = parseDate(first(f, "opended", "opened", "openDate")); + LocalDate giftSended = parseDate(first(f, "giftSended", "giftDateExact")); + String progress = blankToNull(first(f, "progressStatus", "tab")); + if ("ALL".equalsIgnoreCase(progress)) progress = null; + String settle = blankToNull(first(f, "settleStatus", "centerStatus")); + String giftStatus = blankToNull(f.get("giftStatus")); + String carrier = blankToNull(f.get("carrier")); + String branch = blankToNull(f.get("branch")); + String counselor = blankToNull(f.get("counselor")); + String processor = blankToNull(f.get("processor")); + String region = blankToNull(first(f, "region", "customerSido")); + String giftCategory = blankToNull(first(f, "giftCategory", "giftType")); + String productCategory = blankToNull(first(f, "productCategory", "productType")); + String customerName = blankToNull(f.get("customerName")); + String phone = blankToNull(f.get("customerPhone") != null ? f.get("customerPhone") : f.get("phone")); + String orderNumber = blankToNull(f.get("orderNumber")); + String q = blankToNull(first(f, "q", "keyword")); + String postIt = blankToNull(f.get("postIt")); + String agreementName = blankToNull(f.get("agreementName")); + String customerSsid = blankToNull(first(f, "customerSsid", "identifyNumber")); + String customerEmail = blankToNull(first(f, "customerEmail", "email")); + String productName = blankToNull(f.get("productName")); + String ispName = blankToNull(f.get("ispName")); + String contractNumber = blankToNull(f.get("contractNumber")); + String giftPlace = blankToNull(f.get("giftPlace")); + String branchEmployee = blankToNull(first(f, "branchEmployee")); + String branchName = blankToNull(f.get("branchName")); + String sorting = blankToNull(f.get("sorting")); + + String progressF = progress; + List result = all.stream().filter(r -> { + LocalDate od = r.getOrderDate() == null ? null : r.getOrderDate().toLocalDate(); + if (from != null && (od == null || od.isBefore(from))) return false; + if (to != null && (od == null || od.isAfter(to))) return false; + if (openFrom != null && (r.getOpenDate() == null || r.getOpenDate().isBefore(openFrom))) return false; + if (openTo != null && (r.getOpenDate() == null || r.getOpenDate().isAfter(openTo))) return false; + if (opened != null && (r.getOpenDate() == null || !r.getOpenDate().equals(opened))) return false; + if (giftSended != null) { + LocalDate gd = r.getGiftSended() != null ? r.getGiftSended() : r.getGiftDate(); + if (gd == null || !gd.equals(giftSended)) return false; + } + if (progressF != null && !progressF.equals(r.getProgressStatus())) return false; + if (settle != null && !settle.equals(r.getSettleStatus())) return false; + if (giftStatus != null && !giftStatus.equals(r.getGiftStatus())) return false; + if (carrier != null && !carrier.equals(r.getCarrier()) && !carrier.equals(r.getIspName())) return false; + if (ispName != null) { + String hay = (nullToEmpty(r.getIspName()) + " " + nullToEmpty(r.getCarrier())).toLowerCase(); + if (!hay.contains(ispName.toLowerCase())) return false; + } + if (branch != null && !branch.equals(r.getBranch())) return false; + if (branchName != null && !nullToEmpty(r.getBranch()).contains(branchName)) return false; + if (counselor != null && !counselor.equals(r.getCounselor())) return false; + if (branchEmployee != null && !nullToEmpty(r.getCounselor()).contains(branchEmployee)) return false; + if (processor != null && !processor.equals(r.getProcessor())) return false; + if (region != null && !region.equals(r.getRegion())) return false; + if (giftCategory != null && !nullToEmpty(r.getGiftCategory()).contains(giftCategory)) return false; + if (productCategory != null + && !productCategory.equals(r.getProductCategory()) + && !productCategory.equals(r.getProductType())) return false; + if (giftPlace != null && !giftPlace.equals(r.getGiftPlace())) return false; + if (customerName != null && !nullToEmpty(r.getCustomerName()).contains(customerName)) return false; + if (phone != null) { + String digits = nullToEmpty(r.getPhone()).replaceAll("\\D", ""); + if (!digits.contains(phone.replaceAll("\\D", "")) && !nullToEmpty(r.getPhone()).contains(phone)) return false; + } + if (orderNumber != null && !nullToEmpty(r.getOrderNumber()).contains(orderNumber)) return false; + if (postIt != null && !nullToEmpty(r.getPostIt()).contains(postIt) + && !nullToEmpty(r.getPostAdd()).contains(postIt)) return false; + if (agreementName != null && !nullToEmpty(r.getAgreementName()).contains(agreementName)) return false; + if (customerSsid != null && !nullToEmpty(r.getIdentifyNumber()).contains(customerSsid)) return false; + if (customerEmail != null && !nullToEmpty(r.getEmail()).contains(customerEmail)) return false; + if (productName != null && !nullToEmpty(r.getProductName()).contains(productName)) return false; + if (contractNumber != null && !nullToEmpty(r.getContractNumber()).contains(contractNumber)) return false; + if (q != null) { + String hay = (nullToEmpty(r.getCustomerName()) + " " + nullToEmpty(r.getPhone()) + " " + + nullToEmpty(r.getProductName()) + " " + nullToEmpty(r.getOrderNumber()) + " " + + nullToEmpty(r.getCounselor()) + " " + nullToEmpty(r.getBranch()) + " " + + nullToEmpty(r.getMemo()) + " " + nullToEmpty(r.getPostIt()) + " " + + nullToEmpty(r.getIdentifyNumber())).toLowerCase(); + if (!hay.contains(q.toLowerCase())) return false; + } + return true; + }).collect(Collectors.toCollection(ArrayList::new)); + + if ("opended".equals(sorting) || "opened".equals(sorting)) { + result.sort(Comparator + .comparing(HomesReception::getOpenDate, Comparator.nullsLast(Comparator.naturalOrder())) + .reversed()); + } + return result; + } + + private void apply(HomesReception r, Map d) { + if (d.containsKey("orderDate")) r.setOrderDate(parseDateTime(str(d.get("orderDate")))); + if (d.containsKey("counselor")) r.setCounselor(str(d.get("counselor"))); + if (d.containsKey("branch")) r.setBranch(str(d.get("branch"))); + if (d.containsKey("branchGroup")) r.setBranchGroup(str(d.get("branchGroup"))); + if (d.containsKey("carrier")) r.setCarrier(str(d.get("carrier"))); + if (d.containsKey("giftCategory")) r.setGiftCategory(str(d.get("giftCategory"))); + if (d.containsKey("productCategory")) r.setProductCategory(str(d.get("productCategory"))); + if (d.containsKey("productName")) r.setProductName(str(d.get("productName"))); + if (d.containsKey("customerName")) r.setCustomerName(str(d.get("customerName"))); + if (d.containsKey("phone")) r.setPhone(str(d.get("phone"))); + if (d.containsKey("region")) r.setRegion(str(d.get("region"))); + if (d.containsKey("progressStatus")) r.setProgressStatus(str(d.get("progressStatus"))); + if (d.containsKey("orderNumber")) r.setOrderNumber(str(d.get("orderNumber"))); + if (d.containsKey("ispId")) r.setIspId(toLong(d.get("ispId"))); + if (d.containsKey("ispName")) r.setIspName(str(d.get("ispName"))); + if (d.containsKey("inboundInfo")) r.setInboundInfo(str(d.get("inboundInfo"))); + if (d.containsKey("processor")) r.setProcessor(str(d.get("processor"))); + if (d.containsKey("detailProgress")) r.setDetailProgress(str(d.get("detailProgress"))); + if (d.containsKey("paymentType")) r.setPaymentType(str(d.get("paymentType"))); + if (d.containsKey("amount")) r.setAmount(toDecimal(d.get("amount"))); + if (d.containsKey("settleStatus")) r.setSettleStatus(str(d.get("settleStatus"))); + if (d.containsKey("openDate")) r.setOpenDate(parseDate(str(d.get("openDate")))); + if (d.containsKey("giftDate")) r.setGiftDate(parseDate(str(d.get("giftDate")))); + if (d.containsKey("giftStatus")) r.setGiftStatus(str(d.get("giftStatus"))); + if (d.containsKey("giftPlace")) r.setGiftPlace(str(d.get("giftPlace"))); + if (d.containsKey("agreementName")) r.setAgreementName(str(d.get("agreementName"))); + if (d.containsKey("contractNumber")) r.setContractNumber(str(d.get("contractNumber"))); + if (d.containsKey("identifyNumber")) r.setIdentifyNumber(str(d.get("identifyNumber"))); + if (d.containsKey("postIt")) r.setPostIt(str(d.get("postIt"))); + if (d.containsKey("postAdd")) r.setPostAdd(str(d.get("postAdd"))); + if (d.containsKey("memo")) r.setMemo(str(d.get("memo"))); + if (d.containsKey("adminMemo")) r.setAdminMemo(str(d.get("adminMemo"))); + if (d.containsKey("customerType")) r.setCustomerType(str(d.get("customerType"))); + if (d.containsKey("tel")) r.setTel(str(d.get("tel"))); + if (d.containsKey("email")) r.setEmail(str(d.get("email"))); + if (d.containsKey("zipcode")) r.setZipcode(str(d.get("zipcode"))); + if (d.containsKey("address")) r.setAddress(str(d.get("address"))); + if (d.containsKey("addressDetail")) r.setAddressDetail(str(d.get("addressDetail"))); + if (d.containsKey("paymentMethod")) r.setPaymentMethod(str(d.get("paymentMethod"))); + if (d.containsKey("productType")) r.setProductType(str(d.get("productType"))); + if (d.containsKey("installSchedule")) r.setInstallSchedule(str(d.get("installSchedule"))); + if (d.containsKey("customerLoginId")) r.setCustomerLoginId(str(d.get("customerLoginId"))); + if (d.containsKey("newTel")) r.setNewTel(str(d.get("newTel"))); + if (d.containsKey("cancelDate")) r.setCancelDate(parseDate(str(d.get("cancelDate")))); + if (d.containsKey("branchPhone")) r.setBranchPhone(str(d.get("branchPhone"))); + } + + private Map toProgressDetail(HomesReception r, JwtUser u) { + Map data = new LinkedHashMap<>(); + Map reception = toMap(r); + data.put("reception", reception); + data.put("order", reception); // billing_react 호환 별칭 + data.put("id", r.getId()); + + Map customer = new LinkedHashMap<>(); + customer.put("type", blank(r.getCustomerType()) ? "public" : r.getCustomerType()); + customer.put("name", r.getCustomerName()); + customer.put("postIt", r.getPostIt()); + customer.put("postAdd", r.getPostAdd()); + customer.put("phone", r.getPhone()); + customer.put("tel", r.getTel()); + customer.put("email", r.getEmail()); + customer.put("zipcode", r.getZipcode()); + customer.put("address", r.getAddress()); + customer.put("addressDetail", r.getAddressDetail()); + customer.put("sido", r.getRegion()); + customer.put("ssid", r.getIdentifyNumber()); + data.put("customer", customer); + data.put("customerPublic", Map.of("ssid", nullToEmpty(r.getIdentifyNumber()))); + + Map payment = new LinkedHashMap<>(); + payment.put("method", blank(r.getPaymentMethod()) ? paymentMethodFromType(r.getPaymentType()) : r.getPaymentMethod()); + payment.put("name", r.getPaymentName()); + payment.put("ssid", r.getPaymentSsid()); + payment.put("tel", r.getPaymentTel()); + payment.put("bank", r.getPaymentBank()); + payment.put("accountNumber", r.getPaymentAccountNumber()); + payment.put("cardCompany", r.getPaymentCardCompany()); + payment.put("cardYear", r.getPaymentCardYear()); + payment.put("cardMonth", r.getPaymentCardMonth()); + payment.put("cardNumber", r.getPaymentCardNumber()); + data.put("payment", payment); + if ("card".equals(payment.get("method"))) data.put("paymentCard", payment); + else if ("transfer".equals(payment.get("method"))) data.put("paymentTransfer", payment); + + Map product = new LinkedHashMap<>(); + product.put("productType", blank(r.getProductType()) ? productTypeFromCategory(r.getProductCategory()) : r.getProductType()); + product.put("name", r.getProductName()); + product.put("productName", r.getProductName()); + product.put("price", r.getAmount() == null ? 0 : r.getAmount()); + data.put("product", product); + data.put("productName", r.getProductName()); + data.put("agreementName", r.getAgreementName()); + data.put("ispName", blank(r.getIspName()) ? r.getCarrier() : r.getIspName()); + data.put("eventNames", List.of()); + + Map gift = new LinkedHashMap<>(); + gift.put("status", r.getGiftStatus()); + gift.put("place", r.getGiftPlace()); + gift.put("giftDate", r.getGiftDate() == null ? null : r.getGiftDate().toString()); + gift.put("requested", r.getGiftRequested() == null ? null : r.getGiftRequested().toString()); + gift.put("sended", r.getGiftSended() == null ? null : r.getGiftSended().toString()); + gift.put("accountBank", r.getGiftBank()); + gift.put("accountNumber", r.getGiftAccountNumber()); + gift.put("accountName", r.getGiftAccountName()); + gift.put("ssid", r.getGiftSsid()); + gift.put("goods", r.getGiftGoods()); + gift.put("cashAmount", r.getGiftCashAmount()); + gift.put("goodsPrice", r.getGiftGoodsPrice()); + gift.put("category", r.getGiftCategory()); + data.put("gift", gift); + + Map etc = new LinkedHashMap<>(); + etc.put("memo", r.getMemo()); + etc.put("adminMemo", r.getAdminMemo()); + etc.put("file1", r.getFile1()); + etc.put("file2", r.getFile2()); + etc.put("file3", r.getFile3()); + etc.put("file4", r.getFile4()); + etc.put("file5", r.getFile5()); + etc.put("adminFile1", r.getAdminFile1()); + etc.put("adminFile2", r.getAdminFile2()); + etc.put("adminFile3", r.getAdminFile3()); + data.put("etc", etc); + + data.put("employeeName", r.getCounselor()); + data.put("branchOfficeName", blank(r.getBranch()) ? "본지점" : r.getBranch()); + data.put("branchPhone", blank(r.getBranchPhone()) ? "" : r.getBranchPhone()); + data.put("branchTel", ""); + data.put("progressLogs", progressLogs(r.getId(), u.getGroupId())); + data.put("giftProgressLogs", giftProgressLogs(r.getId(), u.getGroupId())); + data.put("logs", data.get("progressLogs")); + data.put("progressStatuses", PROGRESS_STATUSES); + data.put("giftStatuses", GIFT_STATUSES); + data.put("settleStatuses", SETTLE_STATUSES); + Map statusOptions = new LinkedHashMap<>(); + for (String s : PROGRESS_STATUSES) statusOptions.put(s, s); + data.put("statusOptions", statusOptions); + Map giftStatusOptions = new LinkedHashMap<>(); + for (String s : GIFT_STATUSES) giftStatusOptions.put(s, s); + data.put("giftStatusOptions", giftStatusOptions); + // flat alias for list edit form compatibility + data.putAll(reception); + return data; + } + + private static String paymentMethodFromType(String paymentType) { + if ("카드".equals(paymentType)) return "card"; + if ("지로".equals(paymentType)) return "paper"; + if ("전화납부".equals(paymentType)) return "telephone"; + return "transfer"; + } + + private static String productTypeFromCategory(String cat) { + if ("전화".equals(cat)) return "e02"; + if ("TV".equals(cat)) return "e03"; + if ("렌탈".equals(cat)) return "e04"; + return "e01"; + } + + private Map toMap(HomesReception r) { + Map m = new LinkedHashMap<>(); + m.put("id", r.getId()); + m.put("orderDate", fmtDt(r.getOrderDate())); + m.put("today", r.getOrderDate() != null + && r.getOrderDate().toLocalDate().equals(LocalDate.now(ZoneId.of("Asia/Seoul")))); + m.put("counselor", r.getCounselor()); + m.put("branch", r.getBranch()); + m.put("branchGroup", r.getBranchGroup()); + m.put("branchPhone", r.getBranchPhone()); + m.put("carrier", r.getCarrier()); + m.put("giftCategory", r.getGiftCategory()); + m.put("productCategory", r.getProductCategory()); + m.put("productType", r.getProductType()); + m.put("productName", r.getProductName()); + m.put("customerType", r.getCustomerType()); + m.put("customerName", r.getCustomerName()); + m.put("phone", r.getPhone()); + m.put("tel", r.getTel()); + m.put("email", r.getEmail()); + m.put("zipcode", r.getZipcode()); + m.put("address", r.getAddress()); + m.put("addressDetail", r.getAddressDetail()); + m.put("region", r.getRegion()); + m.put("progressStatus", r.getProgressStatus()); + m.put("orderNumber", r.getOrderNumber()); + m.put("ispId", r.getIspId()); + m.put("ispName", r.getIspName()); + m.put("inboundInfo", r.getInboundInfo()); + m.put("processor", r.getProcessor()); + m.put("detailProgress", r.getDetailProgress()); + m.put("paymentType", r.getPaymentType()); + m.put("paymentMethod", r.getPaymentMethod()); + m.put("amount", r.getAmount()); + m.put("settleStatus", r.getSettleStatus()); + m.put("openDate", r.getOpenDate() == null ? null : r.getOpenDate().toString()); + m.put("cancelDate", r.getCancelDate() == null ? null : r.getCancelDate().toString()); + m.put("giftDate", r.getGiftDate() == null ? null : r.getGiftDate().toString()); + m.put("giftStatus", r.getGiftStatus()); + m.put("giftPlace", r.getGiftPlace()); + m.put("giftRequested", r.getGiftRequested() == null ? null : r.getGiftRequested().toString()); + m.put("giftSended", r.getGiftSended() == null ? null : r.getGiftSended().toString()); + m.put("agreementName", r.getAgreementName()); + m.put("contractNumber", r.getContractNumber()); + m.put("installSchedule", r.getInstallSchedule()); + m.put("customerLoginId", r.getCustomerLoginId()); + m.put("newTel", r.getNewTel()); + m.put("multiLine", Boolean.TRUE.equals(r.getMultiLine())); + m.put("addPayer", Boolean.TRUE.equals(r.getAddPayer())); + m.put("record", Boolean.TRUE.equals(r.getRecordFlag())); + m.put("identifyNumber", r.getIdentifyNumber()); + m.put("postIt", r.getPostIt()); + m.put("postAdd", r.getPostAdd()); + m.put("memo", r.getMemo()); + m.put("adminMemo", r.getAdminMemo()); + // billing_react OrderListRow 호환 별칭 + m.put("employeeName", r.getCounselor()); + m.put("sido", r.getRegion()); + m.put("opened", r.getOpenDate() == null ? null : r.getOpenDate().toString()); + m.put("canceled", r.getCancelDate() == null ? null : r.getCancelDate().toString()); + m.put("status", r.getProgressStatus()); + m.put("giftType", r.getGiftCategory()); + m.put("giftSended", r.getGiftSended() == null + ? (r.getGiftDate() == null ? null : r.getGiftDate().toString()) + : r.getGiftSended().toString()); + m.put("branchOfficeName", r.getBranch()); + m.put("branchGroupName", r.getBranchGroup()); + if (blank(r.getIspName())) m.put("ispName", r.getCarrier()); + BigDecimal giftAmt = BigDecimal.ZERO; + if (r.getGiftCashAmount() != null) giftAmt = giftAmt.add(r.getGiftCashAmount()); + if (r.getGiftGoodsPrice() != null) giftAmt = giftAmt.add(r.getGiftGoodsPrice()); + if (giftAmt.signum() == 0 && r.getAmount() != null + && r.getGiftStatus() != null && !"미지급".equals(r.getGiftStatus()) && !"없음".equals(r.getGiftCategory())) { + giftAmt = r.getAmount(); + } + m.put("giftAmount", giftAmt.signum() == 0 ? null : giftAmt); + m.put("centerName", blank(r.getProcessor()) ? "본사" : r.getProcessor()); + m.put("centerStatus", r.getSettleStatus()); + return m; + } + + private List> progressLogs(Long receptionId, String groupId) { + return em.createQuery( + "select l from HomesReceptionProgressLog l where l.groupId=:g and l.receptionId=:rid order by l.loggedAt desc, l.id desc", + HomesReceptionProgressLog.class) + .setParameter("g", groupId).setParameter("rid", receptionId) + .getResultList().stream().map(l -> { + Map m = new LinkedHashMap<>(); + m.put("id", l.getId()); + m.put("status", l.getStatus()); + m.put("memo", l.getMemo()); + m.put("authorName", l.getAuthorName()); + m.put("writer", l.getAuthorName()); + m.put("loggedAt", fmtDt(l.getLoggedAt())); + m.put("createdAt", fmtDt(l.getLoggedAt())); + m.put("adminOnly", Boolean.TRUE.equals(l.getAdminOnly())); + return m; + }).toList(); + } + + private List> giftProgressLogs(Long receptionId, String groupId) { + return em.createQuery( + "select l from HomesReceptionGiftProgressLog l where l.groupId=:g and l.receptionId=:rid order by l.loggedAt desc, l.id desc", + HomesReceptionGiftProgressLog.class) + .setParameter("g", groupId).setParameter("rid", receptionId) + .getResultList().stream().map(l -> { + Map m = new LinkedHashMap<>(); + m.put("id", l.getId()); + m.put("status", l.getStatus()); + m.put("memo", l.getMemo()); + m.put("authorName", l.getAuthorName()); + m.put("writer", l.getAuthorName()); + m.put("loggedAt", fmtDt(l.getLoggedAt())); + m.put("createdAt", fmtDt(l.getLoggedAt())); + return m; + }).toList(); + } + + private void appendLog(HomesReception r, String status, String memo, JwtUser u) { + appendLog(r, status, memo, u, false); + } + + private void appendLog(HomesReception r, String status, String memo, JwtUser u, boolean adminOnly) { + HomesReceptionProgressLog log = new HomesReceptionProgressLog(); + log.setGroupId(u.getGroupId()); + log.setReceptionId(r.getId()); + log.setStatus(status); + log.setMemo(memo); + log.setAuthorName(resolveMemberName(u)); + log.setLoggedAt(LocalDateTime.now(ZoneId.of("Asia/Seoul"))); + log.setAdminOnly(adminOnly); + em.persist(log); + } + + private void appendGiftLog(HomesReception r, String status, String memo, JwtUser u) { + HomesReceptionGiftProgressLog log = new HomesReceptionGiftProgressLog(); + log.setGroupId(u.getGroupId()); + log.setReceptionId(r.getId()); + log.setStatus(status); + log.setMemo(memo); + log.setAuthorName(resolveMemberName(u)); + log.setLoggedAt(LocalDateTime.now(ZoneId.of("Asia/Seoul"))); + em.persist(log); + } + + private HomesReception findOwned(Long id, JwtUser u) { + HomesReception r = em.find(HomesReception.class, id); + if (r == null || !Objects.equals(r.getGroupId(), u.getGroupId())) return null; + return r; + } + + private void writeLog(JwtUser u, String domain, String action, String content) { + HomesLog log = new HomesLog(); + log.setGroupId(u.getGroupId()); + log.setDomain(domain); + log.setAction(action); + log.setContent(content); + String staff = resolveMemberName(u); + log.setStaff(staff == null || staff.isBlank() ? u.getUserid() : staff); + log.setProcessedAt(LocalDateTime.now()); + em.persist(log); + } + + private String resolveMemberName(JwtUser u) { + List rows = em.createQuery("select m from Member m where m.groupId=:g and m.userid=:uid", Member.class) + .setParameter("g", u.getGroupId()).setParameter("uid", u.getUserid()).setMaxResults(1).getResultList(); + return rows.isEmpty() ? u.getUserid() : rows.getFirst().getName(); + } + + private static List distinct(List list, + java.util.function.Function getter, + List fallback) { + List values = list.stream().map(getter).filter(s -> s != null && !s.isBlank()) + .distinct().sorted().collect(Collectors.toCollection(ArrayList::new)); + if (values.isEmpty() && fallback != null) return fallback; + return values; + } + + private static String genOrderNumber() { + return "RCP-" + LocalDateTime.now(ZoneId.of("Asia/Seoul")) + .format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + + "-" + (1000 + new Random().nextInt(9000)); + } + + private static List toIds(Object raw) { + if (!(raw instanceof List list)) return List.of(); + List ids = new ArrayList<>(); + for (Object o : list) { + if (o == null) continue; + try { ids.add(Long.valueOf(String.valueOf(o))); } catch (Exception ignored) { } + } + return ids; + } + + private static BigDecimal toDecimal(Object v) { + if (v == null || String.valueOf(v).isBlank()) return null; + try { return new BigDecimal(String.valueOf(v).replace(",", "")); } + catch (Exception e) { return null; } + } + + private static LocalDate parseDate(String s) { + if (s == null || s.isBlank()) return null; + try { return LocalDate.parse(s.substring(0, Math.min(10, s.length()))); } + catch (Exception e) { return null; } + } + + private static LocalDateTime parseDateTime(String s) { + if (s == null || s.isBlank()) return null; + try { + if (s.length() <= 10) return LocalDate.parse(s).atStartOfDay(); + return LocalDateTime.parse(s.replace(' ', 'T').substring(0, Math.min(19, s.length()))); + } catch (Exception e) { + LocalDate d = parseDate(s); + return d == null ? null : d.atStartOfDay(); + } + } + + private static String fmtDt(LocalDateTime dt) { + if (dt == null) return null; + return dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + } + + private static String first(Map f, String... keys) { + for (String k : keys) { + String v = f.get(k); + if (v != null && !v.isBlank()) return v; + } + return null; + } + + private static String blankToNull(String s) { + return s == null || s.isBlank() ? null : s.trim(); + } + + private static boolean blank(String s) { + return s == null || s.isBlank(); + } + + private static String nullToEmpty(String s) { + return s == null ? "" : s; + } + + private static String str(Object v) { + return v == null ? null : String.valueOf(v).trim(); + } + + private static String firstNonBlank(String... values) { + if (values == null) return null; + for (String v : values) { + if (v != null && !v.isBlank()) return v.trim(); + } + return null; + } + + private static String csvCell(Object value) { + if (value == null) return ""; + String s = String.valueOf(value).replace("\"", "\"\""); + if (s.contains(",") || s.contains("\"") || s.contains("\n")) return "\"" + s + "\""; + return s; + } +} diff --git a/backend/src/main/java/com/bizcare/agent/DataInitializer.java b/backend/src/main/java/com/bizcare/agent/DataInitializer.java new file mode 100644 index 0000000..972b036 --- /dev/null +++ b/backend/src/main/java/com/bizcare/agent/DataInitializer.java @@ -0,0 +1,2965 @@ +package com.bizcare.agent; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +class DataInitializer implements CommandLineRunner { + private final EntityManager em; + private final PasswordEncoder encoder; + + @Override + @Transactional + public void run(String... args) { + if (em.createQuery("select m from Member m where m.userid='demo'", Member.class).getResultList().isEmpty()) { + seedAll(); + } + seedUsimLedgersIfEmpty(); + seedStockHistoryIfEmpty(); + seedIndocsEnrich(); + seedReturnsEnrich(); + seedBalancesIfEmpty(); + seedOpenAdjustFields(); + seedUnitCostsIfEmpty(); + seedHomeSalesIfEmpty(); + seedCareHomesIfEmpty(); + seedHomeIndocsIfEmpty(); + seedOpenExchangesIfEmpty(); + seedOpenNameChangesIfEmpty(); + seedOpenWithdrawalsIfEmpty(); + seedOpenSettleHistoriesIfEmpty(); + seedOpenDeleteHistoriesIfEmpty(); + seedBbsNoticeBoardIfEmpty(); + seedBbsPolicyBoardIfEmpty(); + seedBbsPdsBoardIfEmpty(); + seedBoardGroupsIfEmpty(); + seedScansEnrich(); + seedSettlementDocsIfEmpty(); + seedOutcompanyAccountsIfEmpty(); + seedAgentShopSettingIfEmpty(); + seedOpenStatsDemo(); + seedFareboxSamplesIfEmpty(); + seedFareboxPaymentsIfEmpty(); + seedSmsHistoryIfEmpty(); + seedSmsAddressIfEmpty(); + seedStaffMembersIfEmpty(); + seedHomesMembersEnrich(); + seedIncompaniesIfEmpty(); + seedOutcompaniesEnrich(); + seedDeliveriesIfEmpty(); + seedPreferenceIfEmpty(); + seedCsNoticesIfEmpty(); + seedCsInquiriesIfEmpty(); + seedInternalBoardIfEmpty(); + seedScheduleSampleIfEmpty(); + seedPostitsIfEmpty(); + seedStockInfoSampleIfEmpty(); + } + + private void seedStockInfoSampleIfEmpty() { + Long exists = em.createQuery("select count(s) from Stock s where s.groupId='demo' and s.serial='0018987'", Long.class).getSingleResult(); + if (exists != null && exists > 0) return; + + Company in = em.createQuery("select c from Company c where c.groupId='demo' and c.name like '%CJ헬로%'", Company.class) + .setMaxResults(1).getResultList().stream().findFirst().orElse(null); + if (in == null) { + in = new Company(); + in.setGroupId("demo"); + in.setType("IN"); + in.setName("CJ헬로(SKT)"); + in.setManagerName("CJ담당"); + in.setPhone("02-0000-0000"); + in.setTelecom("SKT"); + in.setActive(true); + em.persist(in); + em.flush(); + } + Company out = em.createQuery("select c from Company c where c.groupId='demo' and c.name='늘푸른통신'", Company.class) + .setMaxResults(1).getResultList().stream().findFirst().orElse(null); + if (out == null) { + out = new Company(); + out.setGroupId("demo"); + out.setType("OUT"); + out.setName("늘푸른통신"); + out.setManagerName("박총괄"); + out.setPhone("010-1111-1003"); + out.setCategory("직영"); + out.setActive(true); + em.persist(out); + em.flush(); + } else if (out.getCategory() == null || out.getCategory().isBlank()) { + out.setCategory("직영"); + } + + LocalDate day = LocalDate.of(2026, 7, 9); + Stock s = new Stock(); + s.setGroupId("demo"); + s.setGubun("태블릿"); + s.setTelecom("SKT"); + s.setTelcompany("LG헬로비전"); + s.setSerial("0018987"); + s.setBarcode("0018987"); + s.setModel("SM-A175N"); + s.setColor("블랙"); + s.setCond("정상"); + s.setPay("여신"); + s.setState("OUT"); + s.setInprice(new BigDecimal("319000")); + s.setIncompanyId(in.getId()); + s.setOutcompanyId(out.getId()); + s.setOutCategory("직영"); + s.setIndate(day); + s.setOutdate(day); + s.setProcessDate(day); + s.setProcessor("김대표"); + s.setUserid("demo"); + s.setProcid("demo"); + s.setUid("stk-0018987"); + em.persist(s); + em.flush(); + + StockHistory inHist = new StockHistory(); + inHist.setGroupId("demo"); + inHist.setStockId(s.getId()); + inHist.setIncompanyId(in.getId()); + inHist.setProcessDate(day); + inHist.setGubun(s.getGubun()); + inHist.setState("IN"); + inHist.setStatusLabel("입고"); + inHist.setSerial(s.getSerial()); + inHist.setModel(s.getModel()); + inHist.setColor(s.getColor()); + inHist.setCond(s.getCond()); + inHist.setProcessor("김대표"); + inHist.setCompanyLabel(""); + em.persist(inHist); + + StockHistory outHist = new StockHistory(); + outHist.setGroupId("demo"); + outHist.setStockId(s.getId()); + outHist.setIncompanyId(in.getId()); + outHist.setOutcompanyId(out.getId()); + outHist.setProcessDate(day); + outHist.setGubun(s.getGubun()); + outHist.setState("OUT"); + outHist.setStatusLabel("출고"); + outHist.setSerial(s.getSerial()); + outHist.setModel(s.getModel()); + outHist.setColor(s.getColor()); + outHist.setCond(s.getCond()); + outHist.setProcessor("김대표"); + outHist.setCompanyLabel("늘푸른통신 (직영)"); + em.persist(outHist); + } + + private void seedAll() { + Company in = company("IN", "입고처A", "김입고", "02-1234-5678"); + Company shop = company("SHOP", "늘붉은통신", "홍길동", "010-1234-5678"); + shop.setBusinessName("늘붉은통신"); + shop.setBankAccount("국민은행 000-000-0000000 홍길동"); + company("DEALER", "데모 딜러", "박딜러", "010-2222-3333"); + Company out = company("OUT", "출고처 샘플", "이출고", "010-4444-5555"); + Company in2 = company("IN", "비즈케어 물류센터", "최물류", "02-5555-1004"); + company("OUT", "D1", "김대리", "010-1111-1001"); + company("OUT", "D2", "이대리", "010-1111-1002"); + company("OUT", "늘푸른통신", "박총괄", "010-1111-1003"); + company("OUT", "스마트모바일", "최영업", "010-1111-1004"); + company("OUT", "블루폰", "정매니저", "010-1111-1005"); + + Member agent = new Member(); + agent.setGroupId("demo"); + agent.setUserid("demo"); + agent.setPassword(encoder.encode("demo123")); + agent.setName("데모 관리자"); + agent.setPhone("010-9999-0000"); + agent.setRole("AGENT"); + agent.setActive(true); + em.persist(agent); + + Member shopUser = new Member(); + shopUser.setGroupId("demo"); + shopUser.setUserid("demoshop"); + shopUser.setPassword(encoder.encode("demoshop")); + shopUser.setName("늘붉은통신"); + shopUser.setPhone("010-1234-5678"); + shopUser.setRole("SHOP"); + shopUser.setCompanyId(shop.getId()); + shopUser.setActive(true); + shopUser.setLoginCount(0); + shopUser.setPlainPassword("demoshop"); + shopUser.setMenuFlags("{\"policy\":true,\"pds\":true,\"return\":true,\"indoc\":true,\"scan\":true,\"stock\":true,\"open\":true,\"settle\":true,\"farebox\":true}"); + em.persist(shopUser); + + Stock phone = stock("휴대폰", "SKT", "SM-S931N_256G", "아이스블루", "5477154", "OUT", in.getId(), shop.getId(), "1155000"); + Stock tablet = stock("태블릿", "KT", "SM-X910", "그레이", "TAB9001", "IN", in.getId(), null, "900000"); + Stock usim = stock("유심", "SKT", "20044", "공통색상", "10867345", "OUT", in.getId(), shop.getId(), "7700"); + String[] states = {"IN", "OUT", "RETURN", "LOSS", "OPEN", "SELL", "RECOVERY", "RECOVERY", "IN", "OUT", "RECOVERY", "RETURN", "IN", "SELL", "LOSS", "OUT", "RECOVERY"}; + String[] models = {"갤럭시 S25", "아이폰 16 Pro", "갤럭시 A36", "아이폰 16", "갤럭시 Z Flip7", "20044", "갤럭시 S25+", "아이폰 15", "갤럭시 A56"}; + String[] colors = {"티타늄 블루", "블랙", "화이트", "실버", "민트", "공통색상"}; + String[] telecoms = {"SKT", "KT", "LGU+"}; + for (int i = 0; i < states.length; i++) { + String state = states[i]; + Stock sample = stock(i % 6 == 5 ? "유심" : "휴대폰", telecoms[i % telecoms.length], models[i % models.length], + colors[i % colors.length], String.format("SN%010d", 240001 + i), state, i % 2 == 0 ? in.getId() : in2.getId(), + state.equals("IN") ? null : (i % 2 == 0 ? shop.getId() : out.getId()), String.valueOf(7700 + i * 85000)); + sample.setPetname(new String[]{"S25특가", "아이폰프로", "보급형", "유심재고"}[i % 4]); + sample.setOutCategory(i % 2 == 0 ? "판매점" : "도매"); + sample.setSalesperson(new String[]{"김영업", "이영업", "박영업"}[i % 3]); + sample.setSource(i % 2 == 0 ? "본사" : "온라인"); + sample.setProcessDate(LocalDate.now().minusDays(i % 12)); + sample.setProcessor(new String[]{"데모 관리자", "김대리", "이과장"}[i % 3]); + sample.setCond(state.equals("RECOVERY") ? "고품" : "정상"); + sample.setMemo(i % 4 == 0 ? "검수 완료" : (state.equals("RECOVERY") ? "회수대기" : null)); + sample.setIndate(LocalDate.now().minusDays(i + 1)); + if (state.equals("RECOVERY")) { + sample.setOutcompanyId(shop.getId()); + sample.setOutdate(LocalDate.now().minusDays(i + 3)); + sample.setRecalldate(null); + } + } + + OpenRecord firstOpen = null; + String[][] openSamples = { + {"할부", "SKT", "보상", "김*철", "010-****-5678", "319000", "20000", "5000"}, + {"현금", "KT", "신규", "이*수", "010-****-1234", "450000", "35000", "0"}, + {"유심", "LGU+", "번호이동", "박*영", "010-****-9876", "0", "15000", "0"}, + {"할부", "SKT", "기변", "최*민", "010-****-4321", "890000", "40000", "10000"}, + {"현금", "KT", "메이징", "정*아", "010-****-2468", "120000", "8000", "0"}, + {"할부", "SKT", "가개통", "한*진", "010-****-1357", "550000", "25000", "3000"}, + {"유심", "LGU+", "선불개통", "오*희", "010-****-8642", "0", "5000", "0"}, + {"현금", "SKT", "신규", "윤*호", "010-****-7531", "280000", "18000", "0"}, + }; + for (int i = 0; i < openSamples.length; i++) { + String[] s = openSamples[i]; + OpenRecord open = new OpenRecord(); + open.setGroupId("demo"); + open.setOutcompanyId(shop.getId()); + open.setIncompanyId(in.getId()); + open.setGubun(s[0]); + open.setTelecom(s[1]); + open.setOpenhow(s[2]); + open.setName(s[3]); + open.setOpenphone(s[4]); + open.setOpendate(LocalDate.now().minusDays(i)); + open.setOpenHour(10 + (i % 8)); + open.setOpenMinute(i % 2 == 0 ? 0 : 30); + open.setOpenStatus("정상"); + open.setOutCategory("판매점"); + open.setSalesperson(i % 2 == 0 ? "김영업" : "이영업"); + open.setEmployee("demo"); + open.setPlan(i % 2 == 0 ? "5GX 프라임" : "LTE 슬림"); + open.setSalemon("할부".equals(s[0]) ? 24 : 0); + open.setAgreemon(24); + open.setSalehow(s[0]); + open.setJoinhow("일반"); + open.setSupportType(i % 3 == 0 ? "선택약정" : "일반"); + open.setPmodel(i % 2 == 0 ? "SM-S928N" : "A2860"); + open.setPserial("할부".equals(s[0]) || "현금".equals(s[0]) ? "IMEI" + (100000 + i) : null); + open.setPcolor("블랙"); + open.setUserial("유심".equals(s[0]) || i % 2 == 0 ? "USIM" + (200000 + i) : null); + open.setInprice(new BigDecimal(s[5])); + open.setNetprice(new BigDecimal(s[5])); + open.setSellprice(new BigDecimal(s[5])); + open.setDealermargin1(new BigDecimal(s[6])); + open.setDeclprice1(new BigDecimal(s[7])); + open.setCommonSupport(new BigDecimal(i % 2 == 0 ? "300000" : "150000")); + open.setBasicprice1(new BigDecimal("100000")); + open.setBasicprice2(new BigDecimal("50000")); + open.setBasicprice3(new BigDecimal("30000")); + open.setDecltype(i % 2 == 0 ? "단말할인" : "요금할인"); + open.setState("OPEN"); + open.setUserid("demo"); + open.setUid("open-" + UUID.randomUUID()); + if (i == 0) { + open.setPhoneStockId(phone.getId()); + open.setPserial(phone.getSerial()); + open.setPmodel(phone.getModel()); + open.setPcolor(phone.getColor()); + } + em.persist(open); + if (i == 0) { + firstOpen = open; + phone.setOpenId(open.getId()); + phone.setState("OPEN"); + } + } + OpenRecord open = firstOpen; + + // shop/policy/pds boards are seeded in dedicated seedBbs* methods + + ScanDoc scan = new ScanDoc(); + scan.setGroupId("demo"); + scan.setOutcompanyId(shop.getId()); + scan.setIncompanyId(in.getId()); + scan.setOutcompanyName(shop.getName()); + scan.setTelcompany("SKT"); + scan.setGubun("보상기변"); + scan.setName("홍길동/010-1234-5678"); + scan.setFilesCount(1); + scan.setMemo("123456"); + scan.setSendmsg("미비"); + scan.setState("완료"); + scan.setSrc("2"); + scan.setRegisteredAt(java.time.LocalDateTime.now().withHour(13).withMinute(32).withSecond(0).withNano(0)); + em.persist(scan); + + IncompleteDoc indoc = new IncompleteDoc(); + indoc.setGroupId("demo"); + indoc.setOutcompanyId(shop.getId()); + indoc.setIncompanyId(in.getId()); + indoc.setOpenId(open.getId()); + indoc.setOpendate(LocalDate.now()); + indoc.setTelecom("SKT"); + indoc.setCustomerName("김*철"); + indoc.setOpenphone("010-****-5678"); + indoc.setMissingDocs("신분증사본"); + indoc.setDeductAmount(new BigDecimal("30000")); + indoc.setDeductReason("서류미비"); + indoc.setDeadline(LocalDate.now().plusDays(7)); + indoc.setStatus("미비"); + em.persist(indoc); + + IncompleteDoc indoc2 = new IncompleteDoc(); + indoc2.setGroupId("demo"); + indoc2.setOutcompanyId(shop.getId()); + indoc2.setIncompanyId(in.getId()); + indoc2.setOpendate(LocalDate.now().minusDays(3)); + indoc2.setTelecom("KT"); + indoc2.setCustomerName("이*수"); + indoc2.setOpenphone("010-****-1234"); + indoc2.setMissingDocs("인감증명서, 가족관계증명서"); + indoc2.setDeductAmount(new BigDecimal("50000")); + indoc2.setDeductReason("추가서류 미제출"); + indoc2.setDeadline(LocalDate.now().plusDays(3)); + indoc2.setStatus("미비"); + em.persist(indoc2); + + IncompleteDoc indoc3 = new IncompleteDoc(); + indoc3.setGroupId("demo"); + indoc3.setOutcompanyId(out.getId()); + indoc3.setIncompanyId(in2.getId()); + indoc3.setOpendate(LocalDate.now().minusDays(10)); + indoc3.setTelecom("LGU+"); + indoc3.setCustomerName("박*영"); + indoc3.setOpenphone("010-****-9876"); + indoc3.setMissingDocs("위임장"); + indoc3.setDeductAmount(new BigDecimal("20000")); + indoc3.setDeductReason("대리개통 서류"); + indoc3.setDeadline(LocalDate.now().minusDays(1)); + indoc3.setStatus("마감임박"); + em.persist(indoc3); + + UnreturnedPhone ret = new UnreturnedPhone(); + ret.setGroupId("demo"); + ret.setOutcompanyId(shop.getId()); + ret.setIncompanyId(in.getId()); + ret.setOpenId(open.getId()); + ret.setOpendate(LocalDate.now().minusDays(10)); + ret.setTelecom("SKT"); + ret.setCustomerName("이*영"); + ret.setOpenphone("010-****-1111"); + ret.setModel("SM-A175N"); + ret.setSerial("0018987"); + ret.setDeductAmount(new BigDecimal("50000")); + ret.setReturnStatus("미반납"); + em.persist(ret); + + String[][] returnSamples = { + {"KT", "박*지", "010-****-6595", "미반납", "A125", "50000", "0"}, + {"SKT", "홍*동", "010-****-2222", "반납", "sm-g220", "1234", "10000"}, + {"LGU+", "김*수", "010-****-3344", "미반납", "SM-S928N", "IMEI9001", "30000"}, + {"SKT", "최*아", "010-****-7788", "반납", "A2860", "SN7788", ""}, + {"KT", "정*호", "010-****-9900", "미반납", "SM-A546", "A5469900", "15000"}, + }; + for (int i = 0; i < returnSamples.length; i++) { + String[] s = returnSamples[i]; + UnreturnedPhone row = new UnreturnedPhone(); + row.setGroupId("demo"); + row.setOutcompanyId(i % 2 == 0 ? shop.getId() : out.getId()); + row.setIncompanyId(i % 2 == 0 ? in.getId() : in2.getId()); + row.setOpendate(LocalDate.now().minusDays(5 + i * 3)); + row.setTelecom(s[0]); + row.setCustomerName(s[1]); + row.setOpenphone(s[2]); + row.setReturnStatus(s[3]); + row.setModel(s[4]); + row.setSerial(s[5]); + if (!s[6].isBlank()) row.setDeductAmount(new BigDecimal(s[6])); + if ("반납".equals(s[3])) row.setReturndate(LocalDate.now().minusDays(i)); + em.persist(row); + } + + SettlementMonth settle = new SettlementMonth(); + settle.setGroupId("demo"); + settle.setOutcompanyId(shop.getId()); + settle.setOutcompanyName(shop.getName()); + settle.setYearMonth(LocalDate.now().toString().substring(0, 7)); + settle.setOpenCount(0); + settle.setOpenAmount(BigDecimal.ZERO); + settle.setAfterSettleCount(1); + settle.setAfterSettleAmount(new BigDecimal("-100000")); + settle.setHomeProductCount(0); + settle.setHomeProductAmount(BigDecimal.ZERO); + settle.setRealSettleAmount(new BigDecimal("-100000")); + settle.setOutCategory("판매점"); + settle.setStatus("READY"); + settle.setIssued(false); + settle.setMemo(""); + em.persist(settle); + + Farebox fare = new Farebox(); + fare.setGroupId("demo"); + fare.setOutcompanyId(shop.getId()); + fare.setOutcompanyName(shop.getName()); + fare.setOutCategory("판매점"); + fare.setFaredate(LocalDate.now()); + fare.setTelcompany("SKT"); + fare.setName("홍*동"); + fare.setPhone("010-****-5678"); + fare.setHow("수납"); + fare.setPaystate("완결"); + fare.setCashprice(new BigDecimal("50000")); + fare.setCardprice(BigDecimal.ZERO); + fare.setDetail("요금수납"); + fare.setRegistrant("김대표"); + fare.setReceiptNo("R-001"); + em.persist(fare); + + AccountEntry acc = new AccountEntry(); + acc.setGroupId("demo"); + acc.setOutcompanyId(shop.getId()); + acc.setEntryDate(LocalDate.now()); + acc.setType("IN"); + acc.setCategory("정산입금"); + acc.setAmount(new BigDecimal("110000")); + acc.setMemo("월정산"); + acc.setYearMonth(LocalDate.now().toString().substring(0, 7)); + em.persist(acc); + + for (String telecom : new String[]{"SKT", "KT", "LGU+"}) { + PlanMaster p = new PlanMaster(); + p.setGroupId("demo"); + p.setTelecom(telecom); + p.setName(telecom + " 5G 베이직"); + p.setPrice(new BigDecimal("55000")); + p.setActive(true); + em.persist(p); + } + + PhoneModel model = new PhoneModel(); + model.setGroupId("demo"); + model.setTelecom("SKT"); + model.setModelCode("SM-S931N"); + model.setModelName("갤럭시 S25"); + model.setColorOptions("블랙,실버,아이스블루"); + model.setActive(true); + em.persist(model); + + ScheduleItem schedule = new ScheduleItem(); + schedule.setGroupId("demo"); + schedule.setUserid("demo"); + schedule.setSdate(LocalDate.now()); + schedule.setStime("10:00"); + schedule.setContents("월 정산 확인"); + em.persist(schedule); + + Alim alim = new Alim(); + alim.setGroupId("demo"); + alim.setUserid("demo"); + alim.setTitle("개통 서류 확인"); + alim.setContent("미비 서류를 확인해 주세요."); + alim.setConfirmed(false); + em.persist(alim); + + SmsTemplate tpl = new SmsTemplate(); + tpl.setGroupId("demo"); + tpl.setTitle("개통안내"); + tpl.setBody("안녕하세요. 개통이 완료되었습니다."); + em.persist(tpl); + + CsInquiry cs = new CsInquiry(); + cs.setGroupId("demo"); + cs.setUserid("demo"); + cs.setAuthorName("김대표"); + cs.setTitle("정산 문의"); + cs.setContent("이번달 실정산금 확인 부탁드립니다."); + cs.setStatus("답변대기"); + cs.setReplyCount(0); + em.persist(cs); + + Setting setting = new Setting(); + setting.setGroupId("demo"); + setting.setSettingKey("company"); + setting.setValue("{\"name\":\"에이전트 데모\",\"phone\":\"010-0000-0000\",\"managerName\":\"김대표\",\"businessName\":\"에이전트 데모\",\"businessNumber\":\"123-45-67890\",\"ceo\":\"김유신\",\"bizType\":\"도소매\",\"bizItem\":\"휴대폰판매\",\"address\":\"대구시 수성구 수성4가 먼길\"}"); + em.persist(setting); + + tablet.getId(); + usim.getId(); + } + + private void seedUsimLedgersIfEmpty() { + Map outs = new LinkedHashMap<>(); + for (Company c : em.createQuery("select c from Company c where c.groupId='demo' and c.type in ('OUT','SHOP')", Company.class).getResultList()) { + outs.put(c.getName(), c.getId()); + } + for (String name : List.of("D1", "D2", "늘푸른통신", "스마트모바일", "블루폰")) { + if (outs.containsKey(name)) continue; + Company c = company("OUT", name, "담당자", "010-0000-0000"); + em.flush(); + outs.put(c.getName(), c.getId()); + } + if (outs.isEmpty()) return; + Object[][] samples = { + {LocalDate.of(2025, 7, 20), "D1", "SKT", 100, "770000", "YENZI", "김대표"}, + {LocalDate.of(2025, 7, 18), "D2", "KT", 50, "385000", "", "정총괄"}, + {LocalDate.of(2025, 7, 15), "늘푸른통신", "LGT", 20, "154000", "333", "김대표"}, + {LocalDate.of(2025, 7, 10), "스마트모바일", "SKT", 5, "38500", "", "이과장"}, + {LocalDate.of(2025, 6, 28), "블루폰", "KT", 8, "61600", "유심일괄", "정총괄"}, + {LocalDate.of(2025, 6, 20), "늘붉은통신", "SK텔링크", 12, "92400", "", "김대표"}, + {LocalDate.of(2025, 6, 12), "출고처 샘플", "SKT", 15, "115500", "프로모션", "데모 관리자"}, + {LocalDate.of(2025, 5, 30), "늘푸른통신", "KT", 10, "77000", "", "정총괄"}, + {LocalDate.of(2025, 5, 22), "D1", "LGT", 7, "53900", "YENZI", "이과장"}, + {LocalDate.of(2025, 5, 10), "D2", "SKT", 25, "192500", "", "김대표"}, + {LocalDate.of(2025, 4, 18), "스마트모바일", "KT", 3, "23100", "333", "정총괄"}, + {LocalDate.of(2025, 4, 5), "블루폰", "LGT", 32, "246400", "", "김대표"}, + }; + for (Object[] s : samples) { + LocalDate date = (LocalDate) s[0]; + String outName = String.valueOf(s[1]); + Long outId = outs.get(outName); + if (outId == null) continue; + Long exists = em.createQuery( + "select count(e) from UsimLedger e where e.groupId='demo' and e.tradeDate=:d and e.outcompanyId=:o and e.qty=:q", Long.class) + .setParameter("d", date).setParameter("o", outId).setParameter("q", (Integer) s[3]).getSingleResult(); + if (exists != null && exists > 0) continue; + UsimLedger row = new UsimLedger(); + row.setGroupId("demo"); + row.setOutcompanyId(outId); + row.setTradeDate(date); + row.setTelecom(String.valueOf(s[2])); + row.setQty((Integer) s[3]); + row.setAmount(new BigDecimal(String.valueOf(s[4]))); + String detail = String.valueOf(s[5]); + row.setDetail(detail.isBlank() ? null : detail); + row.setRegistrant(String.valueOf(s[6])); + row.setStatus("정상거래"); + em.persist(row); + } + } + + private void seedStockHistoryIfEmpty() { + Long count = em.createQuery("select count(e) from StockHistory e", Long.class).getSingleResult(); + if (count != null && count > 0) return; + List stocks = em.createQuery("select s from Stock s where s.groupId='demo'", Stock.class).getResultList(); + for (Stock s : stocks) { + StockHistory h = new StockHistory(); + h.setGroupId("demo"); + h.setStockId(s.getId()); + h.setIncompanyId(s.getIncompanyId()); + h.setOutcompanyId(s.getOutcompanyId()); + h.setProcessDate(s.getProcessDate() != null ? s.getProcessDate() : (s.getIndate() != null ? s.getIndate() : LocalDate.now())); + h.setGubun(s.getGubun()); + h.setState(s.getState()); + h.setStatusLabel(switch (s.getState() == null ? "" : s.getState()) { + case "IN" -> "입고"; + case "OUT" -> "출고"; + case "RETURN" -> "반품"; + case "LOSS" -> "분실"; + case "OPEN" -> "개통"; + case "SELL" -> "판매"; + case "RECOVERY" -> "회수대기"; + default -> s.getState(); + }); + h.setSerial(s.getSerial()); + h.setModel(s.getModel()); + h.setColor(s.getColor()); + h.setCond(s.getCond()); + h.setMemo(s.getMemo()); + h.setProcessor(s.getProcessor() != null ? s.getProcessor() : "데모 관리자"); + h.setSalesperson(s.getSalesperson()); + em.persist(h); + if (s.getIndate() != null && s.getProcessDate() != null && !s.getIndate().equals(s.getProcessDate())) { + StockHistory in = new StockHistory(); + in.setGroupId("demo"); + in.setStockId(s.getId()); + in.setIncompanyId(s.getIncompanyId()); + in.setProcessDate(s.getIndate()); + in.setGubun(s.getGubun()); + in.setState("IN"); + in.setStatusLabel("입고"); + in.setSerial(s.getSerial()); + in.setModel(s.getModel()); + in.setColor(s.getColor()); + in.setCond(s.getCond()); + in.setProcessor("데모 관리자"); + in.setSalesperson(s.getSalesperson()); + em.persist(in); + } + } + } + + private void seedOpenExchangesIfEmpty() { + Long count = em.createQuery("select count(e) from OpenExchange e where e.groupId='demo'", Long.class).getSingleResult(); + if (count > 0) return; + List shops = em.createQuery("select c from Company c where c.groupId='demo' and c.type in ('SHOP','OUT')", Company.class).getResultList(); + if (shops.isEmpty()) return; + Object[][] samples = { + {"단말기", "홍*동", "010-****-5678", "SM-A235N", "블랙", "77777", "SM-A235N", "블랙", "88888", "김처리", "교품완료"}, + {"단말기", "김*수", "010-****-1234", "SM-S911N", "크림", "11111", "SM-S908N", "그린", "22222", "이처리", null}, + {"유심", "박*지", "010-****-9988", "USIM-KT", "-", "U9001", "USIM-KT", "-", "U8001", "demo", "유심교체"}, + {"단말기", "이*호", "010-****-3344", "LM-G820N", "티탄", "33333", "LM-V500N", "화이트", "44444", "최처리", null}, + {"단말기", "최*아", "010-****-5566", "SM-A546S", "라벤더", "55555", "SM-A536S", "어썸블랙", "66666", "김처리", "색상변경"}, + {"유심", "정*민", "010-****-7788", "USIM-SKT", "-", "U7002", "USIM-SKT", "-", "U6002", "demo", null}, + {"단말기", "윤*희", "010-****-1122", "IPHONE15", "블루", "A1001", "IPHONE14", "미드나잇", "A0901", "박처리", null}, + {"단말기", "강*우", "010-****-4455", "SM-F946N", "그레이", "F1001", "SM-F936N", "그린", "F0901", "이처리", "폴드교품"}, + {"단말기", "조*연", "010-****-6677", "SM-A256N", "라이트블루", "A2561", "SM-A245N", "블랙", "A2451", "demo", null}, + {"유심", "한*석", "010-****-8899", "USIM-LGU", "-", "U5003", "USIM-LGU", "-", "U4003", "최처리", null}, + {"단말기", "오*진", "010-****-2211", "SM-S921N", "그레이", "S9211", "SM-S918N", "크림", "S9181", "김처리", null}, + {"단말기", "서*영", "010-****-3434", "LM-Q920N", "오로라", "Q9201", "LM-Q730N", "모로칸블루", "Q7301", "demo", "메모있음"}, + {"단말기", "문*태", "010-****-5656", "SM-A156N", "라이트그레이", "A1561", "SM-A145N", "블랙", "A1451", "박처리", null}, + {"유심", "배*리", "010-****-7878", "USIM-KT", "-", "U3004", "USIM-KT", "-", "U2004", "이처리", null}, + {"단말기", "신*호", "010-****-9090", "SM-G991N", "팬텀그레이", "G9911", "SM-G990N", "화이트", "G9901", "demo", null}, + {"단말기", "유*나", "010-****-1313", "IPHONE13", "핑크", "I1301", "IPHONE12", "퍼플", "I1201", "최처리", "고객요청"}, + }; + for (int i = 0; i < samples.length; i++) { + Object[] s = samples[i]; + Company shop = shops.get(i % shops.size()); + OpenExchange e = new OpenExchange(); + e.setGroupId("demo"); + e.setOutcompanyId(shop.getId()); + e.setOutcompanyName(shop.getName()); + e.setKind(String.valueOf(s[0])); + e.setCustomerName(String.valueOf(s[1])); + e.setOpenphone(String.valueOf(s[2])); + e.setNewModel(String.valueOf(s[3])); + e.setNewColor(String.valueOf(s[4])); + e.setNewSerial(String.valueOf(s[5])); + e.setOldModel(String.valueOf(s[6])); + e.setOldColor(String.valueOf(s[7])); + e.setOldSerial(String.valueOf(s[8])); + e.setProcessor(String.valueOf(s[9])); + e.setMemo(s[10] == null ? null : String.valueOf(s[10])); + e.setExchangeDate(LocalDate.now().minusDays(i)); + e.setOpenDate(LocalDate.now().minusDays(i + 10)); + e.setProcessedAt(java.time.LocalDateTime.now().minusDays(i).withHour(10 + (i % 8)).withMinute((i * 7) % 60).withSecond(0).withNano(0)); + em.persist(e); + } + } + + private void seedOpenNameChangesIfEmpty() { + Long count = em.createQuery("select count(e) from OpenNameChange e where e.groupId='demo'", Long.class).getSingleResult(); + if (count > 0) return; + List shops = em.createQuery("select c from Company c where c.groupId='demo' and c.type in ('SHOP','OUT')", Company.class).getResultList(); + if (shops.isEmpty()) return; + Object[][] samples = { + {"지*이", "010-****-1234", "김*수", "010-****-9999", "usim (0012345)", "김처리", "명의변경"}, + {"박*지", "010-****-5678", "이*호", "010-****-1111", "-", "이처리", null}, + {"최*아", "010-****-3344", "정*민", "010-****-2222", "usim (0098765)", "demo", null}, + {"윤*희", "010-****-7788", "한*석", "010-****-3333", "-", "최처리", "가족명의"}, + {"강*우", "010-****-5566", "오*진", "010-****-4444", "usim (0054321)", "박처리", null}, + {"조*연", "010-****-9900", "서*영", "010-****-5555", "-", "demo", null}, + {"문*태", "010-****-1122", "배*리", "010-****-6666", "usim (0077001)", "김처리", "법인전환"}, + {"신*호", "010-****-4455", "유*나", "010-****-7777", "-", "이처리", null}, + }; + for (int i = 0; i < samples.length; i++) { + Object[] s = samples[i]; + Company shop = shops.get(i % shops.size()); + OpenNameChange e = new OpenNameChange(); + e.setGroupId("demo"); + e.setOutcompanyId(shop.getId()); + e.setOutcompanyName(shop.getName()); + e.setCustomerName(String.valueOf(s[0])); + e.setOpenphone(String.valueOf(s[1])); + e.setOldOwner(String.valueOf(s[2])); + e.setOldPhone(String.valueOf(s[3])); + e.setOldUsim(String.valueOf(s[4])); + e.setProcessor(String.valueOf(s[5])); + e.setMemo(s[6] == null ? null : String.valueOf(s[6])); + e.setChangeDate(LocalDate.now().minusDays(i * 3)); + e.setOpenDate(LocalDate.now().minusDays(i * 3 + 20)); + e.setProcessedAt(java.time.LocalDateTime.now().minusDays(i * 3).withHour(9 + (i % 8)).withMinute((i * 11) % 60).withSecond(0).withNano(0)); + em.persist(e); + } + } + + private void seedOpenWithdrawalsIfEmpty() { + Long count = em.createQuery("select count(e) from OpenWithdrawal e where e.groupId='demo'", Long.class).getSingleResult(); + if (count > 0) return; + List shops = em.createQuery("select c from Company c where c.groupId='demo' and c.type in ('SHOP','OUT')", Company.class).getResultList(); + if (shops.isEmpty()) return; + String[] names = {"김*수", "이*호", "박*지", "최*아", "정*민", "윤*희", "강*우", "조*연", "한*석", "오*진"}; + String[] reasons = {"고객요청", "개통오류", "서류미비", "요금미납", "중복개통", "본인취소", "정책철회", "-"}; + String[] processors = {"김처리", "이처리", "demo", "최처리", "박처리"}; + for (int i = 0; i < 30; i++) { + Company shop = shops.get(i % shops.size()); + OpenWithdrawal e = new OpenWithdrawal(); + e.setGroupId("demo"); + e.setOutcompanyId(shop.getId()); + e.setOutcompanyName(shop.getName()); + e.setCustomerName(names[i % names.length]); + e.setOpenphone(String.format("010-****-%04d", 1000 + i * 17)); + e.setProcessor(processors[i % processors.length]); + e.setReason(reasons[i % reasons.length]); + e.setWithdrawDate(LocalDate.now().minusDays(i)); + e.setOpenDate(LocalDate.now().minusDays(i + 15)); + e.setProcessedAt(java.time.LocalDateTime.now().minusDays(i).withHour(10 + (i % 8)).withMinute((i * 13) % 60).withSecond(0).withNano(0)); + em.persist(e); + } + } + + private void seedOpenSettleHistoriesIfEmpty() { + Long count = em.createQuery("select count(e) from OpenSettleHistory e where e.groupId='demo'", Long.class).getSingleResult(); + if (count > 0) return; + List shops = em.createQuery("select c from Company c where c.groupId='demo' and c.type in ('SHOP','OUT')", Company.class).getResultList(); + if (shops.isEmpty()) return; + String[] names = {"박*수", "김*한", "이*호", "최*아", "정*민", "윤*희", "강*우", "조*연"}; + String[] processors = {"김대표", "이개통", "demo", "최처리", "박처리"}; + String[] memos = {"정책변경", "단가조정", "추가정산", "차감반영", "재계산", "오류수정"}; + for (int i = 0; i < 40; i++) { + Company shop = shops.get(i % shops.size()); + long oldAmt = (i % 3 == 0 ? -1 : 1) * (50000L + (i * 17311L) % 300000); + long newAmt = oldAmt + ((i % 2 == 0 ? 1 : -1) * (10000L + (i * 7919L) % 80000)); + OpenSettleHistory e = new OpenSettleHistory(); + e.setGroupId("demo"); + e.setOutcompanyId(shop.getId()); + e.setOutcompanyName(shop.getName()); + e.setCustomerName(names[i % names.length]); + e.setOpenphone(String.format("010-****-%04d", 2000 + i * 19)); + e.setOldAmount(BigDecimal.valueOf(oldAmt)); + e.setNewAmount(BigDecimal.valueOf(newAmt)); + e.setProcessor(processors[i % processors.length]); + e.setMemo(memos[i % memos.length]); + e.setChangeDate(LocalDate.now().minusDays(i)); + e.setOpenDate(LocalDate.now().minusDays(i + 12)); + e.setProcessedAt(java.time.LocalDateTime.now().minusDays(i).withHour(9 + (i % 9)).withMinute((i * 17) % 60).withSecond(0).withNano(0)); + em.persist(e); + } + } + + private void seedOpenDeleteHistoriesIfEmpty() { + Long count = em.createQuery("select count(e) from OpenDeleteHistory e where e.groupId='demo'", Long.class).getSingleResult(); + if (count > 0) return; + List shops = em.createQuery("select c from Company c where c.groupId='demo' and c.type in ('SHOP','OUT')", Company.class).getResultList(); + if (shops.isEmpty()) return; + Object[][] samples = { + {"홍***님", "010-****-9870", "정총괄", "개통삭제(변심)"}, + {"a****s", "010-****-1234", "김대표", "개통삭제(출고처수정)"}, + {"홍*동", "010-****-5678", "이개통", "첨부파일삭제(파일명.gif)"}, + {"김*수", "010-****-3344", "demo", "개통삭제(오류)"}, + {"박*지", "010-****-7788", "최처리", "개통삭제(변심)"}, + {"이*호", "010-****-5566", "정총괄", "첨부파일삭제(계약서.pdf)"}, + {"최*아", "010-****-9900", "김대표", "개통삭제(출고처수정)"}, + {"정*민", "010-****-1122", "이개통", "개통삭제(중복)"}, + {"윤*희", "010-****-4455", "demo", "개통삭제(변심)"}, + {"강*우", "010-****-6677", "박처리", "첨부파일삭제(신분증.jpg)"}, + {"조*연", "010-****-8899", "정총괄", "개통삭제(요금미납)"}, + {"한*석", "010-****-2211", "김대표", "개통삭제(변심)"}, + {"오*진", "010-****-3434", "이개통", "개통삭제(출고처수정)"}, + {"서*영", "010-****-5656", "demo", "첨부파일삭제(동의서.png)"}, + {"문*태", "010-****-7878", "최처리", "개통삭제(오류)"}, + {"배*리", "010-****-9090", "정총괄", "개통삭제(변심)"}, + }; + for (int i = 0; i < samples.length; i++) { + Object[] s = samples[i]; + Company shop = shops.get(i % shops.size()); + OpenDeleteHistory e = new OpenDeleteHistory(); + e.setGroupId("demo"); + e.setOutcompanyId(shop.getId()); + e.setOutcompanyName(shop.getName()); + e.setCustomerName(String.valueOf(s[0])); + e.setOpenphone(String.valueOf(s[1])); + e.setProcessor(String.valueOf(s[2])); + e.setMemo(String.valueOf(s[3])); + e.setDeleteDate(LocalDate.now().minusDays(i)); + e.setOpenDate(LocalDate.now().minusDays(i + 5)); + e.setProcessedAt(java.time.LocalDateTime.now().minusDays(i).withHour(10 + (i % 8)).withMinute((i * 19) % 60).withSecond(0).withNano(0)); + em.persist(e); + } + } + + private void seedBbsNoticeBoardIfEmpty() { + List posts = em.createQuery("select b from BbsPost b where b.groupId='demo' and b.code='shop'", BbsPost.class).getResultList(); + Object[][] samples = { + {"124124", "2020-10-05"}, + {"※ 동행보고 ※ 124123n124 5", "2020-02-17"}, + {"테스트", "2019-04-09"}, + }; + for (Object[] s : samples) { + String title = String.valueOf(s[0]); + boolean exists = posts.stream().anyMatch(b -> title.equals(b.getTitle())); + if (exists) continue; + BbsPost b = new BbsPost(); + b.setGroupId("demo"); + b.setCode("shop"); + b.setTitle(title); + b.setContents("

" + title + "

"); + b.setAuthorName("김대표"); + b.setUserid("demo"); + b.setTargetGroup("전체"); + b.setConfirmCount(2); + b.setNotice(false); + b.setViews(0); + b.setState("1"); + em.persist(b); + } + for (BbsPost b : em.createQuery("select b from BbsPost b where b.groupId='demo' and b.code='shop'", BbsPost.class).getResultList()) { + if (b.getTargetGroup() == null || b.getTargetGroup().isBlank()) b.setTargetGroup("전체"); + if (b.getConfirmCount() == null) b.setConfirmCount(2); + if (b.getAuthorName() == null || b.getAuthorName().isBlank()) b.setAuthorName("김대표"); + } + } + + private void seedBbsPolicyBoardIfEmpty() { + List posts = em.createQuery("select b from BbsPost b where b.groupId='demo' and b.code='policy'", BbsPost.class).getResultList(); + Object[][] samples = { + {"31일 1차 단가표", "전체", "김대표", 1}, + {"24124", "전체", "김대표", 3}, + {"124124", "KT", "정총괄", 1}, + }; + for (Object[] s : samples) { + String title = String.valueOf(s[0]); + boolean exists = posts.stream().anyMatch(b -> title.equals(b.getTitle())); + if (exists) continue; + BbsPost b = new BbsPost(); + b.setGroupId("demo"); + b.setCode("policy"); + b.setTitle(title); + b.setContents("

" + title + "

"); + b.setAuthorName(String.valueOf(s[2])); + b.setUserid("demo"); + b.setTargetGroup(String.valueOf(s[1])); + b.setConfirmCount(((Number) s[3]).intValue()); + b.setNotice(false); + b.setViews(0); + b.setState("1"); + em.persist(b); + } + for (BbsPost b : em.createQuery("select b from BbsPost b where b.groupId='demo' and b.code='policy'", BbsPost.class).getResultList()) { + if (b.getTargetGroup() == null || b.getTargetGroup().isBlank()) b.setTargetGroup("전체"); + if (b.getConfirmCount() == null) b.setConfirmCount(1); + if (b.getAuthorName() == null || b.getAuthorName().isBlank()) b.setAuthorName("김대표"); + } + } + + private void seedBbsPdsBoardIfEmpty() { + List posts = em.createQuery("select b from BbsPost b where b.groupId='demo' and b.code='pds'", BbsPost.class).getResultList(); + Object[][] samples = { + {"24124124", "전체", "김대표", 2}, + {"123", "전체", "김대표", 2}, + }; + for (Object[] s : samples) { + String title = String.valueOf(s[0]); + boolean exists = posts.stream().anyMatch(b -> title.equals(b.getTitle())); + if (exists) continue; + BbsPost b = new BbsPost(); + b.setGroupId("demo"); + b.setCode("pds"); + b.setTitle(title); + b.setContents("

" + title + "

"); + b.setAuthorName(String.valueOf(s[2])); + b.setUserid("demo"); + b.setTargetGroup(String.valueOf(s[1])); + b.setConfirmCount(((Number) s[3]).intValue()); + b.setNotice(false); + b.setViews(0); + b.setState("1"); + em.persist(b); + } + for (BbsPost b : em.createQuery("select b from BbsPost b where b.groupId='demo' and b.code='pds'", BbsPost.class).getResultList()) { + if (b.getTargetGroup() == null || b.getTargetGroup().isBlank()) b.setTargetGroup("전체"); + if (b.getConfirmCount() == null) b.setConfirmCount(2); + if (b.getAuthorName() == null || b.getAuthorName().isBlank()) b.setAuthorName("김대표"); + } + } + + private void seedBoardGroupsIfEmpty() { + Long count = em.createQuery("select count(g) from BoardGroup g where g.groupId='demo'", Long.class).getSingleResult(); + if (count > 0) return; + List shops = em.createQuery("select c from Company c where c.groupId='demo' and c.type in ('SHOP','OUT')", Company.class).getResultList(); + String shopIds = shops.stream().map(c -> String.valueOf(c.getId())).collect(java.util.stream.Collectors.joining(",")); + Object[][] samples = { + {"기본통신사", "LG헬로비전", shopIds}, + {"기본통신사", "KT", shopIds}, + {"기본통신사", "SKT", shopIds}, + {"기본통신사", "LGU+", shopIds.isEmpty() ? "" : shops.getFirst().getId().toString()}, + {"기본통신사", "SK브로드밴드", ""}, + {"사용자그룹", "A그룹", shops.size() < 2 ? shopIds : shops.stream().limit(2).map(c -> String.valueOf(c.getId())).collect(java.util.stream.Collectors.joining(","))}, + {"사용자그룹", "테스트", ""}, + {"사용자그룹", "LG유모비", ""}, + {"사용자그룹", "B그룹", shops.size() > 1 ? shops.get(1).getId().toString() : ""}, + {"사용자그룹", "VIP", ""}, + {"사용자그룹", "도매전용", shops.isEmpty() ? "" : shops.getFirst().getId().toString()}, + }; + for (Object[] s : samples) { + BoardGroup g = new BoardGroup(); + g.setGroupId("demo"); + g.setCategory(String.valueOf(s[0])); + g.setName(String.valueOf(s[1])); + g.setCompanyIds(String.valueOf(s[2])); + em.persist(g); + } + } + + private void seedScansEnrich() { + List scans = em.createQuery("select s from ScanDoc s where s.groupId='demo'", ScanDoc.class).getResultList(); + for (ScanDoc s : scans) { + if ("DONE".equals(s.getState()) || "DONE".equalsIgnoreCase(String.valueOf(s.getState()))) s.setState("완료"); + if ("PENDING".equals(s.getState())) s.setState("대기"); + if (s.getRegisteredAt() == null) s.setRegisteredAt(s.getCreatedAt() == null ? java.time.LocalDateTime.now() : java.time.LocalDateTime.ofInstant(s.getCreatedAt(), java.time.ZoneId.of("Asia/Seoul"))); + if (s.getOutcompanyName() == null || s.getOutcompanyName().isBlank()) { + if (s.getOutcompanyId() != null) { + Company c = em.find(Company.class, s.getOutcompanyId()); + if (c != null) s.setOutcompanyName(c.getName()); + } + } + } + if (scans.size() >= 3) return; + List shops = em.createQuery("select c from Company c where c.groupId='demo' and c.type in ('SHOP','OUT')", Company.class).getResultList(); + if (shops.isEmpty()) return; + Company shop = shops.getFirst(); + Object[][] samples = { + {"완료", "SKT", "보상기변", "비즈케어/010-1234-5678", null, null, 1}, + {"접수", "SKT2", "MNP", "비즈케어", null, null, 1}, + }; + for (int i = 0; i < samples.length; i++) { + if (scans.size() + i >= 3) break; + Object[] s = samples[i]; + ScanDoc d = new ScanDoc(); + d.setGroupId("demo"); + d.setOutcompanyId(shop.getId()); + d.setOutcompanyName(shop.getName()); + d.setState(String.valueOf(s[0])); + d.setTelcompany(String.valueOf(s[1])); + d.setGubun(String.valueOf(s[2])); + d.setName(String.valueOf(s[3])); + d.setSendmsg(s[4] == null ? null : String.valueOf(s[4])); + d.setMemo(s[5] == null ? null : String.valueOf(s[5])); + d.setFilesCount(((Number) s[6]).intValue()); + d.setSrc("2"); + d.setRegisteredAt(java.time.LocalDateTime.now().minusDays(i + 1).withHour(12 + i).withMinute(1 + i * 20).withSecond(0).withNano(0)); + em.persist(d); + } + } + + private void seedSettlementDocsIfEmpty() { + String ym = LocalDate.now().minusMonths(1).toString().substring(0, 7); + Company d2 = em.createQuery("select c from Company c where c.groupId='demo' and c.name='D2'", Company.class) + .getResultList().stream().findFirst().orElse(null); + if (d2 == null) { + d2 = company("OUT", "D2", "이대리", "010-1111-1002"); + } + List prev = em.createQuery( + "select s from SettlementMonth s where s.groupId='demo' and s.yearMonth=:y", SettlementMonth.class) + .setParameter("y", ym).getResultList(); + if (prev.isEmpty()) { + SettlementMonth s = new SettlementMonth(); + s.setGroupId("demo"); + s.setOutcompanyId(d2.getId()); + s.setOutcompanyName("D2"); + s.setYearMonth(ym); + s.setOutCategory("판매점"); + s.setOpenCount(0); + s.setOpenAmount(BigDecimal.ZERO); + s.setAfterSettleCount(1); + s.setAfterSettleAmount(new BigDecimal("-100000")); + s.setHomeProductCount(0); + s.setHomeProductAmount(BigDecimal.ZERO); + s.setRealSettleAmount(new BigDecimal("-100000")); + s.setIssued(false); + s.setStatus("READY"); + s.setInquiry("-"); + s.setAgreement("-"); + s.setInvoiceStatus("-"); + s.setRemittanceStatus("-"); + em.persist(s); + } else { + // Align demo sample with original screenshot (출고처 D2 / 후정산 1 / -100,000) + SettlementMonth s = prev.getFirst(); + s.setOutcompanyId(d2.getId()); + s.setOutcompanyName("D2"); + s.setOutCategory(s.getOutCategory() == null ? "판매점" : s.getOutCategory()); + if (s.getAfterSettleCount() == null || s.getAfterSettleCount() == 0) s.setAfterSettleCount(1); + if (s.getAfterSettleAmount() == null || s.getAfterSettleAmount().compareTo(BigDecimal.ZERO) == 0) { + s.setAfterSettleAmount(new BigDecimal("-100000")); + } + if (s.getRealSettleAmount() == null || s.getRealSettleAmount().compareTo(BigDecimal.ZERO) == 0) { + s.setRealSettleAmount(s.getAfterSettleAmount()); + } + } + + for (SettlementMonth row : em.createQuery("select s from SettlementMonth s where s.groupId='demo'", SettlementMonth.class).getResultList()) { + if (row.getOutcompanyName() == null || row.getOutcompanyName().isBlank()) { + Company c = row.getOutcompanyId() == null ? null : em.find(Company.class, row.getOutcompanyId()); + if (c != null) row.setOutcompanyName(c.getName()); + } + if (row.getAfterSettleCount() == null) row.setAfterSettleCount(row.getAfterSettleAmount() == null || row.getAfterSettleAmount().compareTo(BigDecimal.ZERO) == 0 ? 0 : 1); + if (row.getOpenCount() == null) row.setOpenCount(0); + if (row.getOpenAmount() == null) row.setOpenAmount(BigDecimal.ZERO); + if (row.getHomeProductCount() == null) row.setHomeProductCount(0); + if (row.getHomeProductAmount() == null) row.setHomeProductAmount(BigDecimal.ZERO); + if (row.getInquiry() == null) row.setInquiry("-"); + if (row.getAgreement() == null) row.setAgreement("-"); + if (row.getInvoiceStatus() == null) row.setInvoiceStatus("-"); + if (row.getRemittanceStatus() == null) row.setRemittanceStatus("-"); + } + } + + private void seedOutcompanyAccountsIfEmpty() { + String menuAll = "{\"policy\":true,\"pds\":true,\"return\":true,\"indoc\":true,\"scan\":true,\"stock\":true,\"open\":true,\"settle\":true,\"farebox\":true}"; + Object[][] specs = { + {"대구 D1", "도매", "P0000", "늘명훈", "010-1111-1111", "demoshop", "demoshop", true, 0, null}, + {"대구 늘푸른통신", "도매", "P1234", "김담당", "010-2222-2222", "as001", "as001", true, 89, "2026-07-10T22:10:00"}, + {"경산 D2", "도매", "P2001", "이대리", "010-1111-1002", "d2shop", "d2shop", true, 53, "2026-07-09T18:20:00"}, + {"스마트모바일", "소매", "P3001", "최영업", "010-1111-1004", "smart01", "smart01", false, 12, "2026-06-01T10:00:00"}, + {"블루폰", "C채널", "P4001", "정매니저", "010-1111-1005", null, null, true, 0, null}, + {"출고처 샘플", "특판", "P5001", "이출고", "010-4444-5555", "sample1", "sample1", true, 100, "2026-07-08T09:15:00"}, + {"늘붉은통신", "소매", "P0001", "홍길동", "010-1234-5678", null, null, true, 0, null}, + }; + for (Object[] s : specs) { + String name = String.valueOf(s[0]); + Company c = em.createQuery("select c from Company c where c.groupId='demo' and c.name=:n", Company.class) + .setParameter("n", name).getResultList().stream().findFirst().orElse(null); + if (c == null && name.endsWith(" D1")) { + c = em.createQuery("select c from Company c where c.groupId='demo' and c.name='D1'", Company.class) + .getResultList().stream().findFirst().orElse(null); + } + if (c == null && name.endsWith(" D2")) { + c = em.createQuery("select c from Company c where c.groupId='demo' and c.name='D2'", Company.class) + .getResultList().stream().findFirst().orElse(null); + } + if (c == null && name.contains("늘푸른")) { + c = em.createQuery("select c from Company c where c.groupId='demo' and c.name='늘푸른통신'", Company.class) + .getResultList().stream().findFirst().orElse(null); + } + if (c == null) { + String type = "늘붉은통신".equals(name) ? "SHOP" : "OUT"; + c = company(type, name, String.valueOf(s[3]), String.valueOf(s[4])); + } else { + c.setName(name); + } + c.setChannel(String.valueOf(s[1])); + c.setPcode(String.valueOf(s[2])); + c.setManagerName(String.valueOf(s[3])); + c.setPhone(String.valueOf(s[4])); + if (s[5] == null) continue; + String userid = String.valueOf(s[5]); + String password = String.valueOf(s[6]); + boolean active = Boolean.TRUE.equals(s[7]); + int loginCount = ((Number) s[8]).intValue(); + Member m = em.createQuery("select m from Member m where m.groupId='demo' and m.role='SHOP' and m.companyId=:c", Member.class) + .setParameter("c", c.getId()).getResultList().stream().findFirst().orElse(null); + if (m == null) { + List byUser = em.createQuery("select m from Member m where m.userid=:id", Member.class) + .setParameter("id", userid).getResultList(); + if (!byUser.isEmpty()) { + m = byUser.getFirst(); + m.setCompanyId(c.getId()); + m.setRole("SHOP"); + } else { + m = new Member(); + m.setGroupId("demo"); + m.setRole("SHOP"); + m.setCompanyId(c.getId()); + m.setUserid(userid); + m.setPassword(encoder.encode(password)); + em.persist(m); + } + } + m.setUserid(userid); + m.setPassword(encoder.encode(password)); + m.setPlainPassword(password); + m.setName(c.getName()); + m.setPhone(c.getPhone()); + m.setActive(active); + m.setLoginCount(loginCount); + m.setMenuFlags(menuAll); + if (s[9] != null) m.setLastLoginAt(java.time.LocalDateTime.parse(String.valueOf(s[9]))); + } + for (Company c : em.createQuery("select c from Company c where c.groupId='demo' and c.type in ('OUT','SHOP')", Company.class).getResultList()) { + if (c.getChannel() == null || c.getChannel().isBlank()) c.setChannel("기타"); + if (c.getPcode() == null || c.getPcode().isBlank()) c.setPcode("P" + String.format("%04d", c.getId() == null ? 0 : c.getId())); + } + for (Member m : em.createQuery("select m from Member m where m.groupId='demo' and m.role='SHOP'", Member.class).getResultList()) { + if ((m.getPlainPassword() == null || m.getPlainPassword().isBlank()) && "demoshop".equals(m.getUserid())) { + m.setPlainPassword("demoshop"); + } + if (m.getMenuFlags() == null || m.getMenuFlags().isBlank()) m.setMenuFlags(menuAll); + if (m.getLoginCount() == null) m.setLoginCount(0); + } + } + + private void seedAgentShopSettingIfEmpty() { + Long count = em.createQuery("select count(s) from Setting s where s.groupId='demo' and s.settingKey='agentshop'", Long.class).getSingleResult(); + if (count != null && count > 0) return; + Setting s = new Setting(); + s.setGroupId("demo"); + s.setSettingKey("agentshop"); + s.setValue("{\"access\":true,\"policy\":true,\"pds\":true,\"returnPhone\":true,\"indoc\":true,\"scan\":true,\"scanReceiveSms\":false,\"scanReceivePhones\":\"\",\"scanSendSms\":false,\"scanSenderPhone\":\"1600-3903\",\"scanCompleteMsg\":\"스캔신청서가 완료 처리되었습니다.\",\"scanHoldMsg\":\"스캔신청서가 보류 처리되었습니다.\",\"stock\":true,\"stockPeriod\":\"always\",\"open\":true,\"settle\":true,\"farebox\":true,\"shopUrl\":\"https://shop.bizcare.co.kr\"}"); + em.persist(s); + } + + private void seedFareboxSamplesIfEmpty() { + Long count = em.createQuery("select count(e) from Farebox e where e.groupId='demo'", Long.class).getSingleResult(); + if (count != null && count >= 6) return; + List shops = em.createQuery("select c from Company c where c.groupId='demo' and c.type in ('SHOP','OUT') order by c.id", Company.class).getResultList(); + if (shops.isEmpty()) return; + Object[][] samples = { + {LocalDate.of(2025, 11, 16), "SKT", "대박통신", "김*현", "010-****-5555", "수납", "0", "12200", "완결", "", "김대표"}, + {LocalDate.of(2025, 5, 22), "SKT", "늘푸른통신", "1*3", "***-****-****", "수납", "653000", "0", "미결", "", "김대표"}, + {LocalDate.of(2025, 4, 10), "KT엠모바일", "늘푸른통신", "최***신", "010-****-1111", "수납", "20000", "0", "완결", "선불충전", "김대표"}, + {LocalDate.of(2025, 3, 8), "LGT", "테스트", "홍*동", "010-****-2222", "대납", "-20000", "0", "미결", "환불금", "김대표"}, + {LocalDate.of(2025, 2, 1), "SK텔링크", shops.getFirst().getName(), "박*수", "010-****-3333", "수납", "0", "50000", "완결", "요금수납", "김대표"}, + }; + for (int i = 0; i < samples.length; i++) { + Object[] s = samples[i]; + LocalDate date = (LocalDate) s[0]; + String name = String.valueOf(s[3]); + Long exists = em.createQuery("select count(e) from Farebox e where e.groupId='demo' and e.faredate=:d and e.name=:n", Long.class) + .setParameter("d", date).setParameter("n", name).getSingleResult(); + if (exists != null && exists > 0) continue; + Company shop = shops.get(i % shops.size()); + Farebox row = new Farebox(); + row.setGroupId("demo"); + row.setOutcompanyId(shop.getId()); + row.setOutcompanyName(String.valueOf(s[2])); + row.setOutCategory("판매점"); + row.setFaredate(date); + row.setTelcompany(String.valueOf(s[1])); + row.setName(name); + row.setPhone(String.valueOf(s[4])); + row.setHow(String.valueOf(s[5])); + row.setCashprice(new BigDecimal(String.valueOf(s[6]))); + row.setCardprice(new BigDecimal(String.valueOf(s[7]))); + row.setPaystate(String.valueOf(s[8])); + row.setDetail(String.valueOf(s[9])); + row.setRegistrant(String.valueOf(s[10])); + row.setReceiptNo("R-S" + (i + 1)); + em.persist(row); + } + } + + private void seedFareboxPaymentsIfEmpty() { + Long count = em.createQuery("select count(p) from FareboxPayment p where p.groupId='demo'", Long.class).getSingleResult(); + if (count != null && count >= 8) return; + List boxes = em.createQuery("select e from Farebox e where e.groupId='demo' order by e.id", Farebox.class).getResultList(); + Object[][] samples = { + {LocalDate.of(2025, 7, 12), "늘푸른통신", "일반", "653000", "이계통", "정상", "판매점"}, + {LocalDate.of(2025, 7, 10), "D1", "건별", "2500", "재고전용", "정상", "판매점"}, + {LocalDate.of(2025, 6, 20), "대박통신", "일반", "12200", "김대표", "취소됨", "판매점"}, + {LocalDate.of(2025, 5, 15), "늘푸른통신", "건별", "20000", "이계통", "정상", "판매점"}, + {LocalDate.of(2025, 4, 8), "테스트", "일반", "50000", "김대표", "정상", "판매점"}, + {LocalDate.of(2025, 3, 22), "늘붉은통신", "일반", "30000", "재고전용", "정상", "판매점"}, + {LocalDate.of(2025, 2, 14), "대박통신", "건별", "15000", "이계통", "취소됨", "판매점"}, + {LocalDate.of(2025, 1, 5), "D1", "일반", "8800", "김대표", "정상", "판매점"}, + }; + for (int i = 0; i < samples.length; i++) { + Object[] s = samples[i]; + LocalDate paydate = (LocalDate) s[0]; + String outName = String.valueOf(s[1]); + Long exists = em.createQuery( + "select count(p) from FareboxPayment p where p.groupId='demo' and p.paydate=:d and p.outcompanyName=:n and p.amount=:a", Long.class) + .setParameter("d", paydate).setParameter("n", outName).setParameter("a", new BigDecimal(String.valueOf(s[3]))) + .getSingleResult(); + if (exists != null && exists > 0) continue; + FareboxPayment p = new FareboxPayment(); + p.setGroupId("demo"); + p.setPaydate(paydate); + p.setOutcompanyName(outName); + p.setMode(String.valueOf(s[2])); + p.setAmount(new BigDecimal(String.valueOf(s[3]))); + p.setRegistrant(String.valueOf(s[4])); + p.setStatus(String.valueOf(s[5])); + p.setOutCategory(String.valueOf(s[6])); + p.setPayCount(1); + p.setMemo(null); + if (!boxes.isEmpty()) { + Farebox box = boxes.get(i % boxes.size()); + p.setOutcompanyId(box.getOutcompanyId()); + p.setFareboxIds(String.valueOf(box.getId())); + } + em.persist(p); + } + } + + private void seedSmsHistoryIfEmpty() { + Long balCount = em.createQuery("select count(s) from Setting s where s.groupId='demo' and s.settingKey='sms_balance'", Long.class).getSingleResult(); + if (balCount == null || balCount == 0) { + Setting bal = new Setting(); + bal.setGroupId("demo"); + bal.setSettingKey("sms_balance"); + bal.setValue("996"); + em.persist(bal); + } + Long count = em.createQuery("select count(e) from SmsMessage e where e.groupId='demo'", Long.class).getSingleResult(); + if (count != null && count >= 4) return; + Object[][] samples = { + {LocalDateTime.of(2026, 7, 10, 13, 40), "01011111111", "단문", "안녕하세요. 비즈케어 안내 문자입니다.", "김대표"}, + {LocalDateTime.of(2026, 7, 9, 10, 15), "01022222222", "단문", "개통 서류 안내드립니다.", "이계통"}, + {LocalDateTime.of(2026, 7, 8, 16, 5), "01033334444,01055556666", "단문", "유심 수령 안내입니다.", "김대표"}, + {LocalDateTime.of(2026, 7, 7, 9, 30), "01077778888", "단문", "요금수납 확인 부탁드립니다.", "이계통"}, + }; + for (Object[] s : samples) { + LocalDateTime ldt = (LocalDateTime) s[0]; + String phone = String.valueOf(s[1]); + Long exists = em.createQuery( + "select count(e) from SmsMessage e where e.groupId='demo' and e.phone=:p and e.message=:m", Long.class) + .setParameter("p", phone).setParameter("m", String.valueOf(s[3])).getSingleResult(); + if (exists != null && exists > 0) continue; + int sendCount = phone.split(",").length; + SmsMessage row = new SmsMessage(); + row.setGroupId("demo"); + row.setUserid("demo"); + row.setPhone(phone); + row.setMessage(String.valueOf(s[3])); + row.setStatus("발송"); + row.setMsgType(String.valueOf(s[2])); + row.setSenderNumber("1600-3903"); + row.setSenderName(String.valueOf(s[4])); + row.setSendCount(sendCount); + row.setDeductCount(sendCount); + row.setSentAt(ldt.atZone(ZoneId.systemDefault()).toInstant()); + em.persist(row); + } + } + + private void seedSmsAddressIfEmpty() { + Long gCount = em.createQuery("select count(g) from SmsAddressGroup g where g.groupId='demo'", Long.class).getSingleResult(); + if (gCount != null && gCount > 0) return; + SmsAddressGroup g1 = new SmsAddressGroup(); + g1.setGroupId("demo"); + g1.setName("동해물과"); + em.persist(g1); + em.flush(); + SmsAddressGroup g2 = new SmsAddressGroup(); + g2.setGroupId("demo"); + g2.setName("백두산이"); + em.persist(g2); + Object[][] contacts = { + {g1.getId(), "1", "01000000000"}, + {g1.getId(), "홍길동", "01015231244"}, + }; + for (Object[] s : contacts) { + SmsAddressContact c = new SmsAddressContact(); + c.setGroupId("demo"); + c.setAddressGroupId((Long) s[0]); + c.setName(String.valueOf(s[1])); + c.setPhone(String.valueOf(s[2])); + em.persist(c); + } + } + + private void seedStaffMembersIfEmpty() { + // enrich demo owner + List demos = em.createQuery("select m from Member m where m.userid='demo'", Member.class).getResultList(); + if (!demos.isEmpty()) { + Member demo = demos.getFirst(); + if (demo.getStaffPermission() == null || demo.getStaffPermission().isBlank()) { + demo.setStaffPermission("대표"); + demo.setName("김대표"); + demo.setPhone("010-1111-2222"); + if (demo.getLoginCount() == null || demo.getLoginCount() == 0) demo.setLoginCount(5032); + if (demo.getLastLoginAt() == null) demo.setLastLoginAt(LocalDateTime.now().minusMinutes(10)); + if (demo.getAssignedOutNames() == null) demo.setAssignedOutNames("늘푸른통신,대박통신"); + } + } + Long count = em.createQuery("select count(m) from Member m where m.groupId='demo' and m.role='AGENT'", Long.class).getSingleResult(); + if (count != null && count >= 8) return; + Object[][] samples = { + {"demo02", "이총괄", "총괄", "010-2222-3333", "D1,D2", 1204}, + {"demo03", "박재고", "재고", "010-3333-4444", "늘푸른통신", 856}, + {"demo04", "최개통", "개통", "010-4444-5555", "스마트모바일,블루폰", 642}, + {"demo05", "정영업", "영업", "010-5555-6666", "대박통신", 311}, + {"demo06", "한딜러", "딜러", "010-6666-7777", "출고처 샘플", 98}, + {"demo07", "오재고", "재고", "010-7777-8888", "", 45}, + {"demo08", "윤영업", "영업", "010-8888-9999", "늘붉은통신", 22}, + {"demo09", "강총괄", "총괄", "010-9999-1111", "D1", 15}, + {"demo10", "조개통", "개통", "010-1212-3434", "", 8}, + {"demo11", "임영업", "영업", "010-5656-7878", "블루폰", 3}, + {"demo12", "배수납", "영업", "010-9090-1212", "스마트모바일", 1}, + }; + for (int i = 0; i < samples.length; i++) { + Object[] s = samples[i]; + String userid = String.valueOf(s[0]); + Long exists = em.createQuery("select count(m) from Member m where m.userid=:id", Long.class) + .setParameter("id", userid).getSingleResult(); + if (exists != null && exists > 0) continue; + Member m = new Member(); + m.setGroupId("demo"); + m.setUserid(userid); + m.setPassword(encoder.encode("demo123")); + m.setPlainPassword("demo123"); + m.setName(String.valueOf(s[1])); + m.setStaffPermission(String.valueOf(s[2])); + m.setPhone(String.valueOf(s[3])); + String outs = String.valueOf(s[4]); + m.setAssignedOutNames(outs.isBlank() ? null : outs); + m.setRole("AGENT"); + m.setActive(true); + m.setLoginCount((Integer) s[5]); + m.setLastLoginAt(LocalDateTime.now().minusDays(i).minusHours(i % 5)); + em.persist(m); + } + } + + private void seedHomesMembersEnrich() { + // 홈즈 직원관리 원본 샘플 (대표 + demo1~5) + Object[][] samples = { + {"demo", "홍길동", "대표", "010-1234-1234", "대구광역시 동구 동대구로455", "", 205, + LocalDateTime.of(2026, 7, 11, 17, 53), LocalDate.of(2022, 4, 12), "데모 대표계정"}, + {"demo1", "홍총괄", "총괄", "010-1111-1111", "1", "", 6, + LocalDateTime.of(2022, 4, 13, 12, 33), LocalDate.of(2022, 4, 13), null}, + {"demo2", "홍팀장", "팀장", "010-2222-2222", "", "", 3, + LocalDateTime.of(2022, 4, 14, 10, 12), LocalDate.of(2022, 4, 13), null}, + {"demo3", "홍사원", "사원", "010-3333-3333", "", "", 2, + LocalDateTime.of(2022, 4, 13, 16, 36), LocalDate.of(2022, 4, 13), null}, + {"demo4", "홍영업", "영업", "010-4444-4444", "", "", 3, + LocalDateTime.of(2022, 4, 13, 16, 42), LocalDate.of(2022, 4, 13), null}, + {"demo5", "홍딜러", "딜러", "010-5555-5555", "", "", 1, + LocalDateTime.of(2022, 4, 13, 16, 49), LocalDate.of(2022, 4, 13), null}, + }; + java.util.Set keep = new java.util.HashSet<>(); + for (Object[] s : samples) keep.add(String.valueOf(s[0])); + for (Member old : em.createQuery("select m from Member m where m.groupId='demo' and m.role='AGENT'", Member.class).getResultList()) { + if (!keep.contains(old.getUserid())) em.remove(old); + } + em.flush(); + for (Object[] s : samples) { + String userid = String.valueOf(s[0]); + List found = em.createQuery("select m from Member m where m.userid=:id", Member.class) + .setParameter("id", userid).getResultList(); + Member m = found.isEmpty() ? new Member() : found.getFirst(); + m.setGroupId("demo"); + m.setUserid(userid); + m.setName(String.valueOf(s[1])); + m.setStaffPermission(String.valueOf(s[2])); + m.setPhone(String.valueOf(s[3])); + String addr = String.valueOf(s[4]); + m.setAddress(addr.isBlank() ? null : addr); + String limit = String.valueOf(s[5]); + m.setAccessLimit(limit.isBlank() ? null : limit); + m.setLoginCount((Integer) s[6]); + m.setLastLoginAt((LocalDateTime) s[7]); + m.setRole("AGENT"); + m.setActive(true); + if (s[9] != null) m.setMemo(String.valueOf(s[9])); + if (found.isEmpty()) { + m.setPassword(encoder.encode("demo123")); + m.setPlainPassword("demo123"); + em.persist(m); + em.flush(); + } + LocalDate reg = (LocalDate) s[8]; + if (reg != null) { + Instant at = reg.atStartOfDay(ZoneId.of("Asia/Seoul")).toInstant(); + m.setCreatedAt(at); + m.setUpdatedAt(at); + } + } + } + + private void seedIncompaniesIfEmpty() { + Long count = em.createQuery("select count(c) from Company c where c.groupId='demo' and c.type='IN'", Long.class).getSingleResult(); + Object[][] samples = { + {"디엘로(KT)", "LG헬로비전 (KT)", "02-0000-0000", "02-0000-0001", "우담당", "010-0000-0000", "디헬로우", "000-00-00000"}, + {"KT", "KT", "02-1111-1111", "02-1111-1112", "김담당", "010-1111-1111", "케이티", "111-11-11111"}, + {"SKT", "SKT", "02-2222-2222", "02-2222-2223", "이담당", "010-2222-2222", "에스케이티", "222-22-22222"}, + {"LGU+", "LGU+", "02-3333-3333", "", "박담당", "010-3333-3333", "엘지유플러스", "333-33-33333"}, + {"SK텔링크", "SK텔링크", "02-4444-4444", "", "최담당", "010-4444-4444", "텔링크", "444-44-44444"}, + {"KT엠모바일", "KT엠모바일", "02-5555-5555", "", "정담당", "010-5555-5555", "엠모바일", "555-55-55555"}, + {"비즈케어 물류센터", "SKT", "02-5555-1004", "02-5555-1005", "최물류", "010-5555-1004", "비즈케어물류", "666-66-66666"}, + {"입고처A", "KT", "02-1234-5678", "", "김입고", "010-1234-5678", "입고에이", "777-77-77777"}, + }; + if (count != null && count >= 8) { + // enrich existing IN companies missing telecom + List existing = em.createQuery("select c from Company c where c.groupId='demo' and c.type='IN'", Company.class).getResultList(); + for (Company c : existing) { + if (c.getTelecom() == null || c.getTelecom().isBlank()) { + for (Object[] s : samples) { + if (String.valueOf(s[0]).equals(c.getName())) { + c.setTelecom(String.valueOf(s[1])); + c.setFax(blankStr(String.valueOf(s[3]))); + c.setMobile(String.valueOf(s[5])); + c.setBusinessName(String.valueOf(s[6])); + c.setBusinessNumber(String.valueOf(s[7])); + break; + } + } + if (c.getTelecom() == null || c.getTelecom().isBlank()) c.setTelecom("SKT"); + } + } + return; + } + for (Object[] s : samples) { + String name = String.valueOf(s[0]); + Long exists = em.createQuery("select count(c) from Company c where c.groupId='demo' and c.type='IN' and c.name=:n", Long.class) + .setParameter("n", name).getSingleResult(); + if (exists != null && exists > 0) continue; + Company c = new Company(); + c.setGroupId("demo"); + c.setType("IN"); + c.setName(name); + c.setTelecom(String.valueOf(s[1])); + c.setPhone(String.valueOf(s[2])); + c.setFax(blankStr(String.valueOf(s[3]))); + c.setManagerName(String.valueOf(s[4])); + c.setMobile(String.valueOf(s[5])); + c.setBusinessName(String.valueOf(s[6])); + c.setBusinessNumber(String.valueOf(s[7])); + c.setActive(true); + em.persist(c); + } + } + + private String blankStr(String v) { return v == null || v.isBlank() ? null : v; } + + private void seedOutcompaniesEnrich() { + List outs = em.createQuery("select c from Company c where c.groupId='demo' and c.type in ('OUT','SHOP')", Company.class).getResultList(); + Object[][] extras = { + {"대구 D1", "도매", "판매점", "P0001", "김영업", "010-1111-1001", "대구디원", "101-01-10001", "국민 111-111-111111"}, + {"경산 D2", "도매", "판매점", "P0002", "이영업", "010-1111-1002", "경산디투", "102-02-10002", "신한 222-222-222222"}, + {"대구 늘푸른통신", "도매", "판매점", "P0003", "박영업", "010-1111-1003", "늘푸른", "103-03-10003", "우리 333-333-333333"}, + {"스마트모바일", "소매", "판매점", "P0004", "최영업", "010-1111-1004", "스마트", "104-04-10004", "하나 444-444-444444"}, + {"블루폰", "특판", "판매점", "P0005", "정영업", "010-1111-1005", "블루폰", "105-05-10005", "농협 555-555-555555"}, + {"늘붉은통신", "도매", "판매점", "P1000", "홍길동", "010-1234-5678", "늘붉은통신", "106-06-10006", "국민은행 000-000-0000000 홍길동"}, + {"출고처 샘플", "기타", "출고처", "P2000", "이출고", "010-4444-5555", "출고샘플", "107-07-10007", null}, + }; + Map byName = new LinkedHashMap<>(); + for (Object[] s : extras) byName.put(String.valueOf(s[0]), s); + for (Company c : outs) { + Object[] s = byName.get(c.getName()); + if (s != null) { + if (blankStr(c.getChannel()) == null) c.setChannel(String.valueOf(s[1])); + if (blankStr(c.getCategory()) == null) c.setCategory(String.valueOf(s[2])); + if (blankStr(c.getPcode()) == null) c.setPcode(String.valueOf(s[3])); + if (blankStr(c.getSalesperson()) == null) c.setSalesperson(String.valueOf(s[4])); + if (blankStr(c.getMobile()) == null) c.setMobile(String.valueOf(s[5])); + if (blankStr(c.getBusinessName()) == null) c.setBusinessName(String.valueOf(s[6])); + if (blankStr(c.getBusinessNumber()) == null) c.setBusinessNumber(String.valueOf(s[7])); + if (blankStr(c.getBankAccount()) == null && s[8] != null) c.setBankAccount(String.valueOf(s[8])); + } else { + if (blankStr(c.getChannel()) == null) c.setChannel("도매"); + if (blankStr(c.getCategory()) == null) c.setCategory("SHOP".equals(c.getType()) ? "판매점" : "출고처"); + if (blankStr(c.getSalesperson()) == null) c.setSalesperson(c.getManagerName()); + } + } + // ensure sample names exist + for (Object[] s : extras) { + String name = String.valueOf(s[0]); + boolean exists = outs.stream().anyMatch(c -> name.equals(c.getName())); + if (exists) continue; + Company c = new Company(); + c.setGroupId("demo"); + c.setType("OUT"); + c.setName(name); + c.setChannel(String.valueOf(s[1])); + c.setCategory(String.valueOf(s[2])); + c.setPcode(String.valueOf(s[3])); + c.setSalesperson(String.valueOf(s[4])); + c.setManagerName(String.valueOf(s[4])); + c.setPhone(String.valueOf(s[5])); + c.setMobile(String.valueOf(s[5])); + c.setBusinessName(String.valueOf(s[6])); + c.setBusinessNumber(String.valueOf(s[7])); + if (s[8] != null) c.setBankAccount(String.valueOf(s[8])); + c.setActive(true); + em.persist(c); + } + } + + private void seedDeliveriesIfEmpty() { + Long count = em.createQuery("select count(c) from Company c where c.groupId='demo' and c.type='DELIVERY'", Long.class).getSingleResult(); + if (count != null && count >= 3) return; + Object[][] samples = { + {"대한통운", "1588-1255", null}, + {"오성퀵", "000-0000-0000", null}, + {"해운퀵", "000-0000-0000", null}, + }; + for (Object[] s : samples) { + String name = String.valueOf(s[0]); + Long exists = em.createQuery("select count(c) from Company c where c.groupId='demo' and c.type='DELIVERY' and c.name=:n", Long.class) + .setParameter("n", name).getSingleResult(); + if (exists != null && exists > 0) continue; + Company c = new Company(); + c.setGroupId("demo"); + c.setType("DELIVERY"); + c.setName(name); + c.setPhone(String.valueOf(s[1])); + c.setMemo(s[2] == null ? null : String.valueOf(s[2])); + c.setActive(true); + em.persist(c); + } + } + + private void seedPreferenceIfEmpty() { + Long count = em.createQuery("select count(s) from Setting s where s.groupId='demo' and s.settingKey='preference'", Long.class).getSingleResult(); + if (count != null && count > 0) return; + String json = "{" + + "\"company\":{" + + "\"name\":\"에이전트 데모\"," + + "\"managerName\":\"김대표\"," + + "\"mobile\":\"010-0000-0000\"," + + "\"businessName\":\"에이전트 데모\"," + + "\"businessNumber\":\"123-45-67890\"," + + "\"ceo\":\"김유신\"," + + "\"bizType\":\"도소매\"," + + "\"bizItem\":\"휴대폰판매\"," + + "\"address\":\"대구시 수성구 수성4가 먼길\"" + + "}," + + "\"accessIps\":{\"enabled\":false,\"ips\":[]}," + + "\"masking\":{\"enabled\":true,\"name\":true,\"phone\":true,\"rrn\":true,\"address\":false}," + + "\"outCategories\":[\"판매점\",\"도매\"]," + + "\"stock\":{\"duplicateSerialCheck\":true,\"allowNegative\":false,\"autoRecallDays\":0}," + + "\"open\":{\"requireUsim\":true,\"requirePhone\":true,\"defaultOpenHow\":\"\",\"autoSettle\":false}" + + "}"; + Setting s = new Setting(); + s.setGroupId("demo"); + s.setSettingKey("preference"); + s.setValue(json); + em.persist(s); + } + + private void seedCsNoticesIfEmpty() { + Long count = em.createQuery("select count(b) from BbsPost b where b.groupId='demo' and b.code='cs'", Long.class).getSingleResult(); + if (count != null && count >= 20) return; + Object[][] samples = { + {"고객센터 5월 4일(월) 임시휴무안내", 6, LocalDate.of(2026, 4, 29)}, + {"지원금표기 관련 설정업데이트", 7, LocalDate.of(2025, 7, 30)}, + {"지원금표기 관련 설정업데이트 안내", 8, LocalDate.of(2025, 7, 30)}, + {"개통등록 화면 개선 안내", 12, LocalDate.of(2025, 7, 15)}, + {"문자발송 시스템 점검 안내", 18, LocalDate.of(2025, 6, 20)}, + {"요금수납 기능 업데이트 안내", 22, LocalDate.of(2025, 5, 28)}, + {"개인정보 마스킹 기능 추가 안내", 31, LocalDate.of(2025, 4, 10)}, + {"출고처 분류 설정 방법 안내", 15, LocalDate.of(2025, 3, 22)}, + {"재고관리 바코드 스캔 개선", 27, LocalDate.of(2025, 2, 14)}, + {"정산자료 엑셀다운로드 오류 수정", 19, LocalDate.of(2025, 1, 20)}, + {"연말연시 고객센터 운영안내", 45, LocalDate.of(2024, 12, 24)}, + {"에이전트샵 접속 장애 복구 안내", 38, LocalDate.of(2024, 11, 8)}, + {"보안 업데이트 적용 안내", 52, LocalDate.of(2024, 10, 15)}, + {"신규 통신사 요금제 등록 안내", 24, LocalDate.of(2024, 9, 3)}, + {"모바일 앱 버전 업데이트 안내", 41, LocalDate.of(2024, 8, 12)}, + {"스캔신청서 접수 문자 설정 안내", 16, LocalDate.of(2024, 7, 25)}, + {"서버 정기점검 안내 (7/20)", 33, LocalDate.of(2024, 7, 18)}, + {"개통내역 일괄삭제 기능 추가", 29, LocalDate.of(2024, 6, 5)}, + {"유심거래장부 메뉴 오픈 안내", 21, LocalDate.of(2024, 5, 17)}, + {"비밀번호 변경 권고 안내", 67, LocalDate.of(2024, 4, 2)}, + {"고객센터 설 연휴 휴무안내", 55, LocalDate.of(2024, 2, 8)}, + {"프로그램 이용약관 개정 안내", 48, LocalDate.of(2024, 1, 15)}, + {"엑셀 업로드 양식 변경 안내", 36, LocalDate.of(2023, 12, 1)}, + {"출고처 일괄등록 기능 추가", 28, LocalDate.of(2023, 10, 20)}, + {"알림톡 발송 서비스 오픈", 44, LocalDate.of(2023, 9, 8)}, + {"재고실사 기능 사용방법 안내", 39, LocalDate.of(2023, 8, 14)}, + {"로그인 2단계 인증 안내", 61, LocalDate.of(2023, 7, 3)}, + {"고객센터 추석 연휴 휴무안내", 50, LocalDate.of(2023, 9, 25)}, + {"개통수수료 정산 로직 변경", 34, LocalDate.of(2023, 6, 12)}, + {"브라우저 권장사양 안내", 72, LocalDate.of(2023, 5, 1)}, + }; + for (Object[] s : samples) { + String title = String.valueOf(s[0]); + Long exists = em.createQuery("select count(b) from BbsPost b where b.groupId='demo' and b.code='cs' and b.title=:t", Long.class) + .setParameter("t", title).getSingleResult(); + if (exists != null && exists > 0) continue; + BbsPost b = new BbsPost(); + b.setGroupId("demo"); + b.setCode("cs"); + b.setTitle(title); + b.setContents("

" + title + "

자세한 내용은 고객센터(1600-3903)로 문의해 주세요.

"); + b.setAuthorName("비즈케어"); + b.setUserid("system"); + b.setTargetGroup("전체"); + b.setNotice(false); + b.setViews((Integer) s[1]); + b.setConfirmCount(0); + b.setState("1"); + em.persist(b); + em.flush(); + LocalDate d = (LocalDate) s[2]; + b.setCreatedAt(d.atStartOfDay(ZoneId.of("Asia/Seoul")).toInstant()); + b.setUpdatedAt(b.getCreatedAt()); + } + } + + private void seedCsInquiriesIfEmpty() { + Long count = em.createQuery("select count(c) from CsInquiry c where c.groupId='demo'", Long.class).getSingleResult(); + // ensure sample matching original screenshot exists + Long test3 = em.createQuery("select count(c) from CsInquiry c where c.groupId='demo' and c.title='test3'", Long.class).getSingleResult(); + if (test3 == null || test3 == 0) { + CsInquiry c = new CsInquiry(); + c.setGroupId("demo"); + c.setUserid("demo"); + c.setAuthorName("김대표"); + c.setTitle("test3"); + c.setContent("테스트 문의입니다."); + c.setReply("확인되었습니다. 추가 문의사항이 있으시면 남겨주세요."); + c.setStatus("답변완료"); + c.setReplyCount(1); + em.persist(c); + em.flush(); + c.setCreatedAt(LocalDate.of(2019, 11, 5).atStartOfDay(ZoneId.of("Asia/Seoul")).toInstant()); + c.setUpdatedAt(c.getCreatedAt()); + } + if (count != null && count >= 1) { + for (CsInquiry c : em.createQuery("select c from CsInquiry c where c.groupId='demo'", CsInquiry.class).getResultList()) { + if (c.getAuthorName() == null || c.getAuthorName().isBlank()) c.setAuthorName("김대표"); + if (c.getStatus() == null || "OPEN".equalsIgnoreCase(c.getStatus())) c.setStatus("답변대기"); + if ("DONE".equalsIgnoreCase(c.getStatus())) c.setStatus("답변완료"); + if (c.getReplyCount() == null) c.setReplyCount(c.getReply() != null && !c.getReply().isBlank() ? 1 : 0); + } + } + } + + private void seedInternalBoardIfEmpty() { + Long count = em.createQuery("select count(b) from BbsPost b where b.groupId='demo' and b.code='company'", Long.class).getSingleResult(); + if (count != null && count >= 5) return; + Object[][] samples = { + {"7", "김대표", 0, LocalDate.of(2023, 6, 23), 0}, + {"업무공지", "김대표", 20, LocalDate.of(2021, 2, 15), 0}, + {"zcxvzxcv", "김대표", 6, LocalDate.of(2020, 11, 13), 0}, + {"124", "오영업", 14, LocalDate.of(2019, 8, 8), 1}, + {"124", "김대표", 8, LocalDate.of(2019, 8, 2), 0}, + }; + for (Object[] s : samples) { + String title = String.valueOf(s[0]); + String author = String.valueOf(s[1]); + LocalDate date = (LocalDate) s[3]; + Long exists = em.createQuery( + "select count(b) from BbsPost b where b.groupId='demo' and b.code='company' and b.title=:t and b.authorName=:a", Long.class) + .setParameter("t", title).setParameter("a", author).getSingleResult(); + if (exists != null && exists > 0) { + // still update date/views for matching sample + continue; + } + BbsPost b = new BbsPost(); + b.setGroupId("demo"); + b.setCode("company"); + b.setTitle(title); + b.setContents("

" + title + "

"); + b.setAuthorName(author); + b.setUserid("demo"); + b.setTargetGroup("전체"); + b.setNotice(false); + b.setViews((Integer) s[2]); + b.setCommentCount((Integer) s[4]); + b.setConfirmCount(0); + b.setState("1"); + em.persist(b); + em.flush(); + b.setCreatedAt(date.atStartOfDay(ZoneId.of("Asia/Seoul")).toInstant()); + b.setUpdatedAt(b.getCreatedAt()); + } + } + + private void seedScheduleSampleIfEmpty() { + LocalDate d = LocalDate.of(2026, 7, 11); + Long exists = em.createQuery( + "select count(s) from ScheduleItem s where s.groupId='demo' and s.sdate=:d and s.contents=:c", Long.class) + .setParameter("d", d).setParameter("c", "ㅇㄴㅁㄹㅇㄴㅁ").getSingleResult(); + if (exists != null && exists > 0) return; + ScheduleItem s = new ScheduleItem(); + s.setGroupId("demo"); + s.setUserid("demo"); + s.setSdate(d); + s.setStime("01:30"); + s.setContents("ㅇㄴㅁㄹㅇㄴㅁ"); + em.persist(s); + } + + private void seedPostitsIfEmpty() { + String json = "[" + + "{\"slot\":1,\"title\":\"포스트잇 #1\",\"contents\":\"신규 예약 개통 오예\",\"authorName\":\"김대표\",\"noteDate\":\"26-06-08\"}," + + "{\"slot\":2,\"title\":\"포스트잇 #2\",\"contents\":\"demo\",\"authorName\":\"김대표\",\"noteDate\":\"26-06-08\"}," + + "{\"slot\":3,\"title\":\"포스트잇 #3\",\"contents\":\"유심 발주 요청\",\"authorName\":\"이과장\",\"noteDate\":\"26-06-05\"}," + + "{\"slot\":4,\"title\":\"포스트잇 #4\",\"contents\":\"정산서 마감 체크\",\"authorName\":\"김대표\",\"noteDate\":\"26-05-28\"}," + + "{\"slot\":5,\"title\":\"포스트잇 #5\",\"contents\":\"스캔자료 검토\",\"authorName\":\"오영업\",\"noteDate\":\"26-05-20\"}," + + "{\"slot\":6,\"title\":\"포스트잇 #6\",\"contents\":\"정책단가 업데이트\",\"authorName\":\"정총괄\",\"noteDate\":\"26-05-12\"}," + + "{\"slot\":7,\"title\":\"포스트잇 #7\",\"contents\":\"고객센터 휴무 안내\",\"authorName\":\"김대표\",\"noteDate\":\"26-04-29\"}," + + "{\"slot\":8,\"title\":\"포스트잇 #8\",\"contents\":\"월간 리포트 작성\",\"authorName\":\"이과장\",\"noteDate\":\"26-04-15\"}" + + "]"; + List rows = em.createQuery("select s from Setting s where s.groupId='demo' and s.settingKey='postits'", Setting.class).getResultList(); + if (rows.isEmpty()) { + Setting s = new Setting(); + s.setGroupId("demo"); + s.setSettingKey("postits"); + s.setValue(json); + em.persist(s); + return; + } + Setting existing = rows.get(0); + if (existing.getValue() != null && existing.getValue().contains("재고실사")) { + existing.setValue(json); + } + } + + private void seedOpenStatsDemo() { + LocalDate today = LocalDate.now(); + LocalDate day8 = today.withDayOfMonth(Math.min(8, today.lengthOfMonth())); + LocalDate day9 = today.withDayOfMonth(Math.min(9, today.lengthOfMonth())); + List shops = em.createQuery("select c from Company c where c.groupId='demo' and c.type in ('SHOP','OUT') order by c.id", Company.class).getResultList(); + List ins = em.createQuery("select c from Company c where c.groupId='demo' and c.type='IN' order by c.id", Company.class).getResultList(); + if (shops.isEmpty()) return; + Long inId = ins.isEmpty() ? null : ins.getFirst().getId(); + ensureOpenStatSample(day8, "기변", "20000", shops.getFirst().getId(), inId, "SM-S928N"); + ensureOpenStatSample(day9, "보상", "25000", shops.get(Math.min(1, shops.size() - 1)).getId(), inId, "SM-A175N"); + LocalDate apr = LocalDate.of(today.getYear(), 4, 12); + LocalDate jul = LocalDate.of(today.getYear(), 7, Math.min(10, today.lengthOfMonth())); + if (shops.size() > 2) ensureOpenStatSample(apr, "신규", "18000", shops.get(2).getId(), inId, "SM-F766NK"); + if (shops.size() > 3) ensureOpenStatSample(apr, "MNP", "22000", shops.get(3).getId(), + ins.size() > 1 ? ins.get(1).getId() : inId, "SM-S942NK"); + ensureOpenStatSample(jul, "신규", "21000", shops.getFirst().getId(), inId, "SM-S942N_256G"); + if (shops.size() > 1) ensureOpenStatSample(jul, "기변", "19000", shops.get(1).getId(), inId, "SM-A175N"); + } + + private void ensureOpenStatSample(LocalDate date, String openhow, String margin, Long shopId, Long inId) { + ensureOpenStatSample(date, openhow, margin, shopId, inId, "SM-S928N"); + } + + private void ensureOpenStatSample(LocalDate date, String openhow, String margin, Long shopId, Long inId, String model) { + Long count = em.createQuery( + "select count(o) from OpenRecord o where o.groupId='demo' and o.opendate=:d and o.openhow=:h and o.pmodel=:m", Long.class) + .setParameter("d", date).setParameter("h", openhow).setParameter("m", model).getSingleResult(); + if (count != null && count > 0) return; + OpenRecord open = new OpenRecord(); + open.setGroupId("demo"); + open.setOutcompanyId(shopId); + open.setIncompanyId(inId); + open.setGubun("할부"); + open.setTelecom("SKT"); + open.setOpenhow(openhow); + open.setName("통계*샘플"); + open.setOpenphone("010-****-0000"); + open.setOpendate(date); + open.setOpenHour(11); + open.setOpenMinute(0); + open.setOpenStatus("정상"); + open.setOutCategory("판매점"); + open.setSalesperson("김영업"); + open.setEmployee("demo"); + open.setPlan("5GX 프라임"); + open.setPmodel(model); + open.setPserial("STAT" + date.getMonthValue() + date.getDayOfMonth() + model.hashCode()); + open.setSellprice(BigDecimal.ZERO); + open.setDealermargin1(new BigDecimal(margin)); + open.setDealermargin2(BigDecimal.ZERO); + open.setState("OPEN"); + open.setUserid("demo"); + open.setUid("open-stat-" + openhow + "-" + date + "-" + model); + em.persist(open); + } + + private void seedHomeIndocsIfEmpty() { + Long count = em.createQuery("select count(d) from HomeIncompleteDoc d where d.groupId='demo'", Long.class).getSingleResult(); + if (count > 0) return; + List shops = em.createQuery("select c from Company c where c.groupId='demo' and c.type in ('SHOP','OUT')", Company.class).getResultList(); + List ins = em.createQuery("select c from Company c where c.groupId='demo' and c.type='IN'", Company.class).getResultList(); + if (shops.isEmpty()) return; + Long inId = ins.isEmpty() ? null : ins.getFirst().getId(); + Object[][] samples = { + {"SKT", "김*근", "010-****-3566", "인터넷,TV", "신분증사본", "30000", "서류미비"}, + {"KT", "박*지", "010-****-1122", "인터넷", "통장사본", "20000", "추가서류"}, + {"LGU+", "이*수", "010-****-5678", "TV,집전화", "인감증명서", "50000", "대리신청"}, + }; + for (int i = 0; i < samples.length; i++) { + Object[] s = samples[i]; + HomeIncompleteDoc d = new HomeIncompleteDoc(); + d.setGroupId("demo"); + d.setOutcompanyId(shops.get(i % shops.size()).getId()); + d.setIncompanyId(inId); + d.setOutcompanyName(shops.get(i % shops.size()).getName()); + d.setTelecom(String.valueOf(s[0])); + d.setCustomerName(String.valueOf(s[1])); + d.setPhone(String.valueOf(s[2])); + d.setProducts(String.valueOf(s[3])); + d.setMissingDocs(String.valueOf(s[4])); + d.setDeductAmount(new BigDecimal(String.valueOf(s[5]))); + d.setDeductReason(String.valueOf(s[6])); + d.setInstallDate(LocalDate.now().minusDays(i * 2)); + d.setDeadline(LocalDate.now().plusDays(7 - i)); + d.setStatus("미비"); + em.persist(d); + } + } + + private void seedHomeSalesIfEmpty() { + Long count = em.createQuery("select count(h) from HomeSale h where h.groupId='demo'", Long.class).getSingleResult(); + if (count > 0) return; + List shops = em.createQuery("select c from Company c where c.groupId='demo' and c.type in ('SHOP','OUT')", Company.class).getResultList(); + List ins = em.createQuery("select c from Company c where c.groupId='demo' and c.type='IN'", Company.class).getResultList(); + if (shops.isEmpty()) return; + Long inId = ins.isEmpty() ? null : ins.getFirst().getId(); + Object[][] samples = { + {"완료", "SKT", "인터넷,TV", "김*근", "010-****-3566", "-700000", "50000", "D2"}, + {"대기", "KT", "인터넷", "박*지", "010-****-1122", "0", "30000", "늘푸른통신"}, + {"완료", "LGU+", "TV,집전화", "이*수", "010-****-5678", "-1000000", "80000", "스마트모바일"}, + {"완료", "SKT", "홈IOT,기타", "최*아", "010-****-7788", "-200000", "20000", "블루폰"}, + {"대기", "KT", "소호,원스톱", "정*호", "010-****-9900", "0", "15000", "메인텔레콤"}, + {"완료", "SKT", "인터넷전화,신용카드", "홍*동", "010-****-2222", "-350000", "40000", "늘붉은통신"}, + {"완료", "LGU+", "후결합,재약정", "윤*희", "010-****-3344", "-150000", "25000", "D1"}, + {"대기", "KT", "렌탈", "한*진", "010-****-4455", "0", "10000", "늘푸른통신"}, + }; + for (int i = 0; i < samples.length; i++) { + Object[] s = samples[i]; + HomeSale h = new HomeSale(); + h.setGroupId("demo"); + h.setOutcompanyId(shops.get(i % shops.size()).getId()); + h.setIncompanyId(inId); + h.setStatus(String.valueOf(s[0])); + h.setTelecom(String.valueOf(s[1])); + h.setProducts(String.valueOf(s[2])); + h.setCustomerName(String.valueOf(s[3])); + h.setPhone(String.valueOf(s[4])); + h.setSettleAmount(new BigDecimal(String.valueOf(s[5]))); + h.setMargin(new BigDecimal(String.valueOf(s[6]))); + h.setOutcompanyName(String.valueOf(s[7])); + h.setOutCategory("판매점"); + h.setEmployee("demo"); + h.setInstallDate(LocalDate.now().minusDays(i)); + h.setReceiptDate(LocalDate.now().minusDays(i + 1)); + h.setMissingDocs("관계없음"); + h.setExtraPolicy(BigDecimal.ZERO); + h.setAddress("서울시 강남구"); + em.persist(h); + } + } + + private void seedCareHomesIfEmpty() { + if (em.createQuery("select count(p) from HomesProduct p where p.groupId='demo'", Long.class).getSingleResult() == 0) { + Object[][] products = { + {"INTERNET", "인터넷", "KT", "안심 인터넷 베이직 와이드", null, 36, "80000", 3}, + {"INTERNET", "인터넷", "KT", "인터넷 슬림", null, 36, "50000", 1}, + {"INTERNET", "인터넷", "KT", "기가인터넷 500M", null, 36, "80000", 0}, + {"INTERNET", "인터넷", "KT", "기가인터넷 1G", null, 36, "90000", 2}, + {"INTERNET", "인터넷", "KT", "인터넷 베이직 와이파이", null, 36, "60000", 1}, + {"INTERNET", "인터넷", "KT", "인터넷 에센스", null, 36, "70000", 0}, + {"INTERNET", "인터넷", "KT", "인터넷 베이직", null, 36, "55000", 0}, + {"INTERNET", "인터넷", "KT", "인터넷 프리미엄", null, 36, "100000", 3}, + {"INTERNET", "인터넷", "SKB", "B tv 인터넷", null, 36, "75000", 1}, + {"INTERNET", "인터넷", "LGU+", "U+ 인터넷", null, 36, "70000", 0}, + {"INTERNET", "TV", "KT", "올레tv 베이직", null, 36, "30000", 1}, + {"INTERNET", "TV", "KT", "올레tv 슬림", null, 36, "25000", 0}, + {"INTERNET", "TV", "KT", "지니 TV 프리미엄", null, 36, "40000", 2}, + {"INTERNET", "전화", "KT", "집전화 기본", null, 24, "10000", 0}, + {"INTERNET", "전화", "KT", "집전화 스마트", null, 24, "15000", 1}, + {"INTERNET", "와이파이", "KT", "와이파이 공유기", null, 24, "15000", 0}, + {"INTERNET", "와이파이", "SKB", "와이파이 메시", null, 24, "20000", 1}, + {"INTERNET", "스마트홈", "LGU+", "스마트홈 패키지", null, 36, "20000", 0}, + {"INTERNET", "스마트홈", "KT", "GiGA IoT 홈캠", null, 36, "25000", 1}, + {"INTERNET", "기타", "KT", "기타 부가상품", null, 12, "5000", 0}, + {"HOME", "정수기", "청호나이스", "450 (냉온정)", "WP-40C9500M", 60, "120000", 1}, + {"HOME", "정수기", "청호나이스", "550 (얼음냉온정)", "WI-55S9500M", 60, "150000", 0}, + {"HOME", "정수기", "청호나이스", "Compact (정)", "WP-CP1000", 60, "90000", 0}, + {"HOME", "정수기", "청호나이스", "700 (얼음냉온정)", "WI-70S9500M", 60, "180000", 1}, + {"HOME", "정수기", "코웨이", "아이콘 정수기", "CHP-7210N", 60, "130000", 0}, + {"HOME", "정수기", "코웨이", "냉온정수기", "CHP-5610L", 36, "110000", 1}, + {"HOME", "정수기", "SK매직", "스스로살균 정수기", "WPU-A110C", 60, "100000", 0}, + {"HOME", "정수기", "LG전자", "퓨리케어 오브제", "WD503AS", 60, "140000", 0}, + {"HOME", "공기청정기", "LG전자", "퓨리케어 360", "AS180DWFA", 36, "90000", 0}, + {"HOME", "공기청정기", "코웨이", "에어메가", "AP-2021A", 36, "85000", 1}, + {"HOME", "공기청정기", "삼성", "비스포크 큐브", "AX60A", 36, "95000", 0}, + {"HOME", "비데", "SK매직", "비데 기본형", "BIDET-01", 36, "50000", 0}, + {"HOME", "비데", "코웨이", "스스로케어 비데", "BAU-20", 36, "60000", 1}, + {"HOME", "비데", "청호나이스", "방수비데", "BA-17", 0, "45000", 0}, + {"HOME", "매트리스", "코웨이", "매트리스 퀸", "MT-Q", 60, "150000", 0}, + {"HOME", "매트리스", "코웨이", "매트리스 킹", "MT-K", 60, "180000", 1}, + {"HOME", "매트리스", "시몬스", "뷰티레스트", "BR-Q", 0, "200000", 0}, + {"HOME", "가전", "삼성", "김치냉장고", "RQ33A74", 36, "80000", 0}, + {"HOME", "가전", "LG전자", "디오스 냉장고", "M872GBB", 36, "100000", 0}, + {"HOME", "가전", "위닉스", "제습기", "DXAE100-KEK", 24, "40000", 1}, + {"HOME", "기타", "기타", "렌탈 기타상품", "ETC-01", 12, "30000", 0}, + }; + for (Object[] s : products) { + HomesProduct p = new HomesProduct(); + p.setGroupId("demo"); + p.setKind(String.valueOf(s[0])); + p.setCategory(String.valueOf(s[1])); + p.setCompany(String.valueOf(s[2])); + p.setName(String.valueOf(s[3])); + p.setModel(s[4] == null ? null : String.valueOf(s[4])); + p.setMonths((Integer) s[5]); + p.setPolicyAmount(new BigDecimal(String.valueOf(s[6]))); + p.setPolicyCount((Integer) s[7]); + em.persist(p); + } + } + if (em.createQuery("select count(c) from HomesCustomer c where c.groupId='demo'", Long.class).getSingleResult() == 0) { + Object[][] customers = { + {"단골", "개인", "임*무", "010-1111-3203", "010-2222-8715", "서울시 강남구", "김사원", LocalDate.of(2024, 4, 26)}, + {"단골", "개인", "홍*동", "010-3333-2222", "", "서울시 서초구", "홍길동", LocalDate.of(2024, 4, 26)}, + {"단골1", "개인", "박*지", "010-2222-1122", "010-9999-1122", "서울시 마포구", "홍길동", LocalDate.of(2024, 5, 10)}, + {"단골2", "사업자", "최*아", "010-4444-7788", "", "인천시 연수구", "김대표", LocalDate.of(2024, 6, 1)}, + {"단골3", "개인", "정*민", "010-5555-9900", "010-5555-1111", "부산시 해운대", "홍총괄", LocalDate.of(2024, 7, 15)}, + {"분류없음", "개인", "이*수", "010-3333-5678", "", "경기도 성남시", "김대표", LocalDate.of(2024, 8, 20)}, + {"단골4", "개인", "김*근", "010-1111-3566", "", "서울시 송파구", "김사원", LocalDate.of(2024, 9, 5)}, + }; + for (Object[] s : customers) { + HomesCustomer c = new HomesCustomer(); + c.setGroupId("demo"); + c.setGrade(String.valueOf(s[0])); + c.setCustomerType(String.valueOf(s[1])); + c.setName(String.valueOf(s[2])); + c.setPhone(String.valueOf(s[3])); + c.setPhoneAlt(String.valueOf(s[4])); + c.setAddress(String.valueOf(s[5])); + c.setEmployee(String.valueOf(s[6])); + c.setRegisteredDate((LocalDate) s[7]); + em.persist(c); + } + } else { + for (HomesCustomer c : em.createQuery("select c from HomesCustomer c where c.groupId='demo'", HomesCustomer.class).getResultList()) { + if (c.getCustomerType() == null || c.getCustomerType().isBlank()) c.setCustomerType("개인"); + if ("일반".equals(c.getGrade())) c.setGrade("분류없음"); + if (c.getRegisteredDate() == null && c.getCreatedAt() != null) { + c.setRegisteredDate(c.getCreatedAt().atZone(ZoneId.systemDefault()).toLocalDate()); + } + if (c.getRegisteredDate() == null) c.setRegisteredDate(LocalDate.now().minusDays(30)); + } + } + if (em.createQuery("select count(c) from HomesContract c where c.groupId='demo'", Long.class).getSingleResult() == 0) { + Object[][] contracts = { + {"INTERNET", "완료", "홍*동", "010-1111-1019", "광랜인터넷 와이파이, B tv All", "30150000", "28000000", "-940000", "SK브로드밴드", "홍길동"}, + {"INTERNET", "완료", "김*수", "010-2222-3344", "기가인터넷 500M", "80000", "70000", "10000", "늘푸른통신", "홍길동"}, + {"INTERNET", "완료", "이*아", "010-3333-5566", "올레tv 베이직, 집전화 기본", "40000", "35000", "5000", "(주)코웨이", "홍총괄"}, + {"INTERNET", "접수", "박*지", "010-2222-1122", "올레tv 베이직", "30000", "25000", "5000", "대박통신", "김대표"}, + {"INTERNET", "신청", "이*수", "010-3333-5678", "와이파이 공유기", "15000", "12000", "3000", "스마트홈", "홍길동"}, + {"INTERNET", "철회", "최*민", "010-7777-8899", "인터넷 슬림", "50000", "0", "0", "대박통신", "홍총괄"}, + {"INTERNET", "완료", "정*희", "010-8888-9900", "기가인터넷 1G", "90000", "80000", "10000", "늘푸른통신", "김대표"}, + {"HOME", "완료", "최*아", "010-4444-7788", "냉온정수기", "120000", "100000", "20000", "늘푸른통신", "김대표"}, + {"HOME", "접수", "정*민", "010-5555-9900", "공기청정기", "90000", "75000", "15000", "블루홈", "홍길동"}, + {"HOME", "철회", "윤*희", "010-6666-1122", "비데 기본형", "50000", "0", "0", "대박통신", "홍총괄"}, + }; + for (int i = 0; i < contracts.length; i++) { + Object[] s = contracts[i]; + HomesContract c = new HomesContract(); + c.setGroupId("demo"); + c.setKind(String.valueOf(s[0])); + c.setStatus(String.valueOf(s[1])); + c.setCustomerType("개인"); + c.setCustomerName(String.valueOf(s[2])); + c.setPhone(String.valueOf(s[3])); + c.setProducts(String.valueOf(s[4])); + c.setPolicySum(new BigDecimal(String.valueOf(s[5]))); + c.setSettleAmount(new BigDecimal(String.valueOf(s[6]))); + c.setMargin(new BigDecimal(String.valueOf(s[7]))); + c.setAgency(String.valueOf(s[8])); + c.setEmployee(String.valueOf(s[9])); + c.setPayMethod("자동이체"); + c.setContractDate(LocalDate.now().minusDays(i * 2L)); + c.setInstallDate("완료".equals(s[1]) ? LocalDate.now().minusDays(i) : null); + c.setMonths(36); + c.setAddress("서울시"); + c.setCustomerGrade("분류없음"); + c.setNationality("내국인"); + em.persist(c); + } + } + if (em.createQuery("select count(b) from HomesBalance b where b.groupId='demo'", Long.class).getSingleResult() == 0) { + Object[][] balances = { + {"정산차감", "하부점", "늘푸른통신", "서류미비", "-21124", "서류 보완 요청"}, + {"정산추가", "직원", "김대표", "추가정책", "30000", null}, + {"정산차감", "직원", "김대표", "철회차감", "-10000", "철회 건"}, + {"정산추가", "하부점", "대박통신", "프로모션", "15000", null}, + {"정산차감", "하부점", "대박통신", "오등록", "-5000", null}, + {"정산추가", "직원", "홍길동", "인센티브", "20000", "월 인센티브"}, + {"정산차감", "직원", "홍길동", "과지급", "-8000", null}, + }; + for (int i = 0; i < balances.length; i++) { + Object[] s = balances[i]; + HomesBalance b = new HomesBalance(); + b.setGroupId("demo"); + b.setBalanceType(String.valueOf(s[0])); + b.setTargetType(String.valueOf(s[1])); + b.setTargetName(String.valueOf(s[2])); + b.setReason(String.valueOf(s[3])); + b.setAmount(new BigDecimal(String.valueOf(s[4]))); + b.setMemo(s[5] == null ? null : String.valueOf(s[5])); + b.setBaseDate(LocalDate.now().minusDays(i)); + if ("하부점".equals(b.getTargetType())) b.setAgency(b.getTargetName()); + else b.setCustomerName(b.getTargetName()); + em.persist(b); + } + } else { + // 구 데이터(차감/추가) → 원본 라벨로 정규화 + 대상 필드 보강 + for (HomesBalance b : em.createQuery("select b from HomesBalance b where b.groupId='demo'", HomesBalance.class).getResultList()) { + if ("차감".equals(b.getBalanceType())) b.setBalanceType("정산차감"); + if ("추가".equals(b.getBalanceType())) b.setBalanceType("정산추가"); + if (b.getTargetType() == null || b.getTargetType().isBlank()) { + if (b.getAgency() != null && !b.getAgency().isBlank() + && (b.getCustomerName() == null || b.getCustomerName().isBlank() || b.getCustomerName().contains("*"))) { + b.setTargetType("하부점"); + b.setTargetName(b.getAgency()); + } else { + b.setTargetType("직원"); + b.setTargetName(b.getCustomerName() != null && !b.getCustomerName().isBlank() ? b.getCustomerName() : "김대표"); + } + } + if ("정산차감".equals(b.getBalanceType()) && b.getAmount() != null && b.getAmount().signum() > 0) { + b.setAmount(b.getAmount().negate()); + } + } + long balCount = em.createQuery("select count(b) from HomesBalance b where b.groupId='demo'", Long.class).getSingleResult(); + if (balCount < 5) { + Object[][] extra = { + {"정산차감", "하부점", "대박통신", "오등록", "-5000", null}, + {"정산추가", "직원", "홍길동", "인센티브", "20000", "월 인센티브"}, + {"정산차감", "직원", "홍길동", "과지급", "-8000", null}, + {"정산추가", "하부점", "늘푸른통신", "프로모션", "15000", null}, + {"정산차감", "직원", "김대표", "철회차감", "-10000", "철회 건"}, + }; + for (int i = 0; i < extra.length && balCount + i < 7; i++) { + Object[] s = extra[i]; + HomesBalance b = new HomesBalance(); + b.setGroupId("demo"); + b.setBalanceType(String.valueOf(s[0])); + b.setTargetType(String.valueOf(s[1])); + b.setTargetName(String.valueOf(s[2])); + b.setReason(String.valueOf(s[3])); + b.setAmount(new BigDecimal(String.valueOf(s[4]))); + b.setMemo(s[5] == null ? null : String.valueOf(s[5])); + b.setBaseDate(LocalDate.now().minusDays(i + 1)); + if ("하부점".equals(b.getTargetType())) b.setAgency(b.getTargetName()); + else b.setCustomerName(b.getTargetName()); + em.persist(b); + } + } + } + if (em.createQuery("select count(i) from HomesInvoice i where i.groupId='demo'", Long.class).getSingleResult() == 0) { + for (int i = 0; i < 6; i++) { + YearMonth ym = YearMonth.now().minusMonths(i); + HomesInvoice inv = new HomesInvoice(); + inv.setGroupId("demo"); + inv.setScope(i % 2 == 0 ? "PUBLISH" : "MY"); + inv.setYearMonth(ym.toString()); + inv.setEmployee("김대표"); + inv.setAgency("늘푸른통신"); + inv.setInternetCount(2 + i % 3); + inv.setInternetAmount(new BigDecimal(50000 + i * 10000L)); + inv.setHomeCount(1 + i % 2); + inv.setHomeAmount(new BigDecimal(80000 + i * 5000L)); + inv.setBalanceCount(i % 3); + inv.setBalanceAmount(new BigDecimal(i % 2 == 0 ? -5000 : 10000)); + inv.setContractCount(inv.getInternetCount() + inv.getHomeCount()); + inv.setSettleAmount(inv.getInternetAmount().add(inv.getHomeAmount()).add(inv.getBalanceAmount())); + inv.setAmount(inv.getSettleAmount()); + inv.setIssued(i != 0); + inv.setStatus(i == 0 ? "대기" : "발행"); + inv.setConsent(i == 0 ? "-" : "대기"); + inv.setTaxInvoice(i == 0 ? "-" : "미발행"); + inv.setWithdrawStatus(i == 0 ? "-" : "대기"); + em.persist(inv); + } + } else { + for (HomesInvoice inv : em.createQuery("select i from HomesInvoice i where i.groupId='demo'", HomesInvoice.class).getResultList()) { + if (("발행".equals(inv.getStatus()) || "확정".equals(inv.getStatus())) && !Boolean.TRUE.equals(inv.getIssued())) { + inv.setIssued(true); + } + if (inv.getSettleAmount() == null && inv.getAmount() != null) inv.setSettleAmount(inv.getAmount()); + if (inv.getInternetCount() == null && inv.getAmount() != null) { + inv.setInternetCount(2); + inv.setInternetAmount(inv.getAmount().multiply(new BigDecimal("0.4")).setScale(0, java.math.RoundingMode.HALF_UP)); + inv.setHomeCount(1); + inv.setHomeAmount(inv.getAmount().multiply(new BigDecimal("0.5")).setScale(0, java.math.RoundingMode.HALF_UP)); + inv.setBalanceCount(1); + inv.setBalanceAmount(inv.getAmount().subtract(inv.getInternetAmount()).subtract(inv.getHomeAmount())); + inv.setContractCount(inv.getInternetCount() + inv.getHomeCount()); + } + } + } + if (em.createQuery("select count(p) from HomesPending p where p.groupId='demo'", Long.class).getSingleResult() == 0) { + Object[][] pendings = { + {"접수", "일반", "설치 상담", "김*근", "010-1111-3566", "김대표", null, null, "14:00", 0, "방문 전 연락 요망"}, + {"접수", "약속1", "방문 약속", "박*지", "010-2222-1122", "홍길동", null, null, "10:30", 1, null}, + {"해결", "약속2", "계약 설명", "이*수", "010-3333-5678", "홍길동", "김대표", 2, "16:00", 2, "완료 확인"}, + {"접수", "일반", "회선 제한 상담", "제한설", "010-7777-8899", "김대표", null, null, "11:00", 6, null}, + {"해결", "약속1", "설치 일정 조율", "최*아", "010-4444-7788", "홍총괄", "홍총괄", 3, "15:30", 4, null}, + {"접수", "일반", "요금 안내", "정*민", "010-5555-9900", "홍길동", null, null, "09:00", 5, "오전 통화"}, + {"접수", "약속2", "해지 상담", "윤*희", "010-6666-1122", "김대표", null, null, "13:20", 7, null}, + {"해결", "일반", "AS 접수", "한*솔", "010-8888-3344", "홍길동", "홍길동", 1, "17:00", 8, null}, + {"접수", "약속1", "이전설치", "오*진", "010-9999-5566", "김대표", null, null, "12:00", 9, null}, + {"접수", "일반", "결합상품 문의", "배*수", "010-1212-3434", "홍총괄", null, null, "14:40", 10, null}, + {"해결", "약속2", "약정 연장", "임*영", "010-5656-7878", "홍길동", "김대표", 5, "10:00", 11, null}, + {"접수", "일반", "개통 일정", "조*현", "010-9090-1212", "김대표", null, null, "16:30", 12, null}, + }; + for (Object[] s : pendings) { + HomesPending p = new HomesPending(); + p.setGroupId("demo"); + p.setStatus(String.valueOf(s[0])); + p.setPendingType(String.valueOf(s[1])); + p.setContents(String.valueOf(s[2])); + p.setCustomerName(String.valueOf(s[3])); + p.setPhone(String.valueOf(s[4])); + p.setReceiveEmployee(String.valueOf(s[5])); + p.setEmployee(String.valueOf(s[5])); + p.setProcessEmployee(s[6] == null ? null : String.valueOf(s[6])); + if (s[7] != null) p.setProcessDate(LocalDate.now().minusDays(((Number) s[7]).longValue())); + p.setPendingTime(String.valueOf(s[8])); + p.setPendingDate(LocalDate.now().plusDays(((Number) s[9]).longValue())); + p.setMemo(s[10] == null ? null : String.valueOf(s[10])); + em.persist(p); + } + } else { + for (HomesPending p : em.createQuery("select p from HomesPending p where p.groupId='demo'", HomesPending.class).getResultList()) { + if ("예정".equals(p.getStatus())) p.setStatus("접수"); + if ("완료".equals(p.getStatus())) p.setStatus("해결"); + if ((p.getReceiveEmployee() == null || p.getReceiveEmployee().isBlank()) && p.getEmployee() != null) { + p.setReceiveEmployee(p.getEmployee()); + } + } + long cnt = em.createQuery("select count(p) from HomesPending p where p.groupId='demo'", Long.class).getSingleResult(); + if (cnt < 8) { + Object[][] extra = { + {"접수", "일반", "요금 안내", "정*민", "010-5555-9900", "홍길동", 5}, + {"해결", "약속1", "설치 일정 조율", "최*아", "010-4444-7788", "홍총괄", 4}, + {"접수", "약속2", "해지 상담", "윤*희", "010-6666-1122", "김대표", 7}, + {"해결", "일반", "AS 접수", "한*솔", "010-8888-3344", "홍길동", 8}, + {"접수", "약속1", "이전설치", "오*진", "010-9999-5566", "김대표", 9}, + {"접수", "일반", "결합상품 문의", "배*수", "010-1212-3434", "홍총괄", 10}, + {"해결", "약속2", "약정 연장", "임*영", "010-5656-7878", "홍길동", 11}, + {"접수", "일반", "개통 일정", "조*현", "010-9090-1212", "김대표", 12}, + }; + for (int i = 0; i < extra.length && cnt + i < 12; i++) { + Object[] s = extra[i]; + HomesPending p = new HomesPending(); + p.setGroupId("demo"); + p.setStatus(String.valueOf(s[0])); + p.setPendingType(String.valueOf(s[1])); + p.setContents(String.valueOf(s[2])); + p.setCustomerName(String.valueOf(s[3])); + p.setPhone(String.valueOf(s[4])); + p.setReceiveEmployee(String.valueOf(s[5])); + p.setEmployee(String.valueOf(s[5])); + if ("해결".equals(p.getStatus())) { + p.setProcessEmployee(String.valueOf(s[5])); + p.setProcessDate(LocalDate.now().minusDays(1)); + } + p.setPendingTime("10:00"); + p.setPendingDate(LocalDate.now().plusDays(((Number) s[6]).longValue())); + em.persist(p); + } + } + } + if (em.createQuery("select count(b) from HomesBooking b where b.groupId='demo'", Long.class).getSingleResult() == 0) { + Object[][] bookings = { + {"완료", "이*신", "0000-0000-0000", "인터넷, TV, 전화", "매트리스, 가전", LocalDate.of(2022, 7, 14)}, + {"접수", "홍*동", "010-1111-2222", "인터넷, 전화, 스마트홈", "공기청정기, 매트리스, 기타", LocalDate.of(2022, 7, 14)}, + {"보류", "김*근", "010-1000-2000", "인터넷, 와이파이", "정수기", LocalDate.now().plusDays(1)}, + }; + for (Object[] s : bookings) { + HomesBooking b = new HomesBooking(); + b.setGroupId("demo"); + b.setStatus(String.valueOf(s[0])); + b.setCustomerName(String.valueOf(s[1])); + b.setPhone(String.valueOf(s[2])); + b.setInternetProducts(String.valueOf(s[3])); + b.setRentalProducts(String.valueOf(s[4])); + b.setProducts(s[3] + ", " + s[4]); + b.setBookingDate((LocalDate) s[5]); + b.setAddress("서울시"); + b.setEmployee("홍길동"); + em.persist(b); + } + } else { + // 기존 시드에 인터넷/홈렌탈 분리 필드 보강 + List existing = em.createQuery( + "select b from HomesBooking b where b.groupId='demo'", HomesBooking.class).getResultList(); + for (HomesBooking b : existing) { + if (b.getInternetProducts() == null || b.getInternetProducts().isBlank()) { + String p = b.getProducts() == null ? "" : b.getProducts(); + if (p.contains("인터넷") || p.contains("기가") || p.contains("TV") || p.contains("전화") || p.contains("와이파이") || p.contains("스마트")) { + List inet = new ArrayList<>(); + if (p.contains("인터넷") || p.contains("기가")) inet.add("인터넷"); + if (p.contains("TV")) inet.add("TV"); + if (p.contains("전화")) inet.add("전화"); + if (p.contains("와이파이")) inet.add("와이파이"); + if (p.contains("스마트")) inet.add("스마트홈"); + if (inet.isEmpty()) inet.add("인터넷"); + b.setInternetProducts(String.join(", ", inet)); + } + if (p.contains("정수기") || p.contains("공기") || p.contains("비데") || p.contains("매트") || p.contains("가전")) { + List rent = new ArrayList<>(); + if (p.contains("정수기")) rent.add("정수기"); + if (p.contains("공기")) rent.add("공기청정기"); + if (p.contains("비데")) rent.add("비데"); + if (p.contains("매트")) rent.add("매트리스"); + if (p.contains("가전")) rent.add("가전"); + b.setRentalProducts(String.join(", ", rent)); + } + if ((b.getInternetProducts() == null || b.getInternetProducts().isBlank()) + && (b.getRentalProducts() == null || b.getRentalProducts().isBlank())) { + b.setInternetProducts("인터넷"); + b.setRentalProducts("기타"); + } + } + if (b.getEmployee() == null || b.getEmployee().isBlank()) b.setEmployee("홍길동"); + } + } + if (em.createQuery("select count(a) from HomesAbook a where a.groupId='demo'", Long.class).getSingleResult() == 0) { + Object[][] abooks = { + {"입금", "일반", "계약정산", "150000", "홍길동"}, + {"출금", "일반", "사무용품", "35000", "홍길동"}, + {"입금", "123", "후정산추가", "10000", "홍길동"}, + {"카드입금", "일반", "카드매출", "55000", "김대표"}, + {"카드출금", "123", "카드수수료", "2200", "홍길동"}, + {"출금", "일반", "테스트2", "5000", "홍길동"}, + }; + for (int i = 0; i < abooks.length; i++) { + Object[] s = abooks[i]; + HomesAbook a = new HomesAbook(); + a.setGroupId("demo"); + a.setEntryType(String.valueOf(s[0])); + a.setCategory(String.valueOf(s[1])); + a.setItem(String.valueOf(s[2])); + a.setAmount(new BigDecimal(String.valueOf(s[3]))); + a.setEmployee(String.valueOf(s[4])); + a.setEntryDate(LocalDate.now().minusDays(i)); + a.setMemo(i == 0 ? "데모 시드" : null); + em.persist(a); + } + } else { + for (HomesAbook a : em.createQuery("select a from HomesAbook a where a.groupId='demo'", HomesAbook.class).getResultList()) { + if ("수입".equals(a.getEntryType())) a.setEntryType("입금"); + else if ("지출".equals(a.getEntryType())) a.setEntryType("출금"); + String cat = a.getCategory(); + boolean knownCat = cat != null && (cat.equals("일반") || cat.equals("123") || cat.startsWith("단골")); + if (!knownCat && cat != null && !cat.isBlank()) { + if (a.getItem() == null || a.getItem().isBlank()) a.setItem(cat); + a.setCategory("일반"); + } + if ((a.getItem() == null || a.getItem().isBlank()) && a.getAgency() != null && !a.getAgency().isBlank()) { + a.setItem(a.getAgency()); + } + if (a.getCategory() == null || a.getCategory().isBlank()) a.setCategory("일반"); + if (a.getEmployee() == null || a.getEmployee().isBlank()) a.setEmployee("홍길동"); + } + } + { + Object[][] agencies = { + {"(주)코웨이", "", "", "", "", LocalDate.of(2022, 5, 27), true, null}, + {"SK브로드밴드", "", "", "", "", LocalDate.of(2022, 5, 27), true, null}, + {"테스트", "02-1234-5678", "02-9876-5432", "홍길동", "010-1234-5678", LocalDate.of(2022, 4, 13), true, "데모 거래처"}, + }; + java.util.Set keep = new java.util.HashSet<>(); + for (Object[] s : agencies) keep.add(String.valueOf(s[0])); + for (HomesAgency old : em.createQuery("select a from HomesAgency a where a.groupId='demo'", HomesAgency.class).getResultList()) { + if (!keep.contains(old.getName())) em.remove(old); + } + em.flush(); + for (Object[] s : agencies) { + String name = String.valueOf(s[0]); + List found = em.createQuery( + "select a from HomesAgency a where a.groupId='demo' and a.name=:name", HomesAgency.class) + .setParameter("name", name).getResultList(); + HomesAgency a = found.isEmpty() ? new HomesAgency() : found.getFirst(); + a.setGroupId("demo"); + a.setName(name); + a.setPhone(String.valueOf(s[1])); + a.setFax(String.valueOf(s[2])); + a.setManagerName(String.valueOf(s[3])); + a.setMobile(String.valueOf(s[4])); + a.setRegisteredDate((LocalDate) s[5]); + a.setActive((Boolean) s[6]); + a.setAgencyType("대리점"); + a.setAddress(null); + a.setMemo(s[7] == null ? null : String.valueOf(s[7])); + if (found.isEmpty()) em.persist(a); + } + } + // 상부점/하부점관리 원본은 빈 목록 + for (HomesPartner p : em.createQuery("select p from HomesPartner p where p.groupId='demo'", HomesPartner.class).getResultList()) { + em.remove(p); + } + em.flush(); + { + // 공지사항 원본 샘플 1건 + String title = "비즈케어 홈즈를 베타오픈 하였습니다."; + List all = em.createQuery("select n from HomesNotice n where n.groupId='demo'", HomesNotice.class).getResultList(); + HomesNotice target = all.stream().filter(n -> title.equals(n.getTitle())).findFirst().orElse(null); + if (target == null) { + for (HomesNotice old : all) em.remove(old); + em.flush(); + target = new HomesNotice(); + target.setGroupId("demo"); + target.setTitle(title); + target.setAuthorName("비즈케어"); + target.setContents("비즈케어 홈즈를 베타오픈 하였습니다.\n\n이용에 참고 부탁드립니다."); + target.setViews(10); + em.persist(target); + em.flush(); + } else { + target.setViews(target.getViews() == null || target.getViews() < 10 ? 10 : target.getViews()); + if (target.getContents() == null || target.getContents().isBlank()) { + target.setContents("비즈케어 홈즈를 베타오픈 하였습니다.\n\n이용에 참고 부탁드립니다."); + } + } + Instant at = LocalDate.of(2022, 11, 22).atStartOfDay(ZoneId.of("Asia/Seoul")).toInstant(); + target.setCreatedAt(at); + target.setUpdatedAt(at); + // 원본과 다른 데모 공지 제거 + for (HomesNotice n : em.createQuery("select n from HomesNotice n where n.groupId='demo'", HomesNotice.class).getResultList()) { + if (!title.equals(n.getTitle())) em.remove(n); + } + } + // 사용문의 원본은 빈 목록 + for (HomesQuestion q : em.createQuery("select q from HomesQuestion q where q.groupId='demo'", HomesQuestion.class).getResultList()) { + em.remove(q); + } + em.flush(); + // 사내게시판 원본은 빈 목록 + for (HomesBbs b : em.createQuery("select b from HomesBbs b where b.groupId='demo'", HomesBbs.class).getResultList()) { + em.remove(b); + } + em.flush(); + if (em.createQuery("select count(r) from HomesReception r where r.groupId='demo'", Long.class).getSingleResult() == 0) { + Object[][] rows = { + {LocalDateTime.of(2026, 7, 10, 9, 12), "김상담", "강남점", "KT", "상품권", "인터넷", "기가인터넷 1G", "홍*동", "010-1111-2222", "서울", "1.접수완료", "SAMPLE-00001", "서울입고", "박처리", "접수", "현금", "100000", "정산대기", null, null, "미지급", "본사", "36개월", "02-1234-5678"}, + {LocalDateTime.of(2026, 7, 10, 10, 30), "이상담", "서초점", "SKB", "가전", "TV", "B tv 프리미엄", "김*수", "010-2222-3333", "경기", "2.고객통화", "SAMPLE-00002", "", "최처리", "통화완료", "계좌", "80000", "정산대기", null, null, "지급대기", "영업점", "24개월", ""}, + {LocalDateTime.of(2026, 7, 9, 14, 5), "김상담", "강남점", "LGU+", "상품권", "인터넷", "U+ 인터넷", "박*지", "010-3333-4444", "인천", "4.개통완료", "SAMPLE-00003", "서울입고", "박처리", "개통", "현금", "120000", "정산대기", LocalDate.of(2026, 7, 9), null, "미지급", "본사", "36개월", "032-555-1212"}, + {LocalDateTime.of(2026, 7, 8, 11, 20), "정상담", "송파점", "KT", "캐시백", "정수기", "청호 450", "최*아", "010-4444-5555", "서울", "4.개통완료", "SAMPLE-00004", "물류완료", "이처리", "설치완료", "카드", "150000", "정산완료", LocalDate.of(2026, 7, 8), LocalDate.of(2026, 7, 10), "지급완료", "본사", "60개월", ""}, + {LocalDateTime.of(2026, 7, 8, 16, 40), "이상담", "서초점", "SKT", "상품권", "전화", "집전화 스마트", "정*민", "010-5555-6666", "부산", "3.서류검토", "SAMPLE-00005", "", "최처리", "서류요청", "현금", "15000", "정산대기", null, null, "미지급", "본사", "24개월", ""}, + {LocalDateTime.of(2026, 7, 7, 9, 50), "김상담", "강남점", "KT", "가전", "공기청정기", "퓨리케어 360", "이*수", "010-6666-7777", "서울", "5.사은품지급", "SAMPLE-00006", "서울입고", "박처리", "사은품발송", "계좌", "90000", "정산대기", LocalDate.of(2026, 7, 6), LocalDate.of(2026, 7, 7), "지급완료", "본사", "36개월", ""}, + {LocalDateTime.of(2026, 7, 6, 13, 15), "정상담", "송파점", "SKB", "상품권", "인터넷", "B tv 인터넷", "윤*희", "010-7777-8888", "경기", "6.정산완료", "SAMPLE-00007", "완료", "이처리", "정산", "현금", "75000", "정산완료", LocalDate.of(2026, 7, 5), LocalDate.of(2026, 7, 6), "지급완료", "영업점", "36개월", ""}, + {LocalDateTime.of(2026, 7, 5, 15, 0), "이상담", "서초점", "LGU+", "캐시백", "와이파이", "와이파이 메시", "한*진", "010-8888-9999", "대전", "9.취소", "SAMPLE-00008", "", "최처리", "고객취소", "", "0", "회수대기", null, null, "반송", "", "12개월", ""}, + {LocalDateTime.of(2026, 7, 11, 8, 45), "김상담", "강남점", "KT", "상품권", "인터넷", "안심 인터넷 베이직", "오*라", "010-1212-3434", "서울", "1.접수완료", "SAMPLE-00009", "", "박처리", "접수", "현금", "60000", "정산대기", null, null, "미지급", "본사", "36개월", ""}, + {LocalDateTime.of(2026, 7, 11, 11, 10), "정상담", "송파점", "SKB", "가전", "비데", "스스로케어 비데", "서*호", "010-5656-7878", "경남", "2.고객통화", "SAMPLE-00010", "물류대기", "이처리", "통화예약", "계좌", "60000", "정산대기", null, null, "지급대기", "본사", "36개월", ""}, + }; + for (Object[] s : rows) { + HomesReception r = new HomesReception(); + r.setGroupId("demo"); + r.setOrderDate((LocalDateTime) s[0]); + r.setCounselor(String.valueOf(s[1])); + r.setBranch(String.valueOf(s[2])); + r.setCarrier(String.valueOf(s[3])); + r.setGiftCategory(String.valueOf(s[4])); + r.setProductCategory(String.valueOf(s[5])); + r.setProductName(String.valueOf(s[6])); + r.setCustomerName(String.valueOf(s[7])); + r.setPhone(String.valueOf(s[8])); + r.setRegion(String.valueOf(s[9])); + r.setProgressStatus(String.valueOf(s[10])); + r.setOrderNumber(String.valueOf(s[11])); + r.setInboundInfo(String.valueOf(s[12])); + r.setProcessor(String.valueOf(s[13])); + r.setDetailProgress(String.valueOf(s[14])); + r.setPaymentType(String.valueOf(s[15])); + r.setAmount(new BigDecimal(String.valueOf(s[16]))); + r.setSettleStatus(String.valueOf(s[17])); + r.setOpenDate(s[18] == null ? null : (LocalDate) s[18]); + r.setGiftDate(s[19] == null ? null : (LocalDate) s[19]); + r.setGiftStatus(String.valueOf(s[20])); + r.setGiftPlace(String.valueOf(s[21])); + r.setAgreementName(String.valueOf(s[22])); + r.setContractNumber(String.valueOf(s[23])); + r.setBranchGroup("본사그룹"); + r.setPostIt(""); + r.setMemo("데모 접수 데이터"); + em.persist(r); + } + em.flush(); + for (HomesReception r : em.createQuery("select r from HomesReception r where r.groupId='demo'", HomesReception.class).getResultList()) { + HomesReceptionProgressLog log = new HomesReceptionProgressLog(); + log.setGroupId("demo"); + log.setReceptionId(r.getId()); + log.setStatus(r.getProgressStatus()); + log.setMemo("초기 등록"); + log.setAuthorName(r.getCounselor()); + log.setLoggedAt(r.getOrderDate() == null ? LocalDateTime.now() : r.getOrderDate()); + em.persist(log); + } + } + seedReceptionIspsIfEmpty(); + enrichReceptionProgressFields(); + if (em.createQuery("select count(l) from HomesLog l where l.groupId='demo'", Long.class).getSingleResult() == 0) { + Object[][] logs = { + {"PRODUCT", "인터넷상품-등록", "인터넷상품 [센트릭스] 등록하였습니다.", "홍길동", 2}, + {"PRODUCT", "인터넷상품-정책-등록", "인터넷상품 정책 [36개월] 등록하였습니다.", "홍길동", 3}, + {"PRODUCT", "인터넷상품-수정", "인터넷상품 [기가인터넷 500M] 수정하였습니다.", "홍총괄", 5}, + {"PRODUCT", "홈렌탈상품-등록", "홈렌탈상품 [450 (냉온정)] 등록하였습니다.", "홍길동", 8}, + {"PRODUCT", "홈렌탈상품-정책-등록", "홈렌탈상품 정책 [60개월] 등록하였습니다.", "홍총괄", 10}, + {"PRODUCT", "인터넷상품-삭제", "인터넷상품 [인터넷 슬림] 삭제하였습니다.", "김대표", 12}, + {"PRODUCT", "기본상품-동기화", "인터넷상품 기본상품 16건을 동기화하였습니다.", "김대표", 20}, + {"CONTRACT", "인터넷접수-등록", "인터넷접수 [홍*동] 등록하였습니다.", "홍길동", 1}, + {"CONTRACT", "인터넷접수-상태", "인터넷접수 [홍*동] 완료(으)로 변경하였습니다.", "홍길동", 2}, + {"CONTRACT", "인터넷접수-수정", "인터넷접수 [김*수] 수정하였습니다.", "홍총괄", 4}, + {"CONTRACT", "홈렌탈접수-등록", "홈렌탈접수 [최*아] 등록하였습니다.", "김대표", 6}, + {"CONTRACT", "홈렌탈접수-상태", "홈렌탈접수 [윤*희] 철회(으)로 변경하였습니다.", "홍총괄", 9}, + {"CUSTOMER", "고객-등록", "고객 [위*우] 등록하였습니다.", "홍길동", 1}, + {"CUSTOMER", "고객-수정", "고객 [홍*동] 수정하였습니다.", "홍길동", 2}, + {"CUSTOMER", "고객-분류", "고객 [홍*동] 분류수정하였습니다.", "홍길동", 3}, + {"CUSTOMER", "고객-등록", "계약고객 [홍*동] 하부점(인터넷)에서 등록하였습니다.", "홍길동", 4}, + {"CUSTOMER", "고객-등록", "고객 [김*근] 등록하였습니다.", "김대표", 5}, + {"CUSTOMER", "고객-삭제", "고객 [이*수] 삭제하였습니다.", "홍길동", 8}, + {"BOOKING", "예약-상태", "예약 [이*신] 완료(으)로 상태변경하였습니다.", "홍길동", 1}, + {"BOOKING", "예약-수정", "예약 [홍*동] 수정하였습니다.", "홍길동", 2}, + {"BOOKING", "예약-등록", "예약 [이*신] 등록하였습니다.", "홍길동", 3}, + {"BOOKING", "예약-등록", "예약 [홍*동] 등록하였습니다.", "홍길동", 4}, + {"BOOKING", "예약-상태", "예약 [김*근] 보류(으)로 상태변경하였습니다.", "홍길동", 7}, + {"ABOOKS", "간편장부-등록", "간편장부 [입금/일반] 등록하였습니다.", "홍길동", 1}, + {"ABOOKS", "간편장부-수정", "간편장부 [출금/일반] 수정하였습니다.", "홍길동", 2}, + {"ABOOKS", "간편장부-삭제", "간편장부 [카드출금/123] 삭제하였습니다.", "홍길동", 3}, + {"ABOOKS", "간편장부-등록", "간편장부 [카드입금/일반] 등록하였습니다.", "김대표", 5}, + {"ABOOKS", "간편장부-등록", "간편장부 [출금/123] 등록하였습니다.", "홍길동", 8}, + {"INVOICE", "정산서-발행", "정산서 [김사원] 발행하였습니다.", "홍길동", 1}, + {"INVOICE", "정산서-발행", "정산서 [김대표] 발행하였습니다.", "김대표", 3}, + {"INVOICE", "정산서-재발행", "정산서 [홍길동] 재발행하였습니다.", "홍총괄", 5}, + {"INVOICE", "정산서-삭제", "정산서 [이총괄] 삭제하였습니다.", "김대표", 8}, + {"INVOICE", "정산서-발행", "정산서 [박재고] 발행하였습니다.", "홍길동", 12}, + {"INVOICE", "정산서-수정", "정산서 [김대표] 수정하였습니다.", "김대표", 15}, + {"INVOICE", "정산서-삭제", "정산서 [윤영업] 삭제하였습니다.", "홍길동", 20}, + }; + for (Object[] s : logs) { + HomesLog l = new HomesLog(); + l.setGroupId("demo"); + l.setDomain(String.valueOf(s[0])); + l.setAction(String.valueOf(s[1])); + l.setContent(String.valueOf(s[2])); + l.setStaff(String.valueOf(s[3])); + l.setProcessedAt(LocalDateTime.now().minusHours(((Number) s[4]).longValue())); + em.persist(l); + } + } else { + // 정산서이력 데모 보강 + long invoiceLogs = em.createQuery("select count(l) from HomesLog l where l.groupId='demo' and l.domain='INVOICE'", Long.class).getSingleResult(); + if (invoiceLogs < 5) { + Object[][] extra = { + {"정산서-발행", "정산서 [김사원] 발행하였습니다.", "홍길동", 2}, + {"정산서-발행", "정산서 [김대표] 발행하였습니다.", "김대표", 6}, + {"정산서-재발행", "정산서 [홍길동] 재발행하였습니다.", "홍총괄", 10}, + {"정산서-삭제", "정산서 [이총괄] 삭제하였습니다.", "김대표", 14}, + {"정산서-발행", "정산서 [박재고] 발행하였습니다.", "홍길동", 18}, + {"정산서-수정", "정산서 [김대표] 수정하였습니다.", "김대표", 22}, + {"정산서-삭제", "정산서 [윤영업] 삭제하였습니다.", "홍길동", 28}, + }; + for (Object[] s : extra) { + HomesLog l = new HomesLog(); + l.setGroupId("demo"); + l.setDomain("INVOICE"); + l.setAction(String.valueOf(s[0])); + l.setContent(String.valueOf(s[1])); + l.setStaff(String.valueOf(s[2])); + l.setProcessedAt(LocalDateTime.now().minusHours(((Number) s[3]).longValue())); + em.persist(l); + } + } + // 구 라벨 정규화 + for (HomesLog l : em.createQuery("select l from HomesLog l where l.groupId='demo' and l.domain='INVOICE'", HomesLog.class).getResultList()) { + if ("발행".equals(l.getAction())) { + l.setAction("정산서-발행"); + if (l.getContent() != null && !l.getContent().contains("정산서 [")) { + l.setContent("정산서 [" + l.getContent().replace(" 정산서를 발행하였습니다.", "").trim() + "] 발행하였습니다."); + } + } else if ("등록".equals(l.getAction())) { + l.setAction("정산서-등록"); + if (l.getContent() != null && !l.getContent().startsWith("정산서 [")) { + String[] parts = l.getContent().split(" ", 2); + String emp = parts.length > 1 ? parts[1] : parts[0]; + l.setContent("정산서 [" + emp + "] 등록하였습니다."); + } + } else if ("삭제".equals(l.getAction())) { + l.setAction("정산서-삭제"); + } + } + // 예약이력 데모 보강 + long bookingLogs = em.createQuery("select count(l) from HomesLog l where l.groupId='demo' and l.domain='BOOKING'", Long.class).getSingleResult(); + if (bookingLogs < 5) { + Object[][] extra = { + {"예약-상태", "예약 [이*신] 완료(으)로 상태변경하였습니다.", "홍길동", 1}, + {"예약-수정", "예약 [홍*동] 수정하였습니다.", "홍길동", 2}, + {"예약-등록", "예약 [이*신] 등록하였습니다.", "홍길동", 3}, + {"예약-등록", "예약 [홍*동] 등록하였습니다.", "홍길동", 4}, + {"예약-상태", "예약 [김*근] 보류(으)로 상태변경하였습니다.", "홍길동", 7}, + }; + for (Object[] s : extra) { + HomesLog l = new HomesLog(); + l.setGroupId("demo"); + l.setDomain("BOOKING"); + l.setAction(String.valueOf(s[0])); + l.setContent(String.valueOf(s[1])); + l.setStaff(String.valueOf(s[2])); + l.setProcessedAt(LocalDateTime.now().minusHours(((Number) s[3]).longValue())); + em.persist(l); + } + } + for (HomesLog l : em.createQuery("select l from HomesLog l where l.groupId='demo' and l.domain='BOOKING'", HomesLog.class).getResultList()) { + if ("등록".equals(l.getAction())) { + l.setAction("예약-등록"); + if (l.getContent() != null && !l.getContent().startsWith("예약 [")) { + l.setContent("예약 [" + l.getContent().trim() + "] 등록하였습니다."); + } + } else if ("수정".equals(l.getAction())) { + l.setAction("예약-수정"); + } else if ("삭제".equals(l.getAction())) { + l.setAction("예약-삭제"); + } else if ("상태".equals(l.getAction())) { + l.setAction("예약-상태"); + } + } + // 고객이력 데모 보강 + long customerLogs = em.createQuery("select count(l) from HomesLog l where l.groupId='demo' and l.domain='CUSTOMER'", Long.class).getSingleResult(); + if (customerLogs < 5) { + Object[][] extra = { + {"고객-등록", "고객 [위*우] 등록하였습니다.", "홍길동", 1}, + {"고객-수정", "고객 [홍*동] 수정하였습니다.", "홍길동", 2}, + {"고객-분류", "고객 [홍*동] 분류수정하였습니다.", "홍길동", 3}, + {"고객-등록", "계약고객 [홍*동] 하부점(인터넷)에서 등록하였습니다.", "홍길동", 4}, + {"고객-등록", "고객 [김*근] 등록하였습니다.", "김대표", 5}, + {"고객-삭제", "고객 [이*수] 삭제하였습니다.", "홍길동", 8}, + }; + for (Object[] s : extra) { + HomesLog l = new HomesLog(); + l.setGroupId("demo"); + l.setDomain("CUSTOMER"); + l.setAction(String.valueOf(s[0])); + l.setContent(String.valueOf(s[1])); + l.setStaff(String.valueOf(s[2])); + l.setProcessedAt(LocalDateTime.now().minusHours(((Number) s[3]).longValue())); + em.persist(l); + } + } + for (HomesLog l : em.createQuery("select l from HomesLog l where l.groupId='demo' and l.domain='CUSTOMER'", HomesLog.class).getResultList()) { + if ("등록".equals(l.getAction())) { + l.setAction("고객-등록"); + if (l.getContent() != null && !l.getContent().startsWith("고객 [") && !l.getContent().startsWith("계약고객 [")) { + l.setContent("고객 [" + l.getContent().trim() + "] 등록하였습니다."); + } + } else if ("수정".equals(l.getAction())) { + l.setAction("고객-수정"); + if (l.getContent() != null && !l.getContent().startsWith("고객 [")) { + l.setContent("고객 [" + l.getContent().trim() + "] 수정하였습니다."); + } + } else if ("삭제".equals(l.getAction())) { + l.setAction("고객-삭제"); + } else if ("분류".equals(l.getAction()) || "고객-분류변경".equals(l.getAction())) { + l.setAction("고객-분류"); + } + } + // 장부이력 데모 보강 + long abookLogs = em.createQuery("select count(l) from HomesLog l where l.groupId='demo' and l.domain='ABOOKS'", Long.class).getSingleResult(); + if (abookLogs < 5) { + Object[][] extra = { + {"간편장부-등록", "간편장부 [입금/일반] 등록하였습니다.", "홍길동", 1}, + {"간편장부-수정", "간편장부 [출금/일반] 수정하였습니다.", "홍길동", 2}, + {"간편장부-삭제", "간편장부 [카드출금/123] 삭제하였습니다.", "홍길동", 3}, + {"간편장부-등록", "간편장부 [카드입금/일반] 등록하였습니다.", "김대표", 5}, + {"간편장부-등록", "간편장부 [출금/123] 등록하였습니다.", "홍길동", 8}, + {"간편장부-수정", "간편장부 [입금/일반] 수정하였습니다.", "홍길동", 12}, + }; + for (Object[] s : extra) { + HomesLog l = new HomesLog(); + l.setGroupId("demo"); + l.setDomain("ABOOKS"); + l.setAction(String.valueOf(s[0])); + l.setContent(String.valueOf(s[1])); + l.setStaff(String.valueOf(s[2])); + l.setProcessedAt(LocalDateTime.now().minusHours(((Number) s[3]).longValue())); + em.persist(l); + } + } + for (HomesLog l : em.createQuery("select l from HomesLog l where l.groupId='demo' and l.domain='ABOOKS'", HomesLog.class).getResultList()) { + if ("등록".equals(l.getAction()) || "장부-등록".equals(l.getAction())) { + l.setAction("간편장부-등록"); + if (l.getContent() != null && !l.getContent().startsWith("간편장부 [")) { + l.setContent("간편장부 [입금/일반] 등록하였습니다."); + } + } else if ("수정".equals(l.getAction()) || "장부-수정".equals(l.getAction())) { + l.setAction("간편장부-수정"); + if (l.getContent() != null && !l.getContent().startsWith("간편장부 [")) { + l.setContent("간편장부 [입금/일반] 수정하였습니다."); + } + } else if ("삭제".equals(l.getAction()) || "장부-삭제".equals(l.getAction())) { + l.setAction("간편장부-삭제"); + if (l.getContent() != null && !l.getContent().startsWith("간편장부 [")) { + l.setContent("간편장부 [출금/일반] 삭제하였습니다."); + } + } + } + } + ensurePendingDemoLogs(); + } + + /** 일정이력 원본 샘플(목록 48건) */ + private void ensurePendingDemoLogs() { + for (HomesLog l : em.createQuery("select l from HomesLog l where l.groupId='demo' and l.domain='PENDING'", HomesLog.class).getResultList()) { + if ("등록".equals(l.getAction())) { + l.setAction("약속-등록"); + if (l.getContent() == null || !l.getContent().startsWith("약속 [")) { + String name = l.getContent() == null || l.getContent().isBlank() ? "일반" : l.getContent().trim(); + l.setContent("약속 [" + name + "] 등록하였습니다."); + } + } else if ("수정".equals(l.getAction())) { + l.setAction("약속-수정"); + } else if ("삭제".equals(l.getAction())) { + l.setAction("약속-삭제"); + } else if ("처리".equals(l.getAction()) || "약속-처리".equals(l.getAction())) { + l.setAction("약속-해결"); + if (l.getContent() != null && l.getContent().contains("해결 처리")) { + l.setContent(l.getContent().replace("해결 처리하였습니다.", "해결완료하였습니다.")); + } else if (l.getContent() == null || !l.getContent().startsWith("약속 [")) { + l.setContent("약속 [일반] 해결완료하였습니다."); + } + } + } + em.flush(); + long pendingLogs = em.createQuery("select count(l) from HomesLog l where l.groupId='demo' and l.domain='PENDING'", Long.class).getSingleResult(); + if (pendingLogs >= 40) return; + for (HomesLog old : em.createQuery("select l from HomesLog l where l.groupId='demo' and l.domain='PENDING'", HomesLog.class).getResultList()) { + em.remove(old); + } + em.flush(); + String[] types = {"일반", "약속1", "약속2"}; + LocalDateTime base = LocalDateTime.of(2024, 4, 1, 19, 49); + for (int i = 0; i < 48; i++) { + String type = types[i % types.length]; + HomesLog l = new HomesLog(); + l.setGroupId("demo"); + l.setDomain("PENDING"); + if (i % 3 == 1) { + l.setAction("약속-해결"); + l.setContent("약속 [" + type + "] 해결완료하였습니다."); + } else if (i % 9 == 0) { + l.setAction("약속-수정"); + l.setContent("약속 [" + type + "] 수정하였습니다."); + } else if (i % 13 == 0) { + l.setAction("약속-삭제"); + l.setContent("약속 [" + type + "] 삭제하였습니다."); + } else { + l.setAction("약속-등록"); + l.setContent("약속 [" + type + "] 등록하였습니다."); + } + l.setStaff("홍길동"); + l.setProcessedAt(base.minusMinutes(i * 7L)); + em.persist(l); + } + } + + private void seedUnitCostsIfEmpty() { + Long count = em.createQuery("select count(t) from UnitCostTable t where t.groupId='demo'", Long.class).getSingleResult(); + if (count > 0) return; + List ins = em.createQuery("select c from Company c where c.groupId='demo' and c.type='IN'", Company.class).getResultList(); + Object[][] samples = { + {"SKT", "SKT", "정책202103_테스트", 4, 11, 14, 2, null, null, null, 1, "표준"}, + {"KT", "KT", "KT기본정책", 2, 5, 7, 1, null, null, null, 1, "공시/선택구분"}, + {"LGU+", "LGT", "LG물류정책", 1, 3, null, null, null, null, null, 0, "표준"}, + {"SKT", "SKT2", "SKT2테스트", 3, 2, 1, null, null, null, null, 1, "표준"}, + {"LGU+", "LG물류", "LG물류", 2, null, null, null, null, null, null, 0, "표준"}, + {"KT", "KT", "선택약정표", 5, 4, 3, 2, 1, null, null, 1, "공시/선택구분"}, + }; + for (Object[] s : samples) { + UnitCostTable t = new UnitCostTable(); + t.setGroupId("demo"); + t.setTelecom(String.valueOf(s[0])); + t.setIncompanyName(String.valueOf(s[1])); + if (!ins.isEmpty()) t.setIncompanyId(ins.getFirst().getId()); + t.setMemo(String.valueOf(s[2])); + t.setPlanGroupA((Integer) s[3]); + t.setPlanGroupB((Integer) s[4]); + t.setPlanGroupC((Integer) s[5]); + t.setPlanGroupD((Integer) s[6]); + t.setPlanGroupE((Integer) s[7]); + t.setPlanGroupF((Integer) s[8]); + t.setPlanGroupG((Integer) s[9]); + t.setPolicyCount((Integer) s[10]); + t.setCategory(String.valueOf(s[11])); + em.persist(t); + em.flush(); + if ((Integer) s[10] > 0) { + UnitCostPolicy p = new UnitCostPolicy(); + p.setGroupId("demo"); + p.setUnitCostId(t.getId()); + p.setPlanGroup("A"); + p.setModel("SM-"); + p.setPlanName("5GX 프라임"); + p.setAmount(new BigDecimal("100000")); + em.persist(p); + } + } + } + + private void seedOpenAdjustFields() { + List opens = em.createQuery("select o from OpenRecord o where o.groupId='demo'", OpenRecord.class).getResultList(); + int i = 0; + for (OpenRecord o : opens) { + if (o.getDecltype() == null) o.setDecltype(i % 2 == 0 ? "단말할인" : "요금할인"); + if (o.getBasicprice1() == null) o.setBasicprice1(new BigDecimal("100000")); + if (o.getBasicprice2() == null) o.setBasicprice2(new BigDecimal("50000")); + if (o.getBasicprice3() == null) o.setBasicprice3(new BigDecimal("30000")); + i++; + } + } + + private void seedBalancesIfEmpty() { + Long count = em.createQuery("select count(b) from AfterBalance b where b.groupId='demo'", Long.class).getSingleResult(); + if (count > 0) return; + List shops = em.createQuery("select c from Company c where c.groupId='demo' and c.type in ('SHOP','OUT')", Company.class).getResultList(); + List ins = em.createQuery("select c from Company c where c.groupId='demo' and c.type='IN'", Company.class).getResultList(); + if (shops.isEmpty()) return; + Long inId = ins.isEmpty() ? null : ins.getFirst().getId(); + Object[][] samples = { + {"정산차감", "그레이드 누락", "20000", "주*라", "010-****-3566", "D2"}, + {"정산추가", "택배비", "-100000", "남*섭", "010-****-1122", "늘푸른통신"}, + {"홈상품차감", "부가서비스 해지", "30000", "김*철", "010-****-5678", "늘붉은통신"}, + {"유심차감", "유심비 미수", "7700", "이*수", "010-****-1234", "스마트모바일"}, + {"정산추가", "테스트", "-3000", "박*영", "010-****-9876", "블루폰"}, + {"정산차감", "악세사리", "15000", "홍*동", "010-****-2222", "메인텔레콤"}, + {"정산차감", "서류미비 차감", "50000", "최*아", "010-****-7788", "늘푸른통신"}, + {"정산추가", "정책 보정", "-20000", "정*호", "010-****-9900", "D1"}, + }; + for (int i = 0; i < samples.length; i++) { + Object[] s = samples[i]; + AfterBalance b = new AfterBalance(); + b.setGroupId("demo"); + b.setOutcompanyId(shops.get(i % shops.size()).getId()); + b.setIncompanyId(inId); + b.setOutcompanyName(String.valueOf(s[5])); + b.setGubun(String.valueOf(s[0])); + b.setReason(String.valueOf(s[1])); + b.setAmount(new BigDecimal(String.valueOf(s[2]))); + b.setCustomerName(String.valueOf(s[3])); + b.setOpenphone(String.valueOf(s[4])); + b.setTelecom(i % 3 == 0 ? "SKT" : (i % 3 == 1 ? "KT" : "LGU+")); + b.setOutCategory("판매점"); + b.setOpendate(LocalDate.now().minusDays(20 + i * 5)); + b.setBasedate(LocalDate.now().minusDays(i * 2)); + b.setMemo(i % 3 == 0 ? "확인필요" : null); + em.persist(b); + } + } + + private void seedReturnsEnrich() { + List rows = em.createQuery("select r from UnreturnedPhone r where r.groupId='demo'", UnreturnedPhone.class).getResultList(); + List ins = em.createQuery("select c from Company c where c.groupId='demo' and c.type='IN'", Company.class).getResultList(); + List shops = em.createQuery("select c from Company c where c.groupId='demo' and c.type in ('SHOP','OUT')", Company.class).getResultList(); + Long inId = ins.isEmpty() ? null : ins.getFirst().getId(); + Long shopId = shops.isEmpty() ? null : shops.getFirst().getId(); + for (UnreturnedPhone r : rows) { + if (r.getTelecom() == null) r.setTelecom("SKT"); + if (r.getReturnStatus() == null) r.setReturnStatus("미반납"); + if (r.getIncompanyId() == null) r.setIncompanyId(inId); + } + if (rows.size() >= 5 || shopId == null) return; + String[][] samples = { + {"KT", "박*지", "010-****-6595", "미반납", "A125", "50000", "0"}, + {"SKT", "홍*동", "010-****-2222", "반납", "sm-g220", "1234", "10000"}, + {"LGU+", "김*수", "010-****-3344", "미반납", "SM-S928N", "IMEI9001", "30000"}, + {"SKT", "최*아", "010-****-7788", "반납", "A2860", "SN7788", ""}, + {"KT", "정*호", "010-****-9900", "미반납", "SM-A546", "A5469900", "15000"}, + }; + for (int i = 0; i < samples.length; i++) { + String[] s = samples[i]; + UnreturnedPhone row = new UnreturnedPhone(); + row.setGroupId("demo"); + row.setOutcompanyId(shopId); + row.setIncompanyId(inId); + row.setOpendate(LocalDate.now().minusDays(5 + i * 3)); + row.setTelecom(s[0]); + row.setCustomerName(s[1]); + row.setOpenphone(s[2]); + row.setReturnStatus(s[3]); + row.setModel(s[4]); + row.setSerial(s[5]); + if (!s[6].isBlank()) row.setDeductAmount(new BigDecimal(s[6])); + if ("반납".equals(s[3])) row.setReturndate(LocalDate.now().minusDays(i)); + em.persist(row); + } + } + + private void seedIndocsEnrich() { + List docs = em.createQuery("select d from IncompleteDoc d where d.groupId='demo'", IncompleteDoc.class).getResultList(); + List ins = em.createQuery("select c from Company c where c.groupId='demo' and c.type='IN'", Company.class).getResultList(); + List shops = em.createQuery("select c from Company c where c.groupId='demo' and c.type='SHOP'", Company.class).getResultList(); + Long inId = ins.isEmpty() ? null : ins.getFirst().getId(); + Long shopId = shops.isEmpty() ? null : shops.getFirst().getId(); + for (IncompleteDoc d : docs) { + if (d.getTelecom() == null) d.setTelecom("SKT"); + if (d.getStatus() == null) d.setStatus("미비"); + if (d.getIncompanyId() == null) d.setIncompanyId(inId); + } + if (docs.size() >= 3 || shopId == null) return; + String[][] samples = { + {"KT", "이*수", "010-****-1234", "인감증명서, 가족관계증명서", "50000", "추가서류 미제출"}, + {"LGU+", "박*영", "010-****-9876", "위임장", "20000", "대리개통 서류"}, + }; + for (int i = 0; i < samples.length; i++) { + String[] s = samples[i]; + IncompleteDoc d = new IncompleteDoc(); + d.setGroupId("demo"); + d.setOutcompanyId(shopId); + d.setIncompanyId(inId); + d.setOpendate(LocalDate.now().minusDays(3L + i * 7)); + d.setTelecom(s[0]); + d.setCustomerName(s[1]); + d.setOpenphone(s[2]); + d.setMissingDocs(s[3]); + d.setDeductAmount(new BigDecimal(s[4])); + d.setDeductReason(s[5]); + d.setDeadline(LocalDate.now().plusDays(5L - i * 3)); + d.setStatus(i == 1 ? "마감임박" : "미비"); + em.persist(d); + } + } + + private void enrichReceptionProgressFields() { + List rows = em.createQuery( + "select r from HomesReception r where r.groupId='demo'", HomesReception.class).getResultList(); + int i = 0; + for (HomesReception r : rows) { + if (r.getCustomerType() == null || r.getCustomerType().isBlank()) r.setCustomerType("public"); + if (r.getPaymentMethod() == null || r.getPaymentMethod().isBlank()) { + String pt = r.getPaymentType(); + r.setPaymentMethod("카드".equals(pt) ? "card" : "지로".equals(pt) ? "paper" : "전화납부".equals(pt) ? "telephone" : "transfer"); + } + if (r.getProductType() == null || r.getProductType().isBlank()) { + String cat = r.getProductCategory(); + r.setProductType("전화".equals(cat) ? "e02" : "TV".equals(cat) ? "e03" : "렌탈".equals(cat) || (cat != null && (cat.contains("정수") || cat.contains("비데") || cat.contains("공기"))) ? "e04" : "e01"); + } + if (r.getEmail() == null || r.getEmail().isBlank()) { + r.setEmail("sample" + (++i) + "@example.com"); + } + if (r.getAddress() == null || r.getAddress().isBlank()) { + r.setZipcode("06236"); + r.setAddress("서울특별시 강남구 테헤란로 " + (10 + i)); + r.setAddressDetail((100 + i) + "호"); + } + if (r.getIdentifyNumber() == null || r.getIdentifyNumber().isBlank()) { + r.setIdentifyNumber("900101-1******"); + } + if (r.getPostIt() == null || r.getPostIt().isBlank()) r.setPostIt("샘플메모" + (i % 50 + 1)); + if (r.getBranchPhone() == null || r.getBranchPhone().isBlank()) r.setBranchPhone("1599-2889"); + if (r.getTel() == null || r.getTel().isBlank()) r.setTel("02-100" + i); + if (r.getGiftSended() == null && r.getGiftDate() != null) r.setGiftSended(r.getGiftDate()); + if (r.getGiftCashAmount() == null && r.getAmount() != null + && r.getGiftStatus() != null && !"미지급".equals(r.getGiftStatus()) && !"없음".equals(r.getGiftCategory())) { + r.setGiftCashAmount(r.getAmount()); + } + if (r.getIspName() == null || r.getIspName().isBlank()) r.setIspName(r.getCarrier()); + if (r.getProcessor() == null || r.getProcessor().isBlank()) r.setProcessor("본사"); + } + } + + private void seedReceptionIspsIfEmpty() { + if (em.createQuery("select count(i) from HomesReceptionIsp i where i.groupId='demo'", Long.class).getSingleResult() > 0) { + return; + } + Object[][] isps = { + {"LG USIM", "LGU+", 1, false}, + {"LGU+ 온라인", "LGU+", 2, false}, + {"LGU+ (T일반)", "LGU+", 3, false}, + {"KT USIM", "KT", 4, false}, + {"KT일반 (강북)", "KT", 5, false}, + {"KT일반 (강남)", "KT", 6, false}, + {"KT일반 (강서)", "KT", 7, false}, + {"KT일반 (경남)", "KT", 8, false}, + {"KT일반 (경북)", "KT", 9, false}, + {"KT일반 (충청)", "KT", 10, false}, + {"KT일반 (전라)", "KT", 11, false}, + {"SKB USIM", "SKB", 12, false}, + {"SKB다이렉트(서울/경기)", "SKB", 13, false}, + {"SKB다이렉트(부산/경남)", "SKB", 14, false}, + {"SKB다이렉트(대구/경북)", "SKB", 15, false}, + {"SKB다이렉트(충청/강원)", "SKB", 16, false}, + {"SKB다이렉트(전라/제주)", "SKB", 17, false}, + {"SKB POP", "SKB", 18, false}, + {"SKT온/한가족(서울/경기)", "SKT", 19, true}, + {"SKT온/한가족(부산/경남)", "SKT", 20, true}, + {"SKT온/한가족(대구/경북)", "SKT", 21, true}, + {"SKT온/한가족(대전/충청/강원)", "SKT", 22, true}, + {"SKT온/한가족(전라/제주)", "SKT", 23, true}, + {"SK매직", "SK매직", 24, false}, + {"LG렌탈", "LG렌탈", 25, false}, + {"Skylife(전국)", "Skylife", 26, false}, + }; + for (Object[] row : isps) { + HomesReceptionIsp isp = new HomesReceptionIsp(); + isp.setGroupId("demo"); + isp.setName(String.valueOf(row[0])); + isp.setTelecom(String.valueOf(row[1])); + isp.setSeq((Integer) row[2]); + isp.setActive(true); + isp.setIsSktb((Boolean) row[3]); + em.persist(isp); + em.flush(); + + boolean rental = "SK매직".equals(isp.getTelecom()) || "LG렌탈".equals(isp.getTelecom()); + String[][] products = rental + ? new String[][]{{"e04", isp.getName() + " 기본렌탈"}, {"e04", isp.getName() + " 프리미엄"}} + : new String[][]{ + {"e01", isp.getTelecom() + " 인터넷 기본"}, + {"e01", isp.getTelecom() + " 인터넷 프리미엄"}, + {"e03", isp.getTelecom() + " TV"}, + {"e02", isp.getTelecom() + " 집전화"}, + }; + for (String[] p : products) { + HomesReceptionIspProduct prod = new HomesReceptionIspProduct(); + prod.setGroupId("demo"); + prod.setIspId(isp.getId()); + prod.setProductType(p[0]); + prod.setName(p[1]); + prod.setActive(true); + em.persist(prod); + } + for (String agName : List.of("24개월", "36개월", "48개월", "60개월")) { + HomesReceptionIspAgreement ag = new HomesReceptionIspAgreement(); + ag.setGroupId("demo"); + ag.setIspId(isp.getId()); + ag.setName(agName); + ag.setActive(true); + em.persist(ag); + } + } + } + + private Company company(String type, String name, String manager, String phone) { + Company c = new Company(); + c.setGroupId("demo"); + c.setType(type); + c.setName(name); + c.setManagerName(manager); + c.setPhone(phone); + c.setActive(true); + em.persist(c); + em.flush(); + return c; + } + + private Stock stock(String gubun, String telecom, String model, String color, String serial, + String state, Long inId, Long outId, String price) { + Stock s = new Stock(); + s.setGroupId("demo"); + s.setGubun(gubun); + s.setTelecom(telecom); + s.setTelcompany(telecom); + s.setModel(model); + s.setColor(color); + s.setSerial(serial); + s.setBarcode(serial); + s.setCond("정상"); + s.setPay("할부"); + s.setState(state); + s.setOutcode(state.equals("OUT") || state.equals("OPEN") ? "21" : "10"); + s.setIncompanyId(inId); + s.setOutcompanyId(outId); + s.setIndate(LocalDate.now().minusDays(5)); + if (outId != null) s.setOutdate(LocalDate.now().minusDays(1)); + s.setProcessDate(LocalDate.now().minusDays(1)); + s.setProcessor("데모 관리자"); + s.setInprice(new BigDecimal(price)); + s.setUserid("demo"); + s.setProcid("demo"); + s.setUid("stk-" + UUID.randomUUID()); + em.persist(s); + return s; + } +} diff --git a/backend/src/main/java/com/bizcare/agent/DomainEntities.java b/backend/src/main/java/com/bizcare/agent/DomainEntities.java new file mode 100644 index 0000000..271c70f --- /dev/null +++ b/backend/src/main/java/com/bizcare/agent/DomainEntities.java @@ -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; +} diff --git a/backend/src/main/java/com/bizcare/agent/FileStorageService.java b/backend/src/main/java/com/bizcare/agent/FileStorageService.java new file mode 100644 index 0000000..1fde98e --- /dev/null +++ b/backend/src/main/java/com/bizcare/agent/FileStorageService.java @@ -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; + } +} diff --git a/backend/src/main/java/com/bizcare/agent/HealthController.java b/backend/src/main/java/com/bizcare/agent/HealthController.java new file mode 100644 index 0000000..85dbdd5 --- /dev/null +++ b/backend/src/main/java/com/bizcare/agent/HealthController.java @@ -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 health() { + return Map.of("status", "UP"); + } +} diff --git a/backend/src/main/java/com/bizcare/agent/SecurityConfig.java b/backend/src/main/java/com/bizcare/agent/SecurityConfig.java new file mode 100644 index 0000000..c46e7f8 --- /dev/null +++ b/backend/src/main/java/com/bizcare/agent/SecurityConfig.java @@ -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 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 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 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;} +} diff --git a/backend/src/main/java/com/bizcare/agent/ShopController.java b/backend/src/main/java/com/bizcare/agent/ShopController.java new file mode 100644 index 0000000..5df0a9e --- /dev/null +++ b/backend/src/main/java/com/bizcare/agent/ShopController.java @@ -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 f,@RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="30") int size) { + Map scoped=new HashMap<>(f); Class 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 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 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 body) { return ApiResponse.ok(crud.update(ScanDoc.class,id,body,u)); } + @SuppressWarnings("unchecked") private Class type(String uri) { + if(uri.contains("stocks"))return (Class)Stock.class;if(uri.contains("opens"))return (Class)OpenRecord.class;if(uri.contains("settlements"))return (Class)SettlementMonth.class; + if(uri.contains("fareboxes"))return (Class)Farebox.class;if(uri.contains("indocs"))return (Class)IncompleteDoc.class;if(uri.contains("returns"))return (Class)UnreturnedPhone.class;if(uri.contains("scans"))return (Class)ScanDoc.class;return (Class)BbsPost.class; + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties new file mode 100644 index 0000000..0b1a488 --- /dev/null +++ b/backend/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=agent diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..ef0ef64 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -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} diff --git a/backend/src/test/java/com/bizcare/agent/AgentApplicationTests.java b/backend/src/test/java/com/bizcare/agent/AgentApplicationTests.java new file mode 100644 index 0000000..bafeb68 --- /dev/null +++ b/backend/src/test/java/com/bizcare/agent/AgentApplicationTests.java @@ -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() { + } + +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..df60401 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..28d8226 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +.DS_Store +*.log +.env* diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -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 }] + } +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..f757e56 --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/frontend/README.md @@ -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. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..acc9162 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + 빌링 케어 / 비즈케어 홈즈 + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..1e7fadb --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..dd9657a --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3607 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "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" + } + }, + "node_modules/@ant-design/charts-util": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@ant-design/charts-util/-/charts-util-0.0.3.tgz", + "integrity": "sha512-x1H7UT6t4dXAyGRoHqlOnEsEqBSTANFGTZEAMI0CWYhYUpp13n0o9grl9oPtoL6FEQMjUBTY+zGJKlHkz8smMw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": ">=16.8.4", + "react-dom": ">=16.8.4" + } + }, + "node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", + "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.2.tgz", + "integrity": "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^2.1.2", + "@babel/runtime": "^7.23.2", + "@rc-component/util": "^1.4.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.3.2.tgz", + "integrity": "sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/icons-svg": "^4.5.0", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz", + "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==", + "license": "MIT" + }, + "node_modules/@ant-design/plots": { + "version": "2.6.8", + "resolved": "https://registry.npmjs.org/@ant-design/plots/-/plots-2.6.8.tgz", + "integrity": "sha512-QsunUs2d5rbq/1BwVhga/siA5H50OaG23YopMYwPD4sPsza6NQzPQ8FM3elNIsD/BIk298tihqX1cJ/MmvVJbQ==", + "license": "MIT", + "dependencies": { + "@ant-design/charts-util": "0.0.3", + "@antv/event-emitter": "^0.1.3", + "@antv/g": "^6.1.7", + "@antv/g2": "^5.2.7", + "@antv/g2-extension-plot": "^0.2.1", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": ">=16.8.4", + "react-dom": ">=16.8.4" + } + }, + "node_modules/@ant-design/react-slick": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz", + "integrity": "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "clsx": "^2.1.1", + "json2mq": "^0.2.0", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@antv/component": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@antv/component/-/component-2.1.11.tgz", + "integrity": "sha512-dTdz8VAd3rpjOaGEZTluz82mtzrP4XCtNlNQyrxY7VNRNcjtvpTLDn57bUL2lRu1T+iklKvgbE2llMriWkq9vQ==", + "license": "MIT", + "dependencies": { + "@antv/g": "^6.1.11", + "@antv/scale": "^0.4.16", + "@antv/util": "^3.3.10", + "svg-path-parser": "^1.1.0" + } + }, + "node_modules/@antv/component/node_modules/@antv/scale": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@antv/scale/-/scale-0.4.16.tgz", + "integrity": "sha512-5wg/zB5kXHxpTV5OYwJD3ja6R8yTiqIOkjOhmpEJiowkzRlbEC/BOyMvNUq5fqFIHnMCE9woO7+c3zxEQCKPjw==", + "license": "MIT", + "dependencies": { + "@antv/util": "^3.3.7", + "color-string": "^1.5.5", + "fecha": "^4.2.1" + } + }, + "node_modules/@antv/coord": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/@antv/coord/-/coord-0.4.7.tgz", + "integrity": "sha512-UTbrMLhwJUkKzqJx5KFnSRpU3BqrdLORJbwUbHK2zHSCT3q3bjcFA//ZYLVfIlwqFDXp/hzfMyRtp0c77A9ZVA==", + "license": "MIT", + "dependencies": { + "@antv/scale": "^0.4.12", + "@antv/util": "^2.0.13", + "gl-matrix": "^3.4.3" + } + }, + "node_modules/@antv/coord/node_modules/@antv/scale": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@antv/scale/-/scale-0.4.16.tgz", + "integrity": "sha512-5wg/zB5kXHxpTV5OYwJD3ja6R8yTiqIOkjOhmpEJiowkzRlbEC/BOyMvNUq5fqFIHnMCE9woO7+c3zxEQCKPjw==", + "license": "MIT", + "dependencies": { + "@antv/util": "^3.3.7", + "color-string": "^1.5.5", + "fecha": "^4.2.1" + } + }, + "node_modules/@antv/coord/node_modules/@antv/scale/node_modules/@antv/util": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/@antv/util/-/util-3.3.11.tgz", + "integrity": "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "gl-matrix": "^3.3.0", + "tslib": "^2.3.1" + } + }, + "node_modules/@antv/coord/node_modules/@antv/util": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@antv/util/-/util-2.0.17.tgz", + "integrity": "sha512-o6I9hi5CIUvLGDhth0RxNSFDRwXeywmt6ExR4+RmVAzIi48ps6HUy+svxOCayvrPBN37uE6TAc2KDofRo0nK9Q==", + "license": "ISC", + "dependencies": { + "csstype": "^3.0.8", + "tslib": "^2.0.3" + } + }, + "node_modules/@antv/event-emitter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@antv/event-emitter/-/event-emitter-0.1.3.tgz", + "integrity": "sha512-4ddpsiHN9Pd4UIlWuKVK1C4IiZIdbwQvy9i7DUSI3xNJ89FPUFt8lxDYj8GzzfdllV0NkJTRxnG+FvLk0llidg==", + "license": "MIT" + }, + "node_modules/@antv/expr": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@antv/expr/-/expr-1.0.2.tgz", + "integrity": "sha512-vrfdmPHkTuiS5voVutKl2l06w1ihBh9A8SFdQPEE+2KMVpkymzGOF1eWpfkbGZ7tiFE15GodVdhhHomD/hdIwg==", + "license": "MIT" + }, + "node_modules/@antv/g": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@antv/g/-/g-6.3.1.tgz", + "integrity": "sha512-WYEKqy86LHB2PzTmrZXrIsIe+3Epeds2f68zceQ+BJtRoGki7Sy4IhlC8LrUMztgfT1t3d/0L745NWZwITroKA==", + "license": "MIT", + "dependencies": { + "@antv/g-lite": "2.7.0", + "@antv/util": "^3.3.5", + "@babel/runtime": "^7.25.6", + "gl-matrix": "^3.4.3", + "html2canvas": "^1.4.1" + } + }, + "node_modules/@antv/g-canvas": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@antv/g-canvas/-/g-canvas-2.2.0.tgz", + "integrity": "sha512-h7zVBBo2aO64DuGKvq9sG+yTU3sCUb9DALCVm7nz8qGPs8hhLuFOkKPEzUDNfNYZGJUGzY8UDtJ3QRGRFcvEQg==", + "license": "MIT", + "dependencies": { + "@antv/g-lite": "2.7.0", + "@antv/g-math": "3.1.0", + "@antv/util": "^3.3.5", + "@babel/runtime": "^7.25.6", + "gl-matrix": "^3.4.3", + "tslib": "^2.5.3" + } + }, + "node_modules/@antv/g-lite": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@antv/g-lite/-/g-lite-2.7.0.tgz", + "integrity": "sha512-uSzgHYa5bwR5L2Au7/5tsOhFmXKZKLPBH90+Q9bP9teVs5VT4kOAi0isPSpDI8uhdDC2/VrfTWu5K9HhWI6FWw==", + "license": "MIT", + "dependencies": { + "@antv/g-math": "3.1.0", + "@antv/util": "^3.3.5", + "@antv/vendor": "^1.0.3", + "@babel/runtime": "^7.25.6", + "eventemitter3": "^5.0.1", + "gl-matrix": "^3.4.3", + "tslib": "^2.5.3" + } + }, + "node_modules/@antv/g-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@antv/g-math/-/g-math-3.1.0.tgz", + "integrity": "sha512-DtN1Gj/yI0UiK18nSBsZX8RK0LszGwqfb+cBYWgE+ddyTm8dZnW4tPUhV7QXePsS6/A5hHC+JFpAAK7OEGo5ZQ==", + "license": "MIT", + "dependencies": { + "@antv/util": "^3.3.5", + "@babel/runtime": "^7.25.6", + "gl-matrix": "^3.4.3", + "tslib": "^2.5.3" + } + }, + "node_modules/@antv/g-plugin-dragndrop": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@antv/g-plugin-dragndrop/-/g-plugin-dragndrop-2.1.1.tgz", + "integrity": "sha512-+aesDUJVQDs6UJ2bOBbDlaGAPCfHmU0MbrMTlQlfpwNplWueqtgVAZ3L57oZ2ZGHRWUHiRwZGPjXMBM3O2LELw==", + "license": "MIT", + "dependencies": { + "@antv/g-lite": "2.7.0", + "@antv/util": "^3.3.5", + "@babel/runtime": "^7.25.6", + "tslib": "^2.5.3" + } + }, + "node_modules/@antv/g2": { + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/@antv/g2/-/g2-5.4.8.tgz", + "integrity": "sha512-IvgIpwmT4M5/QAd3Mn2WiHIDeBqFJ4WA2gcZhRRSZuZ2KmgCqZWZwwIT0hc+kIGxwYeDoCQqf//t6FMVu3ryBg==", + "license": "MIT", + "dependencies": { + "@antv/component": "^2.1.9", + "@antv/coord": "^0.4.7", + "@antv/event-emitter": "^0.1.3", + "@antv/expr": "^1.0.2", + "@antv/g": "^6.1.24", + "@antv/g-canvas": "^2.0.43", + "@antv/g-plugin-dragndrop": "^2.0.35", + "@antv/scale": "^0.5.1", + "@antv/util": "^3.3.10", + "@antv/vendor": "^1.0.11", + "flru": "^1.0.2", + "pdfast": "^0.2.0" + } + }, + "node_modules/@antv/g2-extension-plot": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@antv/g2-extension-plot/-/g2-extension-plot-0.2.2.tgz", + "integrity": "sha512-KJXCXO7as+h0hDqirGXf1omrNuYzQmY3VmBmp7lIvkepbQ7sz3pPwy895r1FWETGF3vTk5UeFcAF5yzzBHWgbw==", + "dependencies": { + "@antv/g2": "^5.1.8", + "@antv/util": "^3.3.5", + "@antv/vendor": "^1.0.10" + } + }, + "node_modules/@antv/scale": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@antv/scale/-/scale-0.5.2.tgz", + "integrity": "sha512-rTHRAwvpHWC5PGZF/mJ2ZuTDqwwvVBDRph0Uu5PV9BXwzV7K8+9lsqGJ+XHVLxe8c6bKog5nlzvV/dcYb0d5Ow==", + "license": "MIT", + "dependencies": { + "@antv/util": "^3.3.7", + "color-string": "^1.5.5", + "fecha": "^4.2.1" + } + }, + "node_modules/@antv/util": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/@antv/util/-/util-3.3.11.tgz", + "integrity": "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "gl-matrix": "^3.3.0", + "tslib": "^2.3.1" + } + }, + "node_modules/@antv/vendor": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@antv/vendor/-/vendor-1.0.11.tgz", + "integrity": "sha512-LmhPEQ+aapk3barntaiIxJ5VHno/Tyab2JnfdcPzp5xONh/8VSfed4bo/9xKo5HcUAEydko38vYLfj6lJliLiw==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.2.1", + "@types/d3-color": "^3.1.3", + "@types/d3-dispatch": "^3.0.6", + "@types/d3-dsv": "^3.0.7", + "@types/d3-ease": "^3.0.2", + "@types/d3-fetch": "^3.0.7", + "@types/d3-force": "^3.0.10", + "@types/d3-format": "^3.0.4", + "@types/d3-geo": "^3.1.0", + "@types/d3-hierarchy": "^3.1.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-path": "^3.1.0", + "@types/d3-quadtree": "^3.0.6", + "@types/d3-random": "^3.0.3", + "@types/d3-scale": "^4.0.9", + "@types/d3-scale-chromatic": "^3.1.0", + "@types/d3-shape": "^3.1.7", + "@types/d3-time": "^3.0.4", + "@types/d3-timer": "^3.0.2", + "d3-array": "^3.2.4", + "d3-color": "^3.1.0", + "d3-dispatch": "^3.0.1", + "d3-dsv": "^3.0.1", + "d3-ease": "^3.0.1", + "d3-fetch": "^3.0.1", + "d3-force": "^3.0.0", + "d3-force-3d": "^3.0.5", + "d3-format": "^3.1.0", + "d3-geo": "^3.1.1", + "d3-geo-projection": "^4.0.0", + "d3-hierarchy": "^3.1.2", + "d3-interpolate": "^3.0.1", + "d3-path": "^3.1.0", + "d3-quadtree": "^3.0.1", + "d3-random": "^3.0.1", + "d3-regression": "^1.3.10", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.1.0", + "d3-shape": "^3.2.0", + "d3-time": "^3.1.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.73.0.tgz", + "integrity": "sha512-HZQRN/UMBu+Ut+/9MiAChkbP4qZqrNOWBcNI45vOT40GVhbGR0JgHB87L48D4iAqFQIdVmeQYtV9RF89AjTKkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.73.0.tgz", + "integrity": "sha512-Gp+KJRylv2aW7thRpG5p1KTxZq4ZJFbWowrKzufNq9d3ssl3r3JviYV45/+p+7CN1Nv0zDd1e8Ex0b/HUDq4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.73.0.tgz", + "integrity": "sha512-3de96NdtXhxERMjIz7wsp2HYMY6pMQycGxFWac2mFecAx6VeARF/IqFb1QIaqiCRIdfzBwzTed+pCTCoiS+CYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.73.0.tgz", + "integrity": "sha512-5zx/uPW32TiaOeVY1dQ/H5iOf0K1HOdFKOJhLqGl4o63+i1fpzoqqu/mKtd7OFgFjNCdhlyTGgjVkQTZm1ELcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.73.0.tgz", + "integrity": "sha512-qNe4gKHaGnLuZJ8toUg90JAa0S2vTVvDw+0bRi3q1avXZXDT4u5mMeECf3nD4HYrbdn1O7dXqWut4onY/yx/Xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.73.0.tgz", + "integrity": "sha512-cCehYh5hTbfShm/fxTD6wwrGUWIpvX+N5OxmAMhFhDeTGXvw+BeNj889tpxsFQ9ZLatQ6wImuY8tsKLZ+FMz7w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.73.0.tgz", + "integrity": "sha512-d5j5GDU/2dMgjVhw7TQT9ITrsIr1Y02KEXKyVGIXUkD+KiaxE9TP65FS2ZdgTBemQvoRL+gSBdbrIm3cQIeacg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.73.0.tgz", + "integrity": "sha512-Eyf1SrP3+yR1DI3OJgOY2Pvrr9dWP9TK37xPaDYycwTtlGlI45erJAVIfH5/m/xosDt6BupJYEFi47bvbTuuyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.73.0.tgz", + "integrity": "sha512-IlT/OJApEDKaMmCooHuncgJZbbCe7T5QIWmTZBEtYscWvzPQuuEinVcid6kwQRVQOUdb7PUCz4jQHnaYXdfJXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.73.0.tgz", + "integrity": "sha512-L+JYcb/vdg5fmcH08V6o0YYLU28cTH1SPNulwJdvK9NK49aXSkYy6oNpKBmddArVOXYqNepriDGiZ04G54kh1Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.73.0.tgz", + "integrity": "sha512-Qtk0g3bKV6OwWjIm7R8kQN1uOZRKQt/MODK2a8QfkwhTpXBD53ozx5XLVWLGDQAVyp2otLW4D2wB98XfAfMPGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.73.0.tgz", + "integrity": "sha512-wX0NQKZVxltkAOVmzFcpOaMpdaUvsq1Eqpx9tkAfl71UdkTlSo1R4AdAnGccR1Fm2+TzFgZ22CyyGuZ41RDr/A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.73.0.tgz", + "integrity": "sha512-vPe7UGBMWyiLTtnqS4xxgMQFSFGmtQwhwCxuiw6lXygaO6bVt0D8dFVg8Xv05eaiN3ybC0HXXHUAohFMFvqoCQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.73.0.tgz", + "integrity": "sha512-2CwIWr9cemFC/CbRBWZvuk5mffz6ObmfFkfcC/9rTQ7f+icNhYr2kOjf9Rt8lLvugvkdGDOmkoVoFFHh6ClCTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.73.0.tgz", + "integrity": "sha512-nDadfJgg7NBBxG0N560wOe7LLX5QiYp6qBaI7viuk5EUORFBktU/NfV0MbTqU3gTqQDCh4VyxKdo5VADxk9w8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.73.0.tgz", + "integrity": "sha512-wGjJC+NLH9xP+IKGn9RDW94ojJR/wPbg5WCnQjj/oReaOtCQthr8ws1zICe77JFmo4ouUdeTHHZL/ESGiF6Pmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.73.0.tgz", + "integrity": "sha512-I7X47GPGljw225YUQ5SbC/rb1Kkdrd0yQf0x+hYxeKS6DpfjMbo9ccQPQ6LNY6BoJQ1sHhgDUGuMn5Vg5gHT6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.73.0.tgz", + "integrity": "sha512-5lWj+3h+74Fm1jYOO9qkJA4xkAlZA099DkXppuXsk7UpnpZLttsefrZU469vChGaG6hcSqrkKXQOvMTZtbjeNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.73.0.tgz", + "integrity": "sha512-WaNRvh4f6zY9CvUQk2YoA1O90ieWrIklI84+HXFr9Isjz9CSESrdqo/RtIYt4Dll/cAchqGDMehfaZd0vqEFZw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-6.0.0.tgz", + "integrity": "sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/cascader": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.17.0.tgz", + "integrity": "sha512-3cVNG0zrQF1PoXq262L3wGCU+/YLEC1mGSVHDl577dQmA0ZKkXFbY6nwyXo+beCcM7buo49t24jkr+QZdL7O8w==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.8.0", + "@rc-component/tree": "~1.3.2", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/checkbox": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/checkbox/-/checkbox-2.0.0.tgz", + "integrity": "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/collapse": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/collapse/-/collapse-1.2.0.tgz", + "integrity": "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-3.1.1.tgz", + "integrity": "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-2.0.2.tgz", + "integrity": "sha512-uiGpAlblCNlziHPwj4S4Iy/oemeuz/hR03mbiEjTCXwsqOIN3BOzsRMyDwpyO5Fm0vIEEJRUf9ZtbRLbhksuTA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dialog": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/dialog/-/dialog-1.10.0.tgz", + "integrity": "sha512-eDukNlz9vNszAGv7i3zKXdxEd3wgVmNxuJijYt8zvTh17QwTu8KK/bdURRd/lU4qaMzhO1HKKmMrwOnkaw0BvQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.3.3", + "@rc-component/portal": "^2.1.0", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/drawer": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@rc-component/drawer/-/drawer-1.4.2.tgz", + "integrity": "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.1.3", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dropdown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/dropdown/-/dropdown-1.0.2.tgz", + "integrity": "sha512-6PY2ecUSYhDPhkNHHb4wfeAya04WhpmUSKzdR60G+kMNVUCX2vjT/AgTS0Lz0I/K6xrPMJ3enQbwVpeN3sHCgg==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/@rc-component/form": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.5.tgz", + "integrity": "sha512-d24EYtvUOBhxEtSd/EqIu9DaMuqrWF2IRIvAFCTM6NQ/GJIYNr8DvEpUSUlv2uPxEJ0ZPwYQ+wwlGIAaiHvdrw==", + "license": "MIT", + "dependencies": { + "@rc-component/async-validator": "^6.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/image": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@rc-component/image/-/image-1.9.0.tgz", + "integrity": "sha512-khF7w7xkBH5B1bsBcI1FSUZdkyd1aqpl2eYyILCqCzzQH3XdfehGUaZTnptyaJJfs09/R5hv9jXWyazOMFIClQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/portal": "^2.1.2", + "@rc-component/util": "^1.10.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/input": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/input/-/input-1.3.1.tgz", + "integrity": "sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig==", + "license": "MIT", + "dependencies": { + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/input-number": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@rc-component/input-number/-/input-number-1.6.2.tgz", + "integrity": "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==", + "license": "MIT", + "dependencies": { + "@rc-component/mini-decimal": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mentions": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/mentions/-/mentions-1.10.0.tgz", + "integrity": "sha512-CI1njYUVY0NjHtLhNoVmXlJyy568Sfep9Wsak6vmGjtT6uazx98djGYlCXz2xkHhEm73g91Y3MTvzUyE5avI7w==", + "license": "MIT", + "dependencies": { + "@rc-component/input": "~1.3.0", + "@rc-component/menu": "~1.4.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/menu": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@rc-component/menu/-/menu-1.4.1.tgz", + "integrity": "sha512-3GsVRoQ4cnF/AoIQ4P+Z1haBfgfBPQfLT1RJY3Nu4DzOnheTslfCiGSPj7bv/cLj5sW5pHqN25dDXGP3JELAlQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.4.tgz", + "integrity": "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/motion": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@rc-component/motion/-/motion-1.3.3.tgz", + "integrity": "sha512-Xh3IszxvlSv3/PLYFyC2UZi9LNB83yOnkB/LNmRzaypZLvkhqUIPS7MQpGZcCMWrNsXV2p6YTSWbSGvFpEle9A==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-2.0.1.tgz", + "integrity": "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/notification": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@rc-component/notification/-/notification-2.0.7.tgz", + "integrity": "sha512-nqZzpf6BPdaj+3ILx7si79LLmqPKyUmQoXa+/9gg0SkH0v1DbD66oJgRMSBEVnd/zUT3D4gwxWIHUKebYf2ZXQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/overflow": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/overflow/-/overflow-1.0.1.tgz", + "integrity": "sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/pagination": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/pagination/-/pagination-1.4.0.tgz", + "integrity": "sha512-CW1g7P9V8u+e8JQdUsl2RWg+GCsoee0mtJjZUCCxn/vb3jzOwDKm6hAdwddHCVBfWJ58eGUBZz3IvnU8rRktjw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/picker": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@rc-component/picker/-/picker-1.11.0.tgz", + "integrity": "sha512-6qXGKtoJvO8sUd17m5cyNEbEJub0zflCHnaZTBBmj63DPRZYc0WEHN8rp6hFSl+yMCJS/dJY5G+1fQ8bLCuD7A==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/trigger": "^3.6.15", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/@rc-component/portal": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-2.2.1.tgz", + "integrity": "sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/progress": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/progress/-/progress-1.0.2.tgz", + "integrity": "sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-2.0.0.tgz", + "integrity": "sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/rate": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/rate/-/rate-1.0.1.tgz", + "integrity": "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/resize-observer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/resize-observer/-/resize-observer-1.1.2.tgz", + "integrity": "sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/segmented": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@rc-component/segmented/-/segmented-1.3.0.tgz", + "integrity": "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/select": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.8.2.tgz", + "integrity": "sha512-HQ9zuYqjfZTlcEMWlU1GAPBajd2OHIMVHyjZSGVTCVARwkfCgvXZMTEn0cduy3L+ejAKkaZluOQvxovZoaJaQw==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.2.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/slider": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/slider/-/slider-1.1.1.tgz", + "integrity": "sha512-LSzgWGYDgeCDgR4r1XlU29gbYws6HpLnvJd/uMhLeW/vQgxldeR+Wb4uzHDCHiYEbr1bnEHWdjkPxjJRHxuiig==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/steps": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rc-component/steps/-/steps-1.2.2.tgz", + "integrity": "sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/switch": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rc-component/switch/-/switch-1.0.3.tgz", + "integrity": "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/table": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.10.4.tgz", + "integrity": "sha512-HwoTnrwc29zeoXkXGhWqzJh8FIibGUxi1jM4LtoSzmR9d5Vv5osUQpZxnXKBP8iOCvyD6BQzZm1nXJRcnrxpAg==", + "license": "MIT", + "dependencies": { + "@rc-component/context": "^2.0.1", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tabs": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@rc-component/tabs/-/tabs-1.11.0.tgz", + "integrity": "sha512-hA/drZYOVa/MMIb4M2fWf3yaTyTG4qVuIABmghvEhyfw2nBob5VTH69lMCDjSVKmgODjO6nWlCV+gVn3xBrj5Q==", + "license": "MIT", + "dependencies": { + "@rc-component/dropdown": "~1.0.0", + "@rc-component/menu": "~1.4.0", + "@rc-component/motion": "^1.1.3", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tooltip": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/tooltip/-/tooltip-1.4.0.tgz", + "integrity": "sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.7.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-2.4.0.tgz", + "integrity": "sha512-aui4r4TqmTzwaBgcQxHYep8kM8PTjZFufjokObpy35KfFeZ0k9ArquWFZqegQlH24P14t+F0qO0mGTgzlav1yg==", + "license": "MIT", + "dependencies": { + "@rc-component/portal": "^2.2.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.7.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tree": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@rc-component/tree/-/tree-1.3.2.tgz", + "integrity": "sha512-bJFj46wEkpBPnWyTm18XmgAgNQ/4YvprxMOPPY2a6rmhGJYxLuNKEFiL5Qej4Qctu9wHJm8WW+v2SYskafE0kA==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.2.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/tree-select": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.11.0.tgz", + "integrity": "sha512-EhS0X0wtUhBfK4S5TlpSY3MR9ndPMGgujtt1PJW3Ej+ToAlnS/6ohYURtCoXBYGqazUwHmgQGVUDsfpVwhWPkg==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.8.0", + "@rc-component/tree": "~1.3.2", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/trigger": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.9.1.tgz", + "integrity": "sha512-LNsYvz60mrLJ/kRvKcHE7boUvcQfVMCfRqZ71x3Fo9AOiZ1KKIEqkzMA8DNvz2V3Bcvir/vwQNn7JF1NPODQ7Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.2.0", + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/upload": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/upload/-/upload-1.1.1.tgz", + "integrity": "sha512-GvYWSKeaJTOxxC5p6+nOSadzfvXA1h8C/iHFPFZX+szH3JUXrvs+DLiW8YUTBgvMh8m63mJeHrlYlJzAlg+pDA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.11.1.tgz", + "integrity": "sha512-awVlI3ub2vqfqkYxOBc/uQ0efm3jw0wcrhtO/YWLyZfxiKXczKwNbVuhlnyxytDt7H9pbbVQiqr+O6MLATtRYg==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/virtual-list": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.3.2.tgz", + "integrity": "sha512-/smuvWBFdP/Is9QuNDKynD0+T3XTXWFyNXXNKJ4sno8CE3bTOK8sfgYmQJtYwLUNX+lv0Ytd+PMshgpmdReq5g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^8.0.0", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/virtual-list/node_modules/@babel/runtime": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.0.tgz", + "integrity": "sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/antd": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/antd/-/antd-6.5.0.tgz", + "integrity": "sha512-9zbVc9UukfGuqCvIAov01nlpDQWfARNmZQyt21ZhqLX7ilXmi4cdkp12xA48WEmXRXwZvno8A03qQuGE9JG8fg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/cssinjs": "^2.1.2", + "@ant-design/cssinjs-utils": "^2.1.2", + "@ant-design/fast-color": "^3.0.1", + "@ant-design/icons": "^6.3.1", + "@ant-design/react-slick": "~2.0.0", + "@babel/runtime": "^7.29.2", + "@rc-component/cascader": "~1.17.0", + "@rc-component/checkbox": "~2.0.0", + "@rc-component/collapse": "~1.2.0", + "@rc-component/color-picker": "~3.1.1", + "@rc-component/dialog": "~1.10.0", + "@rc-component/drawer": "~1.4.2", + "@rc-component/dropdown": "~1.0.2", + "@rc-component/form": "~1.8.5", + "@rc-component/image": "~1.9.0", + "@rc-component/input": "~1.3.1", + "@rc-component/input-number": "~1.6.2", + "@rc-component/mentions": "~1.10.0", + "@rc-component/menu": "~1.4.1", + "@rc-component/motion": "^1.3.3", + "@rc-component/mutate-observer": "^2.0.1", + "@rc-component/notification": "~2.0.7", + "@rc-component/pagination": "~1.4.0", + "@rc-component/picker": "~1.11.0", + "@rc-component/progress": "~1.0.2", + "@rc-component/qrcode": "~2.0.0", + "@rc-component/rate": "~1.0.1", + "@rc-component/resize-observer": "^1.1.2", + "@rc-component/segmented": "~1.3.0", + "@rc-component/select": "~1.8.2", + "@rc-component/slider": "~1.1.1", + "@rc-component/steps": "~1.2.2", + "@rc-component/switch": "~1.0.3", + "@rc-component/table": "~1.10.2", + "@rc-component/tabs": "~1.11.0", + "@rc-component/tooltip": "~1.4.0", + "@rc-component/tour": "~2.4.0", + "@rc-component/tree": "~1.3.2", + "@rc-component/tree-select": "~1.11.0", + "@rc-component/trigger": "^3.9.1", + "@rc-component/upload": "~1.1.1", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1", + "dayjs": "^1.11.11", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-binarytree": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", + "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force-3d": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", + "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", + "license": "MIT", + "dependencies": { + "d3-binarytree": "1", + "d3-dispatch": "1 - 3", + "d3-octree": "1", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo-projection": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-4.0.0.tgz", + "integrity": "sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==", + "license": "ISC", + "dependencies": { + "commander": "7", + "d3-array": "1 - 3", + "d3-geo": "1.12.0 - 3" + }, + "bin": { + "geo2svg": "bin/geo2svg.js", + "geograticule": "bin/geograticule.js", + "geoproject": "bin/geoproject.js", + "geoquantize": "bin/geoquantize.js", + "geostitch": "bin/geostitch.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-regression": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/d3-regression/-/d3-regression-1.3.10.tgz", + "integrity": "sha512-PF8GWEL70cHHWpx2jUQXc68r1pyPHIA+St16muk/XRokETzlegj5LriNKg7o4LR0TySug4nHYPJNNRz/W+/Niw==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/flru": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/flru/-/flru-1.0.2.tgz", + "integrity": "sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.73.0.tgz", + "integrity": "sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.73.0", + "@oxlint/binding-android-arm64": "1.73.0", + "@oxlint/binding-darwin-arm64": "1.73.0", + "@oxlint/binding-darwin-x64": "1.73.0", + "@oxlint/binding-freebsd-x64": "1.73.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.73.0", + "@oxlint/binding-linux-arm-musleabihf": "1.73.0", + "@oxlint/binding-linux-arm64-gnu": "1.73.0", + "@oxlint/binding-linux-arm64-musl": "1.73.0", + "@oxlint/binding-linux-ppc64-gnu": "1.73.0", + "@oxlint/binding-linux-riscv64-gnu": "1.73.0", + "@oxlint/binding-linux-riscv64-musl": "1.73.0", + "@oxlint/binding-linux-s390x-gnu": "1.73.0", + "@oxlint/binding-linux-x64-gnu": "1.73.0", + "@oxlint/binding-linux-x64-musl": "1.73.0", + "@oxlint/binding-openharmony-arm64": "1.73.0", + "@oxlint/binding-win32-arm64-msvc": "1.73.0", + "@oxlint/binding-win32-ia32-msvc": "1.73.0", + "@oxlint/binding-win32-x64-msvc": "1.73.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.24.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/pdfast": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/pdfast/-/pdfast-0.2.0.tgz", + "integrity": "sha512-cq6TTu6qKSFUHwEahi68k/kqN2mfepjkGrG9Un70cgdRRKLKY6Rf8P8uvP2NvZktaQZNF3YE7agEkLj0vGK9bA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/svg-path-parser": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/svg-path-parser/-/svg-path-parser-1.1.0.tgz", + "integrity": "sha512-jGCUqcQyXpfe38R7RFfhrMyfXcBmpMNJI/B+4CE9/Unkh98UporAc461GTthv+TVDuZXsBx7/WiwJb1Oh4tt4A==", + "license": "MIT" + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2480850 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..1a8623d --- /dev/null +++ b/frontend/src/App.tsx @@ -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 + return +} + +export default function App() { + const { theme } = useTheme() + return ( + + + + } /> + }> + }> + } /> + } /> + + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + } /> + } /> + } /> + + } /> + } /> + } /> + + } /> + } /> + + } /> + } /> + + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + + } /> + } /> + + + } /> + } /> + + + + ) +} diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..2d25773 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -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 { + const { data } = await client.post>('/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 { + const { data } = await client.get>(`${apiPrefix()}/homes/my-menus`) + return unwrap(data) +} + +export async function me(): Promise { + const { data } = await client.get>('/auth/me') + return unwrap(data) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..c5f83bf --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,46 @@ +import axios from 'axios' + +export type ApiResponse = { + 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(payload: ApiResponse | T): T { + if (payload && typeof payload === 'object' && 'result' in (payload as object)) { + const r = payload as ApiResponse + 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 diff --git a/frontend/src/api/opens.ts b/frontend/src/api/opens.ts new file mode 100644 index 0000000..822dec1 --- /dev/null +++ b/frontend/src/api/opens.ts @@ -0,0 +1,31 @@ +import client, { apiPrefix, unwrap, type ApiResponse } from './client' + +export type OpenRecord = Record & { id: number } +export type OpenSearch = { total: number; list: OpenRecord[]; counts: Record } + +export const openApi = { + async search(params: Record) { + const { data } = await client.get>(`${apiPrefix()}/opens/search`, { params }) + return unwrap(data) + }, + async get(id: number) { + const { data } = await client.get>(`${apiPrefix()}/opens/${id}`) + return unwrap(data) + }, + async create(payload: Record) { + const { data } = await client.post(`${apiPrefix()}/opens`, payload) + return unwrap(data) + }, + async save(id: number, payload: Record) { + const { data } = await client.put(`${apiPrefix()}/opens/${id}`, payload) + return unwrap(data) + }, + async remove(id: number) { + const { data } = await client.delete(`${apiPrefix()}/opens/${id}`) + return unwrap(data) + }, + async serialLookup(serial: string, gubun = '휴대폰') { + const { data } = await client.get[]>>(`${apiPrefix()}/opens/serial-lookup`, { params: { serial, gubun } }) + return unwrap(data) + }, +} diff --git a/frontend/src/api/resources.ts b/frontend/src/api/resources.ts new file mode 100644 index 0000000..4370136 --- /dev/null +++ b/frontend/src/api/resources.ts @@ -0,0 +1,45 @@ +import client, { apiPrefix, unwrap, type ApiResponse } from './client' +import type { Entity } from '../types' + +export type PageResult = { total: number; list: T[] } + +export const resourceApi = { + async list(path: string, params: Record = {}) { + const { data } = await client.get | ApiResponse>>( + `${apiPrefix()}/${path}`, + { params }, + ) + const body = unwrap(data) as PageResult | Record + 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 = {}) { + const { data } = await client.get(`${apiPrefix()}/${path}`, { params }) + return unwrap(data) + }, + + async create(path: string, payload: Record) { + const { data } = await client.post>(`${apiPrefix()}/${path}`, payload) + return unwrap(data) + }, + + async update(path: string, id: number | string, payload: Record) { + const { data } = await client.put>(`${apiPrefix()}/${path}/${id}`, payload) + return unwrap(data) + }, + + async put(path: string, payload: Record, params?: Record) { + const { data } = await client.put(`${apiPrefix()}/${path}`, payload, { params }) + return unwrap(data) + }, + + async remove(path: string, id: number) { + const { data } = await client.delete(`${apiPrefix()}/${path}/${id}`) + return unwrap(data) + }, +} diff --git a/frontend/src/api/stocks.ts b/frontend/src/api/stocks.ts new file mode 100644 index 0000000..07c7067 --- /dev/null +++ b/frontend/src/api/stocks.ts @@ -0,0 +1,77 @@ +import client, { apiPrefix, unwrap, type ApiResponse } from './client' + +export type Stock = Record & { id: number; state: string; statusLabel: string } +export type StockSearch = { total: number; list: Stock[]; counts: Record } + +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) { + const { data } = await client.get>(`${apiPrefix()}/stocks/search`, { params }) + return unwrap(data) + }, + async detail(id: number) { + const { data } = await client.get>(`${apiPrefix()}/stocks/${id}/detail`) + return unwrap(data) + }, + async save(id: number, payload: Record) { + const { data } = await client.put>(`${apiPrefix()}/stocks/${id}`, payload) + return unwrap(data) + }, + async create(payload: Record) { + const { data } = await client.post>(`${apiPrefix()}/stocks`, payload) + return unwrap(data) + }, + async remove(id: number) { + const { data } = await client.delete(`${apiPrefix()}/stocks/${id}`) + return unwrap(data) + }, + async action(id: number, payload: Record) { + const { data } = await client.post>(`${apiPrefix()}/stocks/${id}/action`, payload) + return unwrap(data) + }, + async addNote(id: number, payload: { contents: string; authorName?: string }) { + const { data } = await client.post>(`${apiPrefix()}/stocks/${id}/notes`, payload) + return unwrap(data) + }, + async deleteNote(id: number, noteId: number) { + const { data } = await client.delete(`${apiPrefix()}/stocks/${id}/notes/${noteId}`) + return unwrap(data) + }, + async bulkIn(payload: Record) { + const { data } = await client.post(`${apiPrefix()}/stocks/bulk-in`, payload) + return unwrap(data) + }, + async bulkAction(ids: number[], state: string) { + const { data } = await client.post(`${apiPrefix()}/stocks/bulk-action`, { ids, state }) + return unwrap(data) + }, + async sheet(params: Record) { + const { data } = await client.get>(`${apiPrefix()}/stocks/sheet`, { params }) + return unwrap(data) + }, + exportUrl(params: Record) { + const query = new URLSearchParams(Object.entries(params).filter(([, value]) => value !== undefined && value !== '').map(([key, value]) => [key, String(value)])) + return `${apiPrefix()}/stocks/export?${query}` + }, +} diff --git a/frontend/src/assets/billcare_mark.png b/frontend/src/assets/billcare_mark.png new file mode 100644 index 0000000..391e8d2 Binary files /dev/null and b/frontend/src/assets/billcare_mark.png differ diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/logo_image.png b/frontend/src/assets/logo_image.png new file mode 100644 index 0000000..f37a17d Binary files /dev/null and b/frontend/src/assets/logo_image.png differ diff --git a/frontend/src/assets/logo_image_theme.png b/frontend/src/assets/logo_image_theme.png new file mode 100644 index 0000000..e8195e1 Binary files /dev/null and b/frontend/src/assets/logo_image_theme.png differ diff --git a/frontend/src/assets/logo_login_dark.png b/frontend/src/assets/logo_login_dark.png new file mode 100644 index 0000000..7a79f1e Binary files /dev/null and b/frontend/src/assets/logo_login_dark.png differ diff --git a/frontend/src/assets/logo_login_light.png b/frontend/src/assets/logo_login_light.png new file mode 100644 index 0000000..e8195e1 Binary files /dev/null and b/frontend/src/assets/logo_login_light.png differ diff --git a/frontend/src/assets/logo_mark_gold.png b/frontend/src/assets/logo_mark_gold.png new file mode 100644 index 0000000..a6c6671 Binary files /dev/null and b/frontend/src/assets/logo_mark_gold.png differ diff --git a/frontend/src/assets/logo_mark_light.png b/frontend/src/assets/logo_mark_light.png new file mode 100644 index 0000000..e8195e1 Binary files /dev/null and b/frontend/src/assets/logo_mark_light.png differ diff --git a/frontend/src/assets/logo_word_theme.png b/frontend/src/assets/logo_word_theme.png new file mode 100644 index 0000000..0657ab0 Binary files /dev/null and b/frontend/src/assets/logo_word_theme.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/AccountPopover.tsx b/frontend/src/components/AccountPopover.tsx new file mode 100644 index 0000000..4367600 --- /dev/null +++ b/frontend/src/components/AccountPopover.tsx @@ -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(null) + const [pwOpen, setPwOpen] = useState(false) + const [pwLoading, setPwLoading] = useState(false) + const [form] = Form.useForm() + + useEffect(() => { + if (!open) return + let cancelled = false + client + .get>(`${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(`${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 = ( +
+
+
{p?.name || '…'}
+
+ {p ? `${p.orgLabel} / ${p.staffPermission}` : '…'} +
+
+
+
+ 계정 + {p?.userid || '…'} +
+
+ 직원 + {p ? `${p.memberCount}명` : '…'} +
+
+
+
이용만료일
+
+ {p ? formatExpireKo(p.expireDate) : '…'} +
+
{p ? remainText : ''}
+
+
+ + +
+
+ ) + + return ( + <> + + + + + setPwOpen(false)} + onOk={() => void submitPassword()} + okText="변경" + cancelText="취소" + confirmLoading={pwLoading} + destroyOnClose + width={400} + > +
+ + + + + + + ({ + validator(_, value) { + if (!value || getFieldValue('newPassword') === value) return Promise.resolve() + return Promise.reject(new Error('새 비밀번호 확인이 일치하지 않습니다.')) + }, + }), + ]} + > + + +
+
+ + ) +} diff --git a/frontend/src/components/AgentShell.tsx b/frontend/src/components/AgentShell.tsx new file mode 100644 index 0000000..a4e52d5 --- /dev/null +++ b/frontend/src/components/AgentShell.tsx @@ -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(() => { + 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>({}) + + 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 ( +
+
+
+ {!collapsed && ( +
+ +
+ )} +
+
+
+ {shop ? ( + 에이전트 데모 / {userName} + ) : ( +
+ [ BillCare ] + + + 문자발송(장바구니) + 잔여SMS 996 +
+ )} +
+
+ {!shop && ( + <> + + + + + )} + +
+
+
+ +
+ {!collapsed && ( + + )} + + {!collapsed && ( +
+ )} + + + +
+
+ +
+
+
+ + {!shop && setScheduleOpen(false)} />} +
+ ) +} diff --git a/frontend/src/components/BillCareLogo.tsx b/frontend/src/components/BillCareLogo.tsx new file mode 100644 index 0000000..12f8ffb --- /dev/null +++ b/frontend/src/components/BillCareLogo.tsx @@ -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 ( +
+ + {title} +
+ ) + } + + // 라이트 헤더: 가로 로고 + if (!dark) { + return ( + {title} + ) + } + + // 다크/블루 헤더: 마크 + BillCare + return ( +
+ + {title} +
+ ) +} diff --git a/frontend/src/components/CareTable.tsx b/frontend/src/components/CareTable.tsx new file mode 100644 index 0000000..0952da9 --- /dev/null +++ b/frontend/src/components/CareTable.tsx @@ -0,0 +1,8 @@ +import { Table, type TableProps } from 'antd' +import { normalizeTableColumns } from '../tableAlign' + +/** Ant Design Table 래퍼 — 목록 정렬 규칙(헤더/텍스트 중앙, 숫자·금액 우측) 적용 */ +export default function CareTable(props: TableProps) { + const { columns, ...rest } = props + return {...rest} columns={normalizeTableColumns(columns)} /> +} diff --git a/frontend/src/components/DraggableModal.tsx b/frontend/src/components/DraggableModal.tsx new file mode 100644 index 0000000..746a42b --- /dev/null +++ b/frontend/src/components/DraggableModal.tsx @@ -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(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) => void + onMouseDown: (e: ReactMouseEvent) => void +}) { + return ( +
+
{title}
+
+ {titleActions} + {showClose && ( + + )} +
+
+ ) +} + +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 ( + onCancel?.(e)} + onMouseDown={onTitleMouseDown} + /> + )} + modalRender={(node) => { + const wrapped = ( +
e.stopPropagation()} + > + {node} +
+ ) + 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 ( +
e.stopPropagation()} + > +
+ +
{children}
+
+
+ ) +} + +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 ( + + {children} + + ) +} + +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 = {node} + 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 } diff --git a/frontend/src/components/OrderFormLayout.tsx b/frontend/src/components/OrderFormLayout.tsx new file mode 100644 index 0000000..917f78e --- /dev/null +++ b/frontend/src/components/OrderFormLayout.tsx @@ -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 ( +
+
+
+

{title}

+ {desc ?

{desc}

: null} +
+ {actions ?
{actions}
: null} +
+
{children}
+
+ ) +} + +export function FormGroup({ label, children }: { label?: string; children: ReactNode }) { + return ( +
+ {label ?
{label}
: null} + {children} +
+ ) +} + +export function FormGrid({ children, cols = 2 }: { children: ReactNode; cols?: 1 | 2 }) { + return
{children}
+} + +export function FormField({ + label, + hint, + full, + children, +}: { + label: string + hint?: string + full?: boolean + children: ReactNode +}) { + return ( +
+
+ {label} + {hint ? {hint} : null} +
+
{children}
+
+ ) +} + +export function FormChoiceRow({ children }: { children: ReactNode }) { + return
{children}
+} + +export function FormChoice({ children }: { children: ReactNode }) { + return +} + +export function FormBlock({ + title, + actions, + children, +}: { + title?: string + actions?: ReactNode + children: ReactNode +}) { + return ( +
+ {(title || actions) && ( +
+ {title ? {title} : null} + {actions ?
{actions}
: null} +
+ )} + {children} +
+ ) +} diff --git a/frontend/src/components/PrgSection.tsx b/frontend/src/components/PrgSection.tsx new file mode 100644 index 0000000..8d5a654 --- /dev/null +++ b/frontend/src/components/PrgSection.tsx @@ -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 ( +
+ {(title || extra) && ( +
+

{title}

+ {extra ?
{extra}
: null} +
+ )} +
{children}
+
+ ) +} diff --git a/frontend/src/components/ReceptionApplyModal.tsx b/frontend/src/components/ReceptionApplyModal.tsx new file mode 100644 index 0000000..3a21700 --- /dev/null +++ b/frontend/src/components/ReceptionApplyModal.tsx @@ -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>({}) + 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>(`${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>(`${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) => { + 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>(`${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') => ( +
+
+ + + + +
+ + + + + + +
+ ) + + return ( + +
+ + + + + + + +

등록된 인증 링크가 없습니다.

+
+
+
+ + + + + + + 개인 + 사업자 + 외국인 + + + + + + {customerType === 'company' ? ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + {addrStack('bl')} + + + + + + + + + + + + {addrStack('')} + + ) : ( + + + + + + + + + + + + + + + + + + +
+ + + + )} +
+
+ + + + + + + + + + + + {addrStack('')} +
+ )} +
+ + + + + + + 자동이체 + 신용카드 + 지로청구 + 전화번호요금통합 + + + + + + {(paymentMethod === 'transfer' || paymentMethod === 'card') && ( + + +
+ + + + + 고객정보와 동일 + +
+
+ + + + + + + {paymentMethod === 'transfer' ? ( + <> + + + + + + ) : ( + <> + +
+ + ({ value: y, label: y }))} style={{ width: 72 }} /> + + + + + + + )} + + )} + + + + + + + + ({ value: p.id, label: p.name }))} + placeholder="선택" + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 본사요청 + 자체발송 + + + + +
+ + + + +
+ + + + + 고객정보와 동일 + +
+
+ + + + + + + + + +
+ + +
+ + + +
+ {[1, 2, 3, 4, 5].map((slot) => ( + +
+ { + const f = e.target.files?.[0] || null + setCreateFiles((prev) => ({ ...prev, [`file${slot}`]: f })) + }} + /> + {createFiles[`file${slot}`] ? ( + {createFiles[`file${slot}`]?.name} + ) : null} +
+
+ ))} + {isAdmin + ? [1, 2, 3].map((slot) => ( + +
+ { + const f = e.target.files?.[0] || null + setCreateFiles((prev) => ({ ...prev, [`adminFile${slot}`]: f })) + }} + /> + {createFiles[`adminFile${slot}`] ? ( + {createFiles[`adminFile${slot}`]?.name} + ) : null} +
+
+ )) + : null} +
+
+ +
+ + +
+ + + ) +} diff --git a/frontend/src/components/ReceptionBulkModal.tsx b/frontend/src/components/ReceptionBulkModal.tsx new file mode 100644 index 0000000..1d86a85 --- /dev/null +++ b/frontend/src/components/ReceptionBulkModal.tsx @@ -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() + const [pgNotNotice, setPgNotNotice] = useState(false) + const [pgCenterId, setPgCenterId] = useState() + const [pgMemo, setPgMemo] = useState('') + const [pgOpended, setPgOpended] = useState('') + const [pgCanceled, setPgCanceled] = useState('') + + const [gPlace, setGPlace] = useState() + const [gStatus, setGStatus] = useState() + 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) => { + 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>(`${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>(`${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>(`${apiPrefix()}/homes/receptions/bulk-center-progress`, { + ids: selectedIds, + }) + const res = unwrap(data) as { count: number } + message.success(`${res.count}건을 입력처 정산대기로 변경했습니다.`) + }) + } + + return ( + +
+ + + +
+ setPgAdminOnly(e.target.checked)}> + 관리자 전용로그 + + ■ 관리자만 보이고 영업점은 보이지 않습니다. +
+
+ +
+ setPgCenterId(v)} + options={options(centerOptions.length ? centerOptions : ['본사'])} + /> + + + setPgMemo(e.target.value)} /> + + + setPgOpended(d ? d.format('YYYY-MM-DD') : '')} + style={{ width: '100%' }} + /> + + + setPgCanceled(d ? d.format('YYYY-MM-DD') : '')} + style={{ width: '100%' }} + /> + + +
+ +
+ + + + + + setGStatus(v)} + options={options(giftStatuses)} + /> + + + setGProgressLog(e.target.value)} /> + + + setGDeliveryCompany(e.target.value)} /> + + + setGTrackingNumber(e.target.value)} /> + + + setGRequested(d ? d.format('YYYY-MM-DD') : '')} + style={{ width: '100%' }} + /> + + + setGSended(d ? d.format('YYYY-MM-DD') : '')} + style={{ width: '100%' }} + /> + + +
+ +
+
+ + + + +

+ ■ 개통완료된 고객중에 입력처 정산이 진행안된 고객은 입력처 정산대기로 일괄처리 진행할수 있습니다. +

+
+
+
+ +
+
+
+ +
+ +
+ + ) +} diff --git a/frontend/src/components/ReceptionProgressModal.tsx b/frontend/src/components/ReceptionProgressModal.tsx new file mode 100644 index 0000000..728e7a0 --- /dev/null +++ b/frontend/src/components/ReceptionProgressModal.tsx @@ -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(EMPTY) + const [recreateIsp, setRecreateIsp] = useState() + + const detailQuery = useQuery({ + queryKey: ['reception-progress', receptionId], + queryFn: async () => { + const { data } = await client.get>>( + `${apiPrefix()}/homes/receptions/${receptionId}`, + ) + return unwrap(data) as Record + }, + enabled: open && receptionId != null, + }) + + const ispQuery = useQuery({ + queryKey: ['reception-isps'], + queryFn: async () => { + const { data } = await client.get>( + `${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) => setF((p) => ({ ...p, ...patch })) + + const saveMut = useMutation({ + mutationFn: async (closeAfter: boolean) => { + const payload: Record = { + 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 ( + + {detailQuery.isLoading || !data ? ( +
로딩 중...
+ ) : ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
접수자 +
+ + + (관리자만 보이고 영업점은 보이지 않습니다.) +
+
현재진행상황 + +
포스트잇 + set({ postIt: e.target.value })} /> +
추가메모 + set({ postAdd: e.target.value })} /> +
진행상황 +
+
계약번호 + set({ contractNumber: e.target.value })} /> + 설치일정 + set({ installSchedule: e.target.value })} /> +
고객아이디 + set({ customerLoginId: e.target.value })} /> + 신규전화번호 + set({ newTel: e.target.value })} /> +
접수유형 +
+ + + +
+
업무진행메모(LOG) + set({ progressLog: e.target.value })} /> +
관리자메모 + set({ adminMemo: e.target.value })} /> +
접수일자 +
+ set({ orderDate: d ? d.format('YYYY-MM-DD') : '' })} + /> + +
+
개통일자 + set({ openDate: d ? d.format('YYYY-MM-DD') : '' })} + /> + 해지일자 + set({ cancelDate: d ? d.format('YYYY-MM-DD') : '' })} + /> +
+
+ + + + + + + + + + + + + + {progressLogs.map((l) => ( + + + + + + + + ))} + {progressLogs.length === 0 && ( + + )} + +
날짜진행상태담당자내용삭제
{dt16(l.loggedAt || l.createdAt)}{l.status}{l.authorName || l.writer}{l.adminOnly ? '[관리자] ' : ''}{l.memo} + +
진행 내역이 없습니다.
+
+ + + + + + + + + + + + + + + + + + + + + +
사은품현재상황 + + 사은품진행상황 +
사은품 지급처 + 지급요청/완료 + + set({ giftRequested: d ? d.format('YYYY-MM-DD') : '' })} + /> + set({ giftSended: d ? d.format('YYYY-MM-DD') : '' })} + /> + +
사은품진행메모 + set({ giftProgressLog: e.target.value })} /> +
+
+ + + + + + + + + + + + + + {giftLogs.map((l) => ( + + + + + + + + ))} + {giftLogs.length === 0 && ( + + )} + +
날짜사은품진행상태담당자내용삭제
{dt16(l.loggedAt || l.createdAt)}{l.status}{l.authorName || l.writer}{l.memo} + +
사은품 진행 내역이 없습니다.
+
+ +
+
+ + + + +
+
+ 개인 + + +
+ + + + + + + + + {(customer.postAdd || reception.postAdd) && ( + + + + + )} + + + + + + + + + + + + + + + + + + + + + {(customer.sido || reception.region) && ( + + + + + )} + +
고객명{customer.name || reception.customerName || '-'}포스트잇{customer.postIt || reception.postIt || ''}
추가메모{customer.postAdd || reception.postAdd}
식별번호{customer.ssid || reception.identifyNumber || ''}고객유형{ctype === 'company' ? '사업자' : ctype === 'foreigner' ? '외국인' : '개인'}
휴대전화{customer.phone || reception.phone || '-'}유선전화{customer.tel || reception.tel || ''}
이메일{customer.email || reception.email || ''}
주소 + {[customer.zipcode || reception.zipcode ? `[${customer.zipcode || reception.zipcode}]` : '', + customer.address || reception.address, + customer.addressDetail || reception.addressDetail].filter(Boolean).join(' ') || '-'} +
지역{customer.sido || reception.region}
+ + + + + + + + + + {(payment.name || payment.bank || payment.cardCompany) && ( + <> + + + + + + + {pmChecked(paymentMethod, 'card') ? ( + <> + + + + + + + + + + + + ) : pmChecked(paymentMethod, 'transfer') ? ( + + + + + + + ) : null} + + )} + +
결제방법 + + + + +
납입자명{payment.name || ''}주민번호{payment.ssid || ''}
카드사{payment.cardCompany || ''}유효기간{[payment.cardYear, payment.cardMonth].filter(Boolean).join('/')}
카드번호{payment.cardNumber || ''}
은행{payment.bank || ''}계좌번호{payment.accountNumber || ''}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
통신사{data.ispName || reception.ispName || reception.carrier || '-'}
상품종류 + + + +
{productType === 'e02' ? '전화' : productType === 'e03' ? 'TV' : '인터넷'} + + + + + + + + + + + + + + + + + + + +
상품{data.productName || product.productName || product.name || reception.productName || '-'}
약정{data.agreementName || reception.agreementName || '-'}
이벤트{(data.eventNames || []).join(', ') || '-'}
월사용요금{amount.toLocaleString()} 원
+
총 요금{amount.toLocaleString()} 원
할인혜택-
설치희망일자{reception.openDate || '-'}연락처{reception.progressStatus || '-'}
+
+ + {(gift.status || gift.category || reception.giftStatus) && ( + + + + + + + + + + + + + + + + +
사은품분류{gift.category || reception.giftCategory || '-'}진행상황{gift.status || reception.giftStatus || '-'}
지급처{gift.place || reception.giftPlace || '-'}지급일{gift.giftDate || reception.giftDate || '-'}
+
+ )} + + {(data.etc?.memo || reception.memo) && ( + + + + + + + + +
메모{data.etc?.memo || reception.memo}
+
+ )} + + ) +} diff --git a/frontend/src/components/SchedulePanel.tsx b/frontend/src/components/SchedulePanel.tsx new file mode 100644 index 0000000..5143039 --- /dev/null +++ b/frontend/src/components/SchedulePanel.tsx @@ -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 +} + +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>(`${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>( + `${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 ( + <> +
+ + + setCreateOpen(false)} + footer={null} + destroyOnClose + > +
createMut.mutate(v)}> + + + + + + +
+ + +
+
+
+ + ) +} diff --git a/frontend/src/components/SubjectSettingsModal.tsx b/frontend/src/components/SubjectSettingsModal.tsx new file mode 100644 index 0000000..0b2fbc9 --- /dev/null +++ b/frontend/src/components/SubjectSettingsModal.tsx @@ -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([]) + 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 ( + +
+ + setInput(e.target.value)} + onPressEnter={(e) => { + e.preventDefault() + addItem() + }} + /> + + + +
    + {list.map((item, index) => ( +
  • + {item} + +
  • + ))} + {!list.length ? ( +
  • 등록된 과목이 없습니다.
  • + ) : null} +
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/formRequiredMark.tsx b/frontend/src/formRequiredMark.tsx new file mode 100644 index 0000000..8287173 --- /dev/null +++ b/frontend/src/formRequiredMark.tsx @@ -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} + 필수 + + ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..44c8650 --- /dev/null +++ b/frontend/src/index.css @@ -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; } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..ffb9cd9 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + + + + + + + , +) diff --git a/frontend/src/menu.ts b/frontend/src/menu.ts new file mode 100644 index 0000000..7b8c754 --- /dev/null +++ b/frontend/src/menu.ts @@ -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 = { + 대시보드: '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}/`)) +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..8e37b16 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -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 ( +
+
+
+ +

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

+
+
+ + setUserid(e.target.value)} + autoComplete="username" + autoFocus + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + /> +
+ {error &&
{error}
} + +
데모: demo / demo123
+
+
+ ) +} diff --git a/frontend/src/pages/care/AbooksLogPage.tsx b/frontend/src/pages/care/AbooksLogPage.tsx new file mode 100644 index 0000000..e506e2a --- /dev/null +++ b/frontend/src/pages/care/AbooksLogPage.tsx @@ -0,0 +1,20 @@ +import { BookOutlined } from '@ant-design/icons' +import { HomesLogListPage } from './HomesLogListPage' + +const ACTIONS = [ + '간편장부-등록', + '간편장부-수정', + '간편장부-삭제', +] + +export default function AbooksLogPage() { + return ( + } + /> + ) +} diff --git a/frontend/src/pages/care/AbooksPage.tsx b/frontend/src/pages/care/AbooksPage.tsx new file mode 100644 index 0000000..d93e771 --- /dev/null +++ b/frontend/src/pages/care/AbooksPage.tsx @@ -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 + categoryCounts?: Record + 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('ALL') + const [categoryTab, setCategoryTab] = useState('ALL') + const [detailFilters, setDetailFilters] = useState>({ + page: 0, + size: 10, + tab: 'ALL' }) + const [selected, setSelected] = useState([]) + const [editing, setEditing] = useState(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>(`${apiPrefix()}/homes/abooks`, { params }) + return unwrap(data) + } }) + + const members = useQuery({ + queryKey: ['homes/members-options'], + queryFn: async () => { + const { data } = await client.get>(`${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) => { + const payload: Record = { + 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) => { + 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) => {v != null ? money(v) : '-'} }, + { + title: '출금(-)', + dataIndex: 'withdraw', + width: 100, + align: 'right' as const, + render: (v: number | null) => {v != null ? money(v) : '-'} }, + { + title: '카드입금(+)', + dataIndex: 'cardDeposit', + width: 110, + align: 'right' as const, + render: (v: number | null) => {v != null ? money(v) : '-'} }, + { + title: '카드출금(-)', + dataIndex: 'cardWithdraw', + width: 110, + align: 'right' as const, + render: (v: number | null) => {v != null ? money(v) : '-'} }, + { 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) => ( + + +
+ + {view === 'detail' && ( + + )} + + +
+ + {view === 'month' ? ( + +
+
+
+ + {Array.from({ length: daysInMonth }, (_, i) => i + 1).map((d) => { + const isToday = month.isSame(today, 'month') && d === today.date() + return ( + + ) + })} +
+
+ + +
+
{summaryText}
+ +
+ ) : ( + <> + +
+
+ + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + ({ value: t, label: t }))} /> + + + + + + + + + + + + +
+ + +
+ + + + setCatOpen(false)} + onSave={(cats) => saveCategories.mutate(cats)} + /> +
+ ) +} diff --git a/frontend/src/pages/care/BbsPage.tsx b/frontend/src/pages/care/BbsPage.tsx new file mode 100644 index 0000000..e7e2f95 --- /dev/null +++ b/frontend/src/pages/care/BbsPage.tsx @@ -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 + +export default function BbsPage() { + const qc = useQueryClient() + const [form] = Form.useForm() + const [editForm] = Form.useForm() + const [filters, setFilters] = useState>(initial) + const [open, setOpen] = useState(false) + const [detail, setDetail] = useState(null) + + const query = useQuery({ + queryKey: ['homes-bbs', filters], + queryFn: async () => { + const { data } = await client.get>(`${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) => { + 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>(`${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) => { + 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) => ( + + ) }, + { + 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 ( +
+
+
+

+ + 사내게시판 +

+

등록된 직원끼리 이용하실 수 있는 사내전용 게시판입니다.

+
+ +
+ + +
+
+ + + + + + + + + + + + + + + +
+ + +
+ + + + setDetail(null)} + footer={} + width={640} + destroyOnHidden + centered + > + {detail ? ( +
+ + {detail.title} + +
+ 직원 {detail.authorName || '-'} + 조회 {detail.views ?? 0} + 등록일 {detail.createdDate || '-'} +
+
+ {(detail.contents || '') + .split('\n') + .map((line, idx) => ( +

{line || '\u00A0'}

+ ))} +
+
+ ) : null} +
+
+ ) +} diff --git a/frontend/src/pages/care/BookingLogPage.tsx b/frontend/src/pages/care/BookingLogPage.tsx new file mode 100644 index 0000000..87f4916 --- /dev/null +++ b/frontend/src/pages/care/BookingLogPage.tsx @@ -0,0 +1,31 @@ +import { HomesLogListPage } from './HomesLogListPage' + +const ACTIONS = [ + '예약-등록', + '예약-수정', + '예약-삭제', + '예약-상태', +] + +/** 원본 예약관리 타이틀 잎 아이콘 */ +function LeafOutlined({ className }: { className?: string }) { + return ( + + + + + + ) +} + +export default function BookingLogPage() { + return ( + } + /> + ) +} diff --git a/frontend/src/pages/care/BookingPage.tsx b/frontend/src/pages/care/BookingPage.tsx new file mode 100644 index 0000000..ac44df4 --- /dev/null +++ b/frontend/src/pages/care/BookingPage.tsx @@ -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 ( + + + + + + ) +} + +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 + employees?: string[] + internetOptions?: string[] + rentalOptions?: string[] +} + +const initial = { page: 0, size: 10, tab: 'ALL' } as Record + +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>(initial) + const [selected, setSelected] = useState([]) + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + + const query = useQuery({ + queryKey: ['homes-bookings', filters], + queryFn: async () => { + const { data } = await client.get>(`${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>(`${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) => { + const payload: Record = { + 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) => { + 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: ( + 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) => ( + 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) => {v || '-'} }, + { + 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) => ( + + + +
+ + +
+
+ + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + { + 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} + > + + +
* 다량의 파일은 압축해서 올려주십시요. (최대 20M)
+ prev.fileName !== cur.fileName} noStyle> + {() => { + const name = editForm.getFieldValue('fileName') + return name ?
{name}
: null + }} +
+
+
+
* 고객명, 연락처는 고객관리에도 동시등록됩니다.
+ + + + +
+ + +
+ ) +} diff --git a/frontend/src/pages/care/CarePlaceholderPage.tsx b/frontend/src/pages/care/CarePlaceholderPage.tsx new file mode 100644 index 0000000..fff08e9 --- /dev/null +++ b/frontend/src/pages/care/CarePlaceholderPage.tsx @@ -0,0 +1,17 @@ +import { Card, Typography } from 'antd' + +type Props = { + title: string + description?: string +} + +export default function CarePlaceholderPage({ + title, + description = '원본 비즈케어홈즈 메뉴와 동일한 경로로 연결해 두었습니다. 상세 화면은 순차 구현 예정입니다.' }: Props) { + return ( + + {title} + {description} + + ) +} diff --git a/frontend/src/pages/care/ContractAdjustPage.tsx b/frontend/src/pages/care/ContractAdjustPage.tsx new file mode 100644 index 0000000..0b20ca1 --- /dev/null +++ b/frontend/src/pages/care/ContractAdjustPage.tsx @@ -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>({}) + const [rows, setRows] = useState([]) + 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>(`${apiPrefix()}/homes/contracts/adjust`, { params: optionParams }) + return unwrap(data) + } }) + const query = useQuery({ + queryKey: ['homes-contract-adjust', filters], + queryFn: async () => { + const { data } = await client.get>(`${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) => { + 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) => ( + updateRow(row.id, 'policySum', v)} + /> + ) }, + { + title: '정산금액', + dataIndex: 'settleAmount', + width: 120, + render: (_: unknown, row: AdjustRow) => ( + updateRow(row.id, 'settleAmount', v)} + /> + ) }, + { + title: '판매마진', + dataIndex: 'margin', + width: 120, + render: (_: unknown, row: AdjustRow) => ( + updateRow(row.id, 'margin', v)} + /> + ) }, + { + title: '개별', + width: 70, + fixed: 'right' as const, + render: (_: unknown, row: AdjustRow) => ( + + ) }, + ] + + return ( +
+
+
+

+ + 일괄정책변경 +

+

등록된 계약정보에 일괄적으로 기본정책금액을 변경하실 수 있습니다. (단일상품의 수량이 2개 이상인 경우 제외됩니다.)

+
+
+ + +
+
+ 계약일 + + + + + + + + + + + * 최대 31일 조회 + +
+ +
+ 검색조건 + + + + + + + + + +
+
+
+ + {enabled && ( + +
+ 검색된 계약 {query.data?.total ?? rows.length} +
+ +
+ 원하시는 금액을 입력 후 엔터키를 눌러주세요. + + + setBatch((s) => ({ ...s, policySum: v ?? undefined }))} + onPressEnter={() => applyBatch('policySum')} + /> + + + + setBatch((s) => ({ ...s, settleAmount: v ?? undefined }))} + onPressEnter={() => applyBatch('settleAmount')} + /> + + + + setBatch((s) => ({ ...s, margin: v ?? undefined }))} + onPressEnter={() => applyBatch('margin')} + /> + + + +
+ + ( + + 합계 + + {money(rows.reduce((s, r) => s + Number(r.policySum || 0), 0))} + + + {money(rows.reduce((s, r) => s + Number(r.settleAmount || 0), 0))} + + + {money(rows.reduce((s, r) => s + Number(r.margin || 0), 0))} + + + + )} + /> + +
+ +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/care/ContractBalancePage.tsx b/frontend/src/pages/care/ContractBalancePage.tsx new file mode 100644 index 0000000..1193f23 --- /dev/null +++ b/frontend/src/pages/care/ContractBalancePage.tsx @@ -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 + employees?: string[] + agencies?: string[] + reasons?: string[] +} + +const isDeduct = (gubun?: string) => Boolean(gubun && gubun.includes('차감')) + +const initial = { + page: 0, + size: 10, + tab: 'ALL' } as Record + +export default function ContractBalancePage() { + const [form] = Form.useForm() + const [editForm] = Form.useForm() + const qc = useQueryClient() + const [filters, setFilters] = useState>(initial) + const [selected, setSelected] = useState([]) + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [amountSign, setAmountSign] = useState<'+' | '-'>('-') + + const query = useQuery({ + queryKey: ['homes-balances', filters], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/homes/balances`, { params: filters }) + return unwrap(data) + } }) + + const members = useQuery({ + queryKey: ['homes/members-options'], + queryFn: async () => { + const { data } = await client.get>(`${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>(`${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) => { + const raw = Number(values.amountAbs ?? 0) + const signed = amountSign === '-' ? -Math.abs(raw) : Math.abs(raw) + const payload: Record = { + 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) => { + 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: ( + 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) => ( + 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) => {v} }, + { 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 {n.toLocaleString()} + } }, + { + title: '비고', + dataIndex: 'memo', + width: 54, + align: 'center' as const, + render: (v: string) => (v ? : '-') }, + { + title: '관리', + width: 58, + fixed: 'right' as const, + render: (_: unknown, row: BalanceRow) => ( + + + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + setAmountSign(String(e.target.value).includes('차감') ? '-' : '+')} + /> + + + + + + + + + + + + + + + + +
+ + +
+ + +
+ ) +} diff --git a/frontend/src/pages/care/ContractHomePage.tsx b/frontend/src/pages/care/ContractHomePage.tsx new file mode 100644 index 0000000..0a51190 --- /dev/null +++ b/frontend/src/pages/care/ContractHomePage.tsx @@ -0,0 +1,13 @@ +import { ContractListPage } from './ContractListShared' + +export default function ContractHomePage() { + return ( + + ) +} diff --git a/frontend/src/pages/care/ContractInternetPage.tsx b/frontend/src/pages/care/ContractInternetPage.tsx new file mode 100644 index 0000000..31d8d73 --- /dev/null +++ b/frontend/src/pages/care/ContractInternetPage.tsx @@ -0,0 +1,13 @@ +import { ContractListPage } from './ContractListShared' + +export default function ContractInternetPage() { + return ( + + ) +} diff --git a/frontend/src/pages/care/ContractListPage.tsx b/frontend/src/pages/care/ContractListPage.tsx new file mode 100644 index 0000000..8f9bda7 --- /dev/null +++ b/frontend/src/pages/care/ContractListPage.tsx @@ -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 & { id: number } +type SearchResult = { total: number; list: Contract[] } + +const initial = { page: 0, size: 15 } as Record + +export default function ContractListPage() { + const [form] = Form.useForm() + const navigate = useNavigate() + const [filters, setFilters] = useState>(initial) + + const query = useQuery({ + queryKey: ['contracts-search', filters], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/contracts/search`, { params: filters }) + return unwrap(data) + } }) + + const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] }) + + const onSearch = (values: Record) => { + 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) => ( + +
+ + +
+
+ + + + + + + + + +
+
+ + + + + + + + + + ({ value: v, label: v }))} /> + + + + + + + + ({ value: v, label: v }))} /> + +
+ + +
+ + + + setSumOpen(false)} footer={null} destroyOnHidden width={400}> + + {selected.length ? `선택 ${selectedSums.count}건` : `현재 목록 ${selectedSums.count}건`} 기준입니다. + +
+
정산금액 합계{selectedSums.settle.toLocaleString()}원
+
+ 판매마진 합계 + + {selectedSums.margin.toLocaleString()}원 + +
+
+
+ +
+
+
+ ) +} diff --git a/frontend/src/pages/care/ContractLogPage.tsx b/frontend/src/pages/care/ContractLogPage.tsx new file mode 100644 index 0000000..ada92e3 --- /dev/null +++ b/frontend/src/pages/care/ContractLogPage.tsx @@ -0,0 +1,23 @@ +import { HomesLogListPage } from './HomesLogListPage' + +const ACTIONS = [ + '인터넷접수-등록', + '인터넷접수-수정', + '인터넷접수-삭제', + '인터넷접수-상태', + '홈렌탈접수-등록', + '홈렌탈접수-수정', + '홈렌탈접수-삭제', + '홈렌탈접수-상태', +] + +export default function ContractLogPage() { + return ( + + ) +} diff --git a/frontend/src/pages/care/ContractWritePage.tsx b/frontend/src/pages/care/ContractWritePage.tsx new file mode 100644 index 0000000..bf05098 --- /dev/null +++ b/frontend/src/pages/care/ContractWritePage.tsx @@ -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, 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([]) + 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>>(`${apiPrefix()}/homes/contracts/${id}`) + return unwrap(data) + } }) + + const agencies = useQuery({ + queryKey: ['homes/agencies-options'], + queryFn: async () => { + const { data } = await client.get>(`${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>(`${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) => { + if (!lines.length) throw new Error('계약 상품을 추가하세요.') + const birthParts = [values.birthYear, values.birthMonth, values.birthDay].filter(Boolean) + const payload: Record = { + ...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 ( +
+
+
+

+ + {title} +

+
+

{guide}

+ +
+
+
+
+ +
+
save.mutate(v)} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 상기 고객연락처와 주소가 동일합니다. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ({ value: o.value, label: o.label }))} + /> + +
+ + +
+ + +
+ ) +} diff --git a/frontend/src/pages/care/CsInquiryPage.tsx b/frontend/src/pages/care/CsInquiryPage.tsx new file mode 100644 index 0000000..ad3095c --- /dev/null +++ b/frontend/src/pages/care/CsInquiryPage.tsx @@ -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(null) + const [filters, setFilters] = useState>({ + searchType: '제목', + page: 0, + size: 15, + sort: 'createdAt,desc' }) + + const query = useQuery({ + queryKey: ['cs-inquiries', filters], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/cs/search`, { params: filters }) + return unwrap(data) + } }) + + const refresh = () => qc.invalidateQueries({ queryKey: ['cs-inquiries'] }) + + const createMut = useMutation({ + mutationFn: async (values: Record) => { + 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>(`${apiPrefix()}/cs/${id}`) + return unwrap(data) + }, + onSuccess: (row) => setDetail(row), + onError: (e: Error) => message.error(e.message) }) + + const onSearch = (values: Record) => { + 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) => ( + + ) }, + { + 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 ( +
+
+
+

사용문의

+

궁금하신 사항이나 사용상 문제가 있으시면 언제든지 내용을 남겨주세요. 담당자 확인 후 답변드리겠습니다.

+
+ +
+ + +
+
+ + + + + + + + + + + + + + + +
+ + +
+ + + + setDetail(null)} + footer={} + width={720} + destroyOnClose + > +
+ 작성자 {detail?.authorName || '-'} + 등록일 {detail?.createdDate || '-'} + {detail?.answer || detail?.status || '-'} +
+
+ 문의내용 +
{detail?.content || '-'}
+
+
+ 답변 +
{detail?.reply || '답변 대기 중입니다.'}
+
+
+
+ ) +} diff --git a/frontend/src/pages/care/CsNoticePage.tsx b/frontend/src/pages/care/CsNoticePage.tsx new file mode 100644 index 0000000..a1abb6a --- /dev/null +++ b/frontend/src/pages/care/CsNoticePage.tsx @@ -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 + +export default function CsNoticePage() { + const qc = useQueryClient() + const [form] = Form.useForm() + const [filters, setFilters] = useState>(initial) + const [detail, setDetail] = useState(null) + + const query = useQuery({ + queryKey: ['homes-notices', filters], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/homes/notices`, { + params: { + ...filters, + q: filters.keyword || undefined } }) + return unwrap(data) + } }) + + const openDetail = useMutation({ + mutationFn: async (id: number) => { + const { data } = await client.get>(`${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) => { + 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) => ( + + ) }, + { + 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 ( +
+
+
+

+ + 공지사항 +

+

프로그램 사용에 대한 공지사항이나 업데이트를 알려드립니다.

+
+
+ + +
+
+ + + + + + + + + + + ({ value: v, label: v }))} + /> + + + + + + + + + + + + + + + + +
+ + +
+ + + + setDetail(null)} + footer={} + width={640} + destroyOnHidden + centered + > + {detail ? ( +
+ + {detail.title} + +
+ 작성자 {detail.authorName || '-'} + 등록일 {detail.createdDate || '-'} + {detail.answer || '미답변'} +
+
+ 문의내용 +
+ {(detail.content || '') + .split('\n') + .map((line, idx) => ( +

{line || '\u00A0'}

+ ))} +
+
+ {detail.reply ? ( +
+ 답변 +
+ {detail.reply + .split('\n') + .map((line, idx) => ( +

{line || '\u00A0'}

+ ))} +
+
+ ) : null} +
+ ) : null} +
+
+ ) +} diff --git a/frontend/src/pages/care/CustomerListPage.tsx b/frontend/src/pages/care/CustomerListPage.tsx new file mode 100644 index 0000000..43660c7 --- /dev/null +++ b/frontend/src/pages/care/CustomerListPage.tsx @@ -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 & { id: number } +type SearchResult = { total: number; list: Customer[] } + +const initial = { page: 0, size: 15 } as Record + +export default function CustomerListPage() { + const [form] = Form.useForm() + const navigate = useNavigate() + const [filters, setFilters] = useState>(initial) + + const query = useQuery({ + queryKey: ['customers-list', filters], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/customers`, { params: filters }) + return unwrap(data) + } }) + + const onSearch = (values: Record) => { + 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) => ( + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + +
+ + + +
+ + +
+ + + + setGradeOpen(false)} + footer={null} + destroyOnHidden + width={420} + > +
{ + const grades = String(values.gradesText || '') + .split(/\n|,/) + .map((s) => s.trim()) + .filter(Boolean) + saveGrades.mutate(grades) + }} + > + + + +
+ + +
+
+
+ + setChangeGradeOpen(false)} + footer={null} + destroyOnHidden + width={400} + > +
changeGradeMut.mutate({ ids: selected, grade: String(values.grade) })} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ ) +} diff --git a/frontend/src/pages/care/DeliveryPage.tsx b/frontend/src/pages/care/DeliveryPage.tsx new file mode 100644 index 0000000..79ffc85 --- /dev/null +++ b/frontend/src/pages/care/DeliveryPage.tsx @@ -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(null) + const [selected, setSelected] = useState([]) + const [filters, setFilters] = useState>({ + searchType: '업체명', + page: 0, + size: 15, + sort: 'name,asc' }) + + const query = useQuery({ + queryKey: ['deliveries', filters], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/deliveries`, { params: filters }) + return unwrap(data) + } }) + + const refresh = () => { + setSelected([]) + qc.invalidateQueries({ queryKey: ['deliveries'] }) + } + + const saveMut = useMutation({ + mutationFn: async (values: Record) => { + 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) => { + 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) => {v} }, + { 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) => ( + + + + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + ({ value: n, label: `목록 ${n}개씩 보기` }))} + onChange={(size) => { + form.setFieldValue('size', size) + setFilters((prev) => ({ ...prev, size, page: 0 })) + }} + /> + +
+
+
+ + +
+ 목록 {query.data?.total ?? 0} + +
+ { + setSelected([]) + setFilters((prev) => ({ ...prev, page: page - 1 })) + } }} + locale={{ emptyText: '등록되어 있는 자료가 없습니다.' }} + /> +
+
+ ) +} diff --git a/frontend/src/pages/care/InCompanyPage.tsx b/frontend/src/pages/care/InCompanyPage.tsx new file mode 100644 index 0000000..8e1fe54 --- /dev/null +++ b/frontend/src/pages/care/InCompanyPage.tsx @@ -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(null) + const [selected, setSelected] = useState([]) + const [filters, setFilters] = useState>({ + searchType: '업체명', + page: 0, + size: 15, + sort: 'name,asc' }) + + const query = useQuery({ + queryKey: ['incompanies', filters], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/incompanies`, { params: filters }) + return unwrap(data) + } }) + + const refresh = () => { + setSelected([]) + qc.invalidateQueries({ queryKey: ['incompanies'] }) + } + + const saveMut = useMutation({ + mutationFn: async (values: Record) => { + 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) => { + 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) => {v} }, + { 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) => ( + + + + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ({ value: v, label: v }))} /> + + + + + + + + + + + + + + + + +
+ + +
+ + + + setDetail(null)} + footer={ + + + + + } + width={720} + destroyOnClose + > +
+ 직원 {detail?.authorName || '-'} + 등록일 {detail?.createdDate || '-'} + 조회 {detail?.views ?? 0} +
+
') }} + /> + +
+ ) +} + +export function CompanyBoardPage() { + return +} + +export function DataBoardPage() { + return +} diff --git a/frontend/src/pages/care/InvoiceLogPage.tsx b/frontend/src/pages/care/InvoiceLogPage.tsx new file mode 100644 index 0000000..565e925 --- /dev/null +++ b/frontend/src/pages/care/InvoiceLogPage.tsx @@ -0,0 +1,22 @@ +import { HomesLogListPage } from './HomesLogListPage' +import { FileTextOutlined } from '@ant-design/icons' + +const ACTIONS = [ + '정산서-발행', + '정산서-재발행', + '정산서-삭제', + '정산서-등록', + '정산서-수정', +] + +export default function InvoiceLogPage() { + return ( + } + /> + ) +} diff --git a/frontend/src/pages/care/InvoiceMyPage.tsx b/frontend/src/pages/care/InvoiceMyPage.tsx new file mode 100644 index 0000000..605f2bb --- /dev/null +++ b/frontend/src/pages/care/InvoiceMyPage.tsx @@ -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>(`${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) => ( + + ) : ( + + ) + ) }, + { + 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 ( +
+
+
+

+ + 정산서발행 +

+

+ 직원 및 하부점에 정산서를 발행 및 관리하실 수 있습니다. (계약 진행상태가 완료인 자료만 정산서를 발행하실 수 있습니다.) +

+
+
+ + +
+
+ + {cursor.format('YYYY년 M월')} + +
+ +
+
+ + + `${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' : '')} + /> +
+ + * 정산서발행 시 자료의 양에 따라 처리시간이 오래 걸릴 수 있습니다. + + + * 붉은색 표기는 추가/수정으로 인해 발행정산서와 일치하지 않는 내용입니다. (필요한 경우 재발행하시면 해결됩니다.) + +
+
+
+ ) +} diff --git a/frontend/src/pages/care/KindStatReportPage.tsx b/frontend/src/pages/care/KindStatReportPage.tsx new file mode 100644 index 0000000..dfeb61f --- /dev/null +++ b/frontend/src/pages/care/KindStatReportPage.tsx @@ -0,0 +1,154 @@ +import { useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import dayjs, { type Dayjs } from 'dayjs' +import { Table, Button, Card, Empty } from 'antd' +import CareTable from '../../components/CareTable' +import { BarChartOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons' +import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client' +import { money } from './homesShared' + +type StatRow = { + key?: number + label?: string + day?: number + month?: number + count?: number + policySum?: number + settleAmount?: number + margin?: number +} + +type KindStatData = { + mode?: 'daily' | 'monthly' + year?: number + month?: number + title?: string + rows?: StatRow[] + totals?: StatRow +} + +const num = (v?: number) => Number(v ?? 0) +const cell = (v?: number) => (num(v) === 0 ? '' : money(v)) +const countCell = (v?: number) => (num(v) === 0 ? '' : String(v)) + +type Props = { + kind: 'internet' | 'home' + title: string + description: string +} + +export function KindStatReportPage({ kind, title, description }: Props) { + const [mode, setMode] = useState<'daily' | 'monthly'>('daily') + const [cursor, setCursor] = useState(dayjs()) + + const params = useMemo(() => ({ + mode, + year: cursor.year(), + month: mode === 'daily' ? cursor.month() + 1 : undefined }), [mode, cursor]) + + const query = useQuery({ + queryKey: [`homes-report-${kind}`, params], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/homes/reports/${kind}`, { + params }) + return unwrap(data) + } }) + + const rows = query.data?.rows || [] + const totals = query.data?.totals + + const columns = useMemo(() => [ + { + title: mode === 'daily' ? '일자' : '월', + dataIndex: 'label', + width: 90, + align: 'center' as const }, + { + title: '건수', + dataIndex: 'count', + width: 90, + align: 'right' as const, + render: countCell }, + { + title: '정책합산', + dataIndex: 'policySum', + width: 120, + align: 'right' as const, + render: cell }, + { + title: '정산합계', + dataIndex: 'settleAmount', + width: 120, + align: 'right' as const, + render: cell }, + { + title: '마진합계', + dataIndex: 'margin', + width: 120, + align: 'right' as const, + render: cell }, + ], [mode]) + + const shift = (dir: -1 | 1) => { + setCursor((d) => (mode === 'daily' ? d.add(dir, 'month') : d.add(dir, 'year'))) + } + + const periodLabel = mode === 'daily' + ? cursor.format('YYYY년 M월') + : cursor.format('YYYY년') + + return ( +
+
+
+

+ + {title} +

+

{description}

+
+
+ + +
+
* 철회된 계약은 제외됩니다.
+
+ + +
+
+
+ + + {rows.length ? ( + String(r.key ?? r.label)} + size="small" + dataSource={rows} + columns={columns} + pagination={false} + scroll={{ x: 600 }} + summary={() => ( + + 합계 + {countCell(totals?.count)} + {cell(totals?.policySum)} + {cell(totals?.settleAmount)} + {cell(totals?.margin)} + + )} + /> + ) : ( + + )} + +
+ ) +} diff --git a/frontend/src/pages/care/OpenAdjustPage.tsx b/frontend/src/pages/care/OpenAdjustPage.tsx new file mode 100644 index 0000000..9e4ca09 --- /dev/null +++ b/frontend/src/pages/care/OpenAdjustPage.tsx @@ -0,0 +1,356 @@ +import { useEffect, useMemo, useState } from 'react' +import { useMutation, useQuery } from '@tanstack/react-query' +import dayjs, { type Dayjs } from 'dayjs' +import { Button, Card, DatePicker, Form, Input, InputNumber, Select, Space, Typography, message } from 'antd' +import CareTable from '../../components/CareTable' +import { DownloadOutlined, RightOutlined, SearchOutlined } from '@ant-design/icons' +import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client' +import { resourceApi } from '../../api/resources' + +const { RangePicker } = DatePicker +const sortFields = ['종류', '모델명', '요금제', '개통일', '출고처', '지원방법', '고객명', '통신사'] +const defaultSorts = ['종류', '모델명', '요금제', '개통일', '출고처', '지원방법', '고객명'] +const options = (values: string[]) => values.map((value) => ({ value, label: value })) +const money = (v: unknown) => (v == null || v === '' ? '0' : Number(v).toLocaleString()) + +type AdjustRow = { + id: number + opendate?: string + incompanyName?: string + outcompanyName?: string + name?: string + openhow?: string + pmodel?: string + pserial?: string + decltype?: string + plan?: string + sellprice?: number + basicprice1?: number + basicprice2?: number + basicprice3?: number +} + +export default function OpenAdjustPage() { + const [form] = Form.useForm() + const [searched, setSearched] = useState(false) + const [filters, setFilters] = useState>({}) + const [rows, setRows] = useState([]) + const [batch, setBatch] = useState({ basicprice1: undefined as number | undefined, basicprice2: undefined as number | undefined, basicprice3: undefined as number | undefined }) + + const companies = useQuery({ queryKey: ['adjust-companies'], queryFn: () => resourceApi.list('companies', { size: 200 }) }) + const inOptions = useMemo( + () => (companies.data?.items ?? []).filter((c) => String(c.type) === 'IN').map((c) => ({ value: c.id, label: String(c.name) })), + [companies.data], + ) + const outOptions = useMemo( + () => (companies.data?.items ?? []).filter((c) => ['SHOP', 'DEALER', 'OUT'].includes(String(c.type))).map((c) => ({ value: c.id, label: String(c.name) })), + [companies.data], + ) + + const enabled = searched && !!filters.dateFrom && !!filters.dateTo + const query = useQuery({ + queryKey: ['open-adjust', filters], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/opens/adjust`, { params: filters }) + return unwrap(data) + }, + enabled }) + + useEffect(() => { + if (query.data?.list) { + setRows(query.data.list.map((row) => ({ + ...row, + basicprice1: Number(row.basicprice1 ?? 0), + basicprice2: Number(row.basicprice2 ?? 0), + basicprice3: Number(row.basicprice3 ?? 0) }))) + } + }, [query.data]) + + const setPreset = (from: Dayjs, to: Dayjs) => { + if (to.diff(from, 'day') > 30) { + message.warning('최대 31일까지 조회할 수 있습니다.') + return + } + form.setFieldsValue({ dates: [from, to] }) + } + + const onSearch = (values: Record) => { + const dates = values.dates as [Dayjs, Dayjs] | undefined + if (!dates?.[0] || !dates?.[1]) { + message.warning('개통일을 선택하세요.') + return + } + if (dates[1].diff(dates[0], 'day') > 30) { + message.warning('최대 31일까지 조회할 수 있습니다.') + return + } + const next: Record = { + dateFrom: dates[0].format('YYYY-MM-DD'), + dateTo: dates[1].format('YYYY-MM-DD'), + openhow: values.openhow, + telecom: values.telecom, + incompanyId: values.incompanyId, + outcompanyId: values.outcompanyId, + salesperson: values.salesperson, + model: values.model, + plan: values.plan } + for (let i = 1; i <= 7; i++) next[`sort${i}`] = values[`sort${i}`] + setSearched(true) + setFilters(next) + } + + const updateRow = (id: number, field: 'basicprice1' | 'basicprice2' | 'basicprice3', value: number | null) => { + setRows((prev) => prev.map((row) => (row.id === id ? { ...row, [field]: value ?? 0 } : row))) + } + + const applyBatch = (field: 'basicprice1' | 'basicprice2' | 'basicprice3') => { + const value = batch[field] + if (value == null) { + message.warning('금액을 입력하세요.') + return + } + setRows((prev) => prev.map((row) => ({ ...row, [field]: value }))) + message.success('일괄 입력했습니다.') + } + + const saveOne = useMutation({ + mutationFn: async (row: AdjustRow) => { + const { data } = await client.put(`${apiPrefix()}/opens/${row.id}`, { + basicprice1: row.basicprice1, + basicprice2: row.basicprice2, + basicprice3: row.basicprice3 }) + return unwrap(data as ApiResponse) + }, + onSuccess: () => message.success('변경했습니다.'), + onError: (e: Error) => message.error(e.message) }) + + const saveAll = useMutation({ + mutationFn: async () => { + const { data } = await client.post(`${apiPrefix()}/opens/adjust/batch`, { + items: rows.map((row) => ({ + id: row.id, + basicprice1: row.basicprice1, + basicprice2: row.basicprice2, + basicprice3: row.basicprice3 })) }) + return unwrap(data as ApiResponse<{ updated: number }>) + }, + onSuccess: (data) => message.success(`${data.updated}건을 변경했습니다.`), + onError: (e: Error) => message.error(e.message) }) + + const download = async () => { + if (!filters.dateFrom || !filters.dateTo) { + message.warning('개통일을 선택하세요.') + return + } + const { data } = await client.get(`${apiPrefix()}/opens/adjust/export`, { params: filters, responseType: 'blob' }) + const url = URL.createObjectURL(data) + const a = document.createElement('a') + a.href = url + a.download = '일괄개통정산.csv' + a.click() + URL.revokeObjectURL(url) + } + + const columns = [ + { title: '개통일', dataIndex: 'opendate', width: 100 }, + { title: '입고처', dataIndex: 'incompanyName', width: 100, ellipsis: true }, + { title: '출고처', dataIndex: 'outcompanyName', width: 100, ellipsis: true }, + { title: '고객명', dataIndex: 'name', width: 80 }, + { title: '종류', dataIndex: 'openhow', width: 70 }, + { title: '모델명', dataIndex: 'pmodel', width: 140, ellipsis: true }, + { title: '일련번호', dataIndex: 'pserial', width: 110, ellipsis: true }, + { title: '지원방법', dataIndex: 'decltype', width: 90, render: (v: string) => v || '-' }, + { title: '요금제', dataIndex: 'plan', width: 110, ellipsis: true }, + { title: '정산금액', dataIndex: 'sellprice', width: 90, align: 'right' as const, render: money }, + { + title: '기본정책', + dataIndex: 'basicprice1', + width: 110, + render: (_: unknown, row: AdjustRow) => ( + updateRow(row.id, 'basicprice1', v)} + /> + ) }, + { + title: '구두정책', + dataIndex: 'basicprice2', + width: 110, + render: (_: unknown, row: AdjustRow) => ( + updateRow(row.id, 'basicprice2', v)} + /> + ) }, + { + title: '추가정책', + dataIndex: 'basicprice3', + width: 110, + render: (_: unknown, row: AdjustRow) => ( + updateRow(row.id, 'basicprice3', v)} + /> + ) }, + { + title: '개별', + width: 70, + fixed: 'right' as const, + render: (_: unknown, row: AdjustRow) => ( + + ) }, + ] + + return ( +
+
+
+

일괄개통정산

+

등록된 개통정보에 일괄적으로 정책금액(정산)을 변경하실 수 있습니다. (유심개통 제외)

+
+
+ + +
+
+ 개통일 + + + + + + + + + + + * 최대 31일 조회 +
+ +
+ 검색조건 + + + + + + + + + + + + +
+ +
+ 정렬순서 + {defaultSorts.map((_, index) => ( + + {index > 0 && } + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + ({ value: v, label: v }))} /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ({ value: v, label: v }))} /> + +

판매하신 상품을 선택해주세요.

+
+ + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ setFilters((v) => ({ ...v, tab, page: 0 }))} + items={tabs.map(([key, label]) => ({ + key, + label: `${label} ${query.data?.counts?.[key] ?? 0}` }))} + /> + + + + + + + + + + + + ({ value: v, label: v }))} disabled={Boolean(editing)} /> + + + + * 지정입고처의 개통만 적용할 경우 선택해주세요.}> + ({ value: g, label: `${g}군` }))} /> + + + + + + + + + Number(v ?? 0).toLocaleString() }, + ]} + /> + +
+ ) +} diff --git a/frontend/src/pages/care/OpenWithdrawPage.tsx b/frontend/src/pages/care/OpenWithdrawPage.tsx new file mode 100644 index 0000000..d4917ea --- /dev/null +++ b/frontend/src/pages/care/OpenWithdrawPage.tsx @@ -0,0 +1,199 @@ +import { useMemo, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import dayjs, { type Dayjs } from 'dayjs' +import { Button, Card, DatePicker, Form, Input, Select, Space, Typography, message } from 'antd' +import CareTable from '../../components/CareTable' +import Modal from '../../components/DraggableModal' +import { + CloseOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, SearchOutlined } from '@ant-design/icons' +import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client' + +const { RangePicker } = DatePicker +const searchTypes = ['개통번호(뒷4자리)', '고객명', '사유 및 비고'] + +type WithdrawalRow = Record & { id: number; no?: number } +type WithdrawalData = { total: number; list: WithdrawalRow[] } + +const initial = { page: 0, size: 15, searchType: '개통번호(뒷4자리)' } as Record + +const withdrawalApi = { + async search(params: Record) { + const { data } = await client.get>(`${apiPrefix()}/withdrawals/search`, { params }) + return unwrap(data) + }, + async remove(ids: number[]) { + const { data } = await client.post(`${apiPrefix()}/withdrawals/delete`, { ids }) + return unwrap(data) + } } + +export default function OpenWithdrawPage() { + const [form] = Form.useForm() + const qc = useQueryClient() + const [selected, setSelected] = useState([]) + const [filters, setFilters] = useState>(initial) + + const query = useQuery({ + queryKey: ['open-withdraw', filters], + queryFn: () => withdrawalApi.search(filters) }) + + const remove = useMutation({ + mutationFn: () => withdrawalApi.remove(selected), + onSuccess: () => { + message.success('삭제했습니다.') + setSelected([]) + qc.invalidateQueries({ queryKey: ['open-withdraw'] }) + }, + onError: (e: Error) => message.error(e.message) }) + + const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] }) + + const onSearch = (values: Record) => { + const dates = values.dates as [Dayjs, Dayjs] | undefined + setSelected([]) + setFilters({ + ...initial, + outcompanyName: values.outcompanyName ? String(values.outcompanyName) : undefined, + searchType: values.searchType || '개통번호(뒷4자리)', + keyword: values.keyword ? String(values.keyword) : undefined, + dateFrom: dates?.[0]?.format('YYYY-MM-DD'), + dateTo: dates?.[1]?.format('YYYY-MM-DD'), + page: 0, + size: values.size ?? filters.size ?? 15 }) + } + + const reset = () => { + form.resetFields() + form.setFieldsValue({ searchType: '개통번호(뒷4자리)', size: 15 }) + setSelected([]) + setFilters(initial) + } + + const clearDates = () => form.setFieldValue('dates', undefined) + + const download = async () => { + try { + const { data } = await client.get(`${apiPrefix()}/withdrawals/export`, { params: filters, responseType: 'blob' }) + const url = URL.createObjectURL(data) + const a = document.createElement('a') + a.href = url + a.download = '개통철회이력.csv' + a.click() + URL.revokeObjectURL(url) + } catch { + message.error('엑셀 다운로드에 실패했습니다.') + } + } + + const columns = useMemo(() => [ + { title: '번호', dataIndex: 'no', width: 64, align: 'center' as const }, + { title: '철회일', dataIndex: 'withdrawDate', width: 110 }, + { title: '출고처', dataIndex: 'outcompanyName', width: 130, ellipsis: true }, + { title: '고객명', dataIndex: 'customerName', width: 90 }, + { title: '개통일', dataIndex: 'openDate', width: 110 }, + { title: '개통번호', dataIndex: 'openphone', width: 130 }, + { title: '처리직원', dataIndex: 'processor', width: 90 }, + { title: '처리일', dataIndex: 'processedAt', width: 140 }, + { title: '사유 및 비고', dataIndex: 'reason', width: 160, ellipsis: true, render: (v: string) => v || '-' }, + ], []) + + return ( +
+
+
+

개통 철회이력

+

모바일 개통과 관련된 이력을 확인하실 수 있습니다.

+
+ + * 대표권한은 이력을 삭제하실 수 있습니다. + + +
+ + +
+
+ + + + + + + + + + + + + + changeType(type)} /> + {type}개통 + + ))} +
+
+ + save.mutate(values)} + > + + + + + +
+ 단말기 + {openType !== '유심' && } +
+ {openType === '유심' ? ( +
관계없음
+ ) : ( + <> + + lookupSerial(v, 'phone')} + onSelect={(_, opt) => applyPhone((opt as { stock: Record }).stock)} + placeholder="단말기 일련번호를 입력해주세요" + > + { const v = e.target.value; if (v.length >= 3) lookupSerial(v, 'phone').then(() => { const hit = phoneOptions.find((o) => o.value === v); if (hit) applyPhone(hit.stock) }) }} /> + + +

(3자리 이상 입력 시 검색됩니다)

+
+
단말기정보{phoneInfo ? `${phoneInfo.model ?? '-'} / ${phoneInfo.color ?? '-'}` : '-'}
+
입고정보{phoneInfo ? `${phoneInfo.incompanyName ?? '-'} / ${phoneInfo.inprice != null ? Number(phoneInfo.inprice).toLocaleString() + '원' : '-'}` : '-'}
+
출고정보{phoneInfo ? `${phoneInfo.outcompanyName ?? '-'} / ${phoneInfo.statusLabel ?? phoneInfo.state ?? '-'}` : '-'}
+
+ + )} + + +
+ 유심 + +
+ + lookupSerial(v, 'usim')} + onSelect={(_, opt) => applyUsim((opt as { stock: Record }).stock)} + placeholder="유심 일련번호를 입력해주세요" + > + + + +

(3자리 이상 입력 시 검색됩니다)

+
+
유심정보{usimInfo ? String(usimInfo.model ?? usimInfo.serial ?? '-') : '-'}
+
입고정보{usimInfo ? `${usimInfo.incompanyName ?? '-'} / ${usimInfo.inprice != null ? Number(usimInfo.inprice).toLocaleString() + '원' : '-'}` : '-'}
+
출고정보{usimInfo ? `${usimInfo.outcompanyName ?? '-'} / ${usimInfo.statusLabel ?? usimInfo.state ?? '-'}` : '-'}
+
+ +
+
+ + + {openType === '유심' && ( + + + + )} + + + {openType !== '유심' && ( + <> + + + + + 요금할인 + + + {openType === '할부' && } + + + + + + + + + {openType === '할부' && } + {openType === '현금' && } + {openType === '현금' && } + + + )} + {openType === '유심' && ( + <> + + + + + + )} + + + + {openType !== '유심' && ( + <> + + + + + + + + )} + {openType === '유심' && ( + <> + + + + + )} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + + + + v && setKeepBase(v)} style={{ width: '100%', marginBottom: 8 }} /> +
+ + + setKeepDays(v)} style={{ width: 70 }} /> + 일 = + +
+
+ +
+ +
+ ) +} diff --git a/frontend/src/pages/care/OutCompanyPage.tsx b/frontend/src/pages/care/OutCompanyPage.tsx new file mode 100644 index 0000000..5c53f24 --- /dev/null +++ b/frontend/src/pages/care/OutCompanyPage.tsx @@ -0,0 +1,449 @@ +import { useMemo, useState } from 'react' +import Modal from '../../components/DraggableModal' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + Button, + Card, + Form, + Input, + Popover, + Select, + Space, + Typography, + message } from 'antd' +import CareTable from '../../components/CareTable' +import { + EditOutlined, FileTextOutlined, InfoCircleOutlined, MailOutlined, PlusOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons' +import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client' + +type OutCompanyRow = { + id: number + status?: string + active?: boolean + channel?: string + category?: string + name?: string + pcode?: string + salesperson?: string + phone?: string + managerName?: string + mobile?: string + businessName?: string + businessNumber?: string + bankAccount?: string + createdAt?: string + memo?: string +} + +type OutCompanyData = { + total: number + list: OutCompanyRow[] +} + +const channelOptions = ['도매', '소매', 'C채널', '특판', '기타'] +const categoryOptions = ['판매점', '출고처', '도매', '특판', '본사'] + +export default function OutCompanyPage() { + const qc = useQueryClient() + const [form] = Form.useForm() + const [editForm] = Form.useForm() + const [memoForm] = Form.useForm() + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [memoOpen, setMemoOpen] = useState(false) + const [memoTarget, setMemoTarget] = useState(null) + const [selected, setSelected] = useState([]) + const [filters, setFilters] = useState>({ + searchType: '출고처명', + page: 0, + size: 15, + sort: 'name,asc' }) + + const query = useQuery({ + queryKey: ['outcompanies', filters], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/outcompanies`, { params: filters }) + return unwrap(data) + } }) + + const refresh = () => { + setSelected([]) + qc.invalidateQueries({ queryKey: ['outcompanies'] }) + } + + const saveMut = useMutation({ + mutationFn: async (values: Record) => { + if (editing?.id) { + const { data } = await client.put(`${apiPrefix()}/outcompanies/${editing.id}`, values) + return unwrap(data) + } + const { data } = await client.post(`${apiPrefix()}/outcompanies`, values) + return unwrap(data) + }, + onSuccess: () => { + message.success(editing ? '출고처를 수정했습니다.' : '출고처를 등록했습니다.') + setOpen(false) + setEditing(null) + editForm.resetFields() + refresh() + }, + onError: (e: Error) => message.error(e.message) }) + + const statusMut = useMutation({ + mutationFn: async (status: '사용' | '미사용') => { + const { data } = await client.post(`${apiPrefix()}/outcompanies/status`, { ids: selected, status }) + return unwrap(data) + }, + onSuccess: (_d, status) => { + message.success(`${status} 처리했습니다.`) + refresh() + }, + onError: (e: Error) => message.error(e.message) }) + + const memoMut = useMutation({ + mutationFn: async (memo: string) => { + const { data } = await client.put(`${apiPrefix()}/outcompanies/${memoTarget!.id}`, { memo }) + return unwrap(data) + }, + onSuccess: () => { + message.success('비고를 저장했습니다.') + setMemoOpen(false) + setMemoTarget(null) + memoForm.resetFields() + refresh() + }, + onError: (e: Error) => message.error(e.message) }) + + const onSearch = (values: Record) => { + setFilters((prev) => ({ + ...prev, + status: values.status, + channel: values.channel, + category: values.category, + searchType: values.searchType || '출고처명', + keyword: values.keyword, + page: 0, + size: Number(values.size ?? prev.size ?? 15) })) + } + + const onReset = () => { + form.setFieldsValue({ + status: undefined, + channel: undefined, + category: undefined, + searchType: '출고처명', + keyword: undefined, + size: 15 }) + setFilters({ searchType: '출고처명', page: 0, size: 15, sort: 'name,asc' }) + } + + const openCreate = () => { + setEditing(null) + editForm.setFieldsValue({ + name: undefined, + channel: '도매', + category: '판매점', + pcode: undefined, + salesperson: undefined, + phone: undefined, + managerName: undefined, + mobile: undefined, + businessName: undefined, + businessNumber: undefined, + bankAccount: undefined, + memo: undefined, + status: '사용', + type: 'OUT' }) + setOpen(true) + } + + const openEdit = (row: OutCompanyRow) => { + setEditing(row) + editForm.setFieldsValue({ + name: row.name, + channel: row.channel, + category: row.category, + pcode: row.pcode, + salesperson: row.salesperson, + phone: row.phone, + managerName: row.managerName, + mobile: row.mobile, + businessName: row.businessName, + businessNumber: row.businessNumber, + bankAccount: row.bankAccount, + memo: row.memo, + status: row.status || '사용' }) + setOpen(true) + } + + const openMemo = (row: OutCompanyRow) => { + setMemoTarget(row) + memoForm.setFieldsValue({ memo: row.memo }) + setMemoOpen(true) + } + + const download = async () => { + const { data } = await client.get(`${apiPrefix()}/outcompanies/export`, { + params: filters, + responseType: 'blob' }) + const url = URL.createObjectURL(data) + const a = document.createElement('a') + a.href = url + a.download = '출고처관리.csv' + a.click() + URL.revokeObjectURL(url) + } + + const columns = useMemo(() => [ + { + title: '상태', + dataIndex: 'status', + width: 70, + align: 'center' as const, + render: (v: string) => {v} }, + { title: '채널', dataIndex: 'channel', width: 80, align: 'center' as const, sorter: true }, + { title: '업체명', dataIndex: 'name', width: 140, ellipsis: true, sorter: true }, + { title: 'P코드', dataIndex: 'pcode', width: 90, align: 'center' as const, sorter: true }, + { title: '영업담당', dataIndex: 'salesperson', width: 90, align: 'center' as const }, + { title: '전화', dataIndex: 'phone', width: 120, align: 'center' as const }, + { title: '담당자', dataIndex: 'managerName', width: 90, align: 'center' as const }, + { title: '휴대폰', dataIndex: 'mobile', width: 120, align: 'center' as const }, + { title: '상호', dataIndex: 'businessName', width: 110, ellipsis: true }, + { title: '사업자번호', dataIndex: 'businessNumber', width: 120, align: 'center' as const }, + { + title: '계좌', + dataIndex: 'bankAccount', + width: 70, + align: 'center' as const, + render: (v?: string) => ( + v + ? ( + {v}
} title="계좌정보"> + + + + +
+ + +
+
+ + ({ value: v, label: v }))} /> + + + ({ value: v, label: v }))} + /> + + + + + + + + + + + + + + + ({ value: v, label: v }))} /> + + + + + + + + + + + + + + + + + + + + + + + + + + + setPendingType(v)} + /> + setView(v)} + /> +
+
+ +
+
+ {WEEK_LABELS.map((w) => ( +
{w.label}
+ ))} +
+
+ {cells.map((date) => { + const key = date.format('YYYY-MM-DD') + const inMonth = date.month() === cursor.month() + const isToday = key === todayKey + const isMonthStart = date.date() === 1 + const items = byDate[key] || [] + const dateLabel = isMonthStart ? `${date.month() + 1}월 ${date.date()}일` : String(date.date()) + return ( +
openCreate(date)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + openCreate(date) + } + }} + > +
+ {dateLabel} +
+
+ {items.slice(0, view === 'simple' ? 3 : 6).map((row) => { + const text = eventLabel(row, view) + return ( + +
{ + e.stopPropagation() + openEdit(row) + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + e.stopPropagation() + openEdit(row) + } + }} + > + {text} +
+
+ ) + })} + {items.length > (view === 'simple' ? 3 : 6) && ( +
+{items.length - (view === 'simple' ? 3 : 6)}
+ )} +
+
+ ) + })} +
+
+ + + + save.mutate(values)} + className="balance-edit-form" + > + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+ + o.value)} + saving={saveTypes.isPending} + submitClassName="pending-submit-btn" + onCancel={() => setTypeOpen(false)} + onSave={(types) => saveTypes.mutate(types)} + /> +
+ ) +} diff --git a/frontend/src/pages/care/PendingLogPage.tsx b/frontend/src/pages/care/PendingLogPage.tsx new file mode 100644 index 0000000..5922ee8 --- /dev/null +++ b/frontend/src/pages/care/PendingLogPage.tsx @@ -0,0 +1,21 @@ +import { ClockCircleOutlined } from '@ant-design/icons' +import { HomesLogListPage } from './HomesLogListPage' + +const ACTIONS = [ + '약속-등록', + '약속-수정', + '약속-삭제', + '약속-해결', +] + +export default function PendingLogPage() { + return ( + } + /> + ) +} diff --git a/frontend/src/pages/care/PendingPage.tsx b/frontend/src/pages/care/PendingPage.tsx new file mode 100644 index 0000000..b1d070b --- /dev/null +++ b/frontend/src/pages/care/PendingPage.tsx @@ -0,0 +1,515 @@ +import { useMemo, useState } from 'react' +import Modal from '../../components/DraggableModal' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import dayjs, { type Dayjs } from 'dayjs' +import { + Button, + Card, + Checkbox, + DatePicker, + Form, + Input, + Select, + Space, + Tabs, + Tooltip, + message } from 'antd' +import CareTable from '../../components/CareTable' +import SubjectSettingsModal from '../../components/SubjectSettingsModal' +import { + ClockCircleOutlined, + CloseOutlined, + DownloadOutlined, + EditOutlined, + MailOutlined, + PlusSquareOutlined, + ReloadOutlined, + SearchOutlined, + SettingOutlined } from '@ant-design/icons' +import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client' +import { options } from './homesShared' + +const { RangePicker } = DatePicker + +type PendingRow = { + id: number + status?: string + pendingDate?: string + pendingType?: string + contents?: string + customerName?: string + phone?: string + receiveEmployee?: string + processDate?: string + processEmployee?: string + memo?: string + pendingTime?: string +} + +type PendingResult = { + total: number + list: PendingRow[] + counts?: Record + types?: string[] + statuses?: string[] + receiveEmployees?: string[] + processEmployees?: string[] +} + +const initial = { page: 0, size: 10, tab: 'ALL' } as Record + +const maskName = (name?: string) => { + if (!name) return '-' + if (name.length <= 1) return name + if (name.length === 2) return `${name[0]}*` + return `${name[0]}*${name.slice(2)}` +} + +const maskPhone = (phone?: string) => { + if (!phone) return '-' + const digits = phone.replace(/\D/g, '') + if (digits.length < 7) return phone + if (digits.length === 10) return `${digits.slice(0, 3)}-****-${digits.slice(6)}` + return `${digits.slice(0, 3)}-****-${digits.slice(-4)}` +} + +const splitPhone = (phone?: string) => { + const digits = (phone || '').replace(/\D/g, '') + if (digits.length >= 10) { + return { + phone1: digits.slice(0, 3), + phone2: digits.slice(3, digits.length - 4), + phone3: digits.slice(-4) } + } + return { phone1: '010', phone2: '', phone3: '' } +} + +export default function PendingPage() { + const [form] = Form.useForm() + const [editForm] = Form.useForm() + const qc = useQueryClient() + const [filters, setFilters] = useState>(initial) + const [selected, setSelected] = useState([]) + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [typeOpen, setTypeOpen] = useState(false) + + const query = useQuery({ + queryKey: ['homes-pendings', filters], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/homes/pendings`, { + params: { + ...filters, + pendingType: filters.tab === 'ALL' ? undefined : filters.tab } }) + return unwrap(data) + } }) + + const members = useQuery({ + queryKey: ['homes/members-options'], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/homes/members`, { + params: { page: 0, size: 200 } }) + return unwrap(data) + } }) + + const refresh = () => { + qc.invalidateQueries({ queryKey: ['homes-pendings'] }) + setSelected([]) + } + + const staffOptions = useMemo(() => { + const fromApi = [ + ...(query.data?.receiveEmployees || []), + ...(query.data?.processEmployees || []), + ] + const fromMembers = (members.data?.list || []).map((m) => String(m.name || '')).filter(Boolean) + return Array.from(new Set([...fromApi, ...fromMembers])).map((v) => ({ value: v, label: v })) + }, [query.data, members.data]) + + const typeOptions = useMemo(() => { + const fromApi = query.data?.types || ['일반', '약속1', '약속2'] + return fromApi.map((v) => ({ value: v, label: v })) + }, [query.data?.types]) + + const tabs = useMemo(() => { + const types = query.data?.types || ['일반', '약속1', '약속2'] + return [ + { key: 'ALL', label: `전체 ${query.data?.counts?.ALL ?? query.data?.total ?? 0}` }, + ...types.map((t) => ({ key: t, label: `${t} ${query.data?.counts?.[t] ?? 0}` })), + ] + }, [query.data]) + + const save = useMutation({ + mutationFn: async (values: Record) => { + const phone = [values.phone1, values.phone2, values.phone3] + .map((v) => String(v || '').replace(/\D/g, '')) + .filter(Boolean) + .join('-') + const payload: Record = { + receiveEmployee: values.receiveEmployee, + employee: values.receiveEmployee, + pendingDate: values.pendingDate ? dayjs(values.pendingDate as Dayjs).format('YYYY-MM-DD') : undefined, + pendingType: values.pendingType, + contents: values.contents, + customerName: values.customerName, + phone: phone || undefined, + memo: values.memo, + status: values.status || (editing?.status ?? '접수') } + if (editing) { + const { data } = await client.put(`${apiPrefix()}/homes/pendings/${editing.id}`, payload) + return unwrap(data as ApiResponse) + } + const { data } = await client.post(`${apiPrefix()}/homes/pendings`, payload) + return unwrap(data as ApiResponse) + }, + onSuccess: () => { + message.success(editing ? '수정했습니다.' : '일정을 등록했습니다.') + setCreating(false) + setEditing(null) + editForm.resetFields() + refresh() + }, + onError: (e: Error) => message.error(e.message) }) + + const processMut = useMutation({ + mutationFn: async (ids: number[]) => { + const { data } = await client.post(`${apiPrefix()}/homes/pendings/process`, { ids }) + return unwrap(data as ApiResponse<{ updated: number }>) + }, + onSuccess: (data) => { + message.success(`${data.updated}건을 처리했습니다.`) + refresh() + }, + onError: (e: Error) => message.error(e.message) }) + + const saveTypes = useMutation({ + mutationFn: async (types: string[]) => { + const { data } = await client.put(`${apiPrefix()}/homes/pendings/types`, { types }) + return unwrap(data as ApiResponse<{ types: string[] }>) + }, + onSuccess: () => { + message.success('과목을 저장했습니다.') + setTypeOpen(false) + refresh() + }, + onError: (e: Error) => message.error(e.message) }) + + const setPreset = (from: Dayjs, to: Dayjs) => form.setFieldsValue({ dates: [from, to] }) + + const onSearch = (values: Record) => { + const dates = values.dates as [Dayjs, Dayjs] | undefined + setFilters({ + ...initial, + status: values.status || undefined, + receiveEmployee: values.receiveEmployee || undefined, + processEmployee: values.processEmployee || undefined, + phoneTail: values.phoneTail || undefined, + dateFrom: dates?.[0]?.format('YYYY-MM-DD'), + dateTo: dates?.[1]?.format('YYYY-MM-DD'), + page: 0, + size: values.size ?? filters.size ?? 10, + tab: filters.tab }) + } + + const reset = () => { + form.resetFields() + form.setFieldsValue({ size: 10 }) + setFilters(initial) + } + + const openCreate = () => { + setEditing(null) + setCreating(true) + const defaultStaff = staffOptions[0]?.value || '김대표' + editForm.setFieldsValue({ + receiveEmployee: defaultStaff, + pendingDate: undefined, + pendingType: typeOptions[0]?.value || '일반', + contents: undefined, + customerName: undefined, + phone1: '010', + phone2: '', + phone3: '', + memo: undefined, + status: '접수' }) + } + + const openEdit = (row: PendingRow) => { + setCreating(false) + setEditing(row) + const phones = splitPhone(row.phone) + editForm.setFieldsValue({ + receiveEmployee: row.receiveEmployee, + pendingDate: row.pendingDate ? dayjs(row.pendingDate) : undefined, + pendingType: row.pendingType, + contents: row.contents, + customerName: row.customerName, + ...phones, + memo: row.memo, + status: row.status || '접수' }) + } + + const download = async () => { + try { + const { data } = await client.get(`${apiPrefix()}/homes/pendings/export`, { + params: { ...filters, pendingType: filters.tab === 'ALL' ? undefined : filters.tab }, + responseType: 'blob' }) + const url = URL.createObjectURL(data) + const a = document.createElement('a') + a.href = url + a.download = '일정관리.csv' + a.click() + URL.revokeObjectURL(url) + } catch { + message.error('엑셀 다운로드에 실패했습니다.') + } + } + + const openTypeSettings = () => setTypeOpen(true) + + const typeItems = query.data?.types || ['일반', '약속1', '약속2'] + + const columns = [ + { + title: ( + 0 && selected.length < (query.data?.list?.length || 0)} + onChange={(e) => setSelected(e.target.checked ? (query.data?.list || []).map((r) => r.id) : [])} + /> + ), + width: 42, + render: (_: unknown, row: PendingRow) => ( + setSelected((prev) => (e.target.checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)))} + /> + ) }, + { + title: '상태', + dataIndex: 'status', + width: 70, + render: (v: string) => {v || '-'} }, + { + title: '일자', + dataIndex: 'pendingDate', + width: 100, + sorter: (a: PendingRow, b: PendingRow) => String(a.pendingDate || '').localeCompare(String(b.pendingDate || '')) }, + { title: '과목', dataIndex: 'pendingType', width: 80 }, + { title: '내용', dataIndex: 'contents', width: 160, ellipsis: true }, + { + title: '고객명', + dataIndex: 'customerName', + width: 90, + render: (v: string) => maskName(v) }, + { + title: '연락처', + dataIndex: 'phone', + width: 120, + render: (v: string) => maskPhone(v) }, + { title: '접수직원', dataIndex: 'receiveEmployee', width: 90 }, + { + title: '처리일', + dataIndex: 'processDate', + width: 100, + sorter: (a: PendingRow, b: PendingRow) => String(a.processDate || '').localeCompare(String(b.processDate || '')), + render: (v: string) => v || '-' }, + { + title: '처리직원', + dataIndex: 'processEmployee', + width: 90, + render: (v: string) => v || '-' }, + { + title: '비고', + dataIndex: 'memo', + width: 54, + align: 'center' as const, + render: (v: string) => (v ? : '-') }, + { + title: '관리', + width: 72, + fixed: 'right' as const, + align: 'center' as const, + render: (_: unknown, row: PendingRow) => ( + + + + + + +
+
+ + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + + + setTypeOpen(false)} + onSave={(types) => saveTypes.mutate(types)} + /> +
+ ) +} diff --git a/frontend/src/pages/care/PreferencePage.tsx b/frontend/src/pages/care/PreferencePage.tsx new file mode 100644 index 0000000..80083e5 --- /dev/null +++ b/frontend/src/pages/care/PreferencePage.tsx @@ -0,0 +1,624 @@ +import { useEffect, useRef, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + Button, Card, Checkbox, Form, Input, InputNumber, Radio, Select, Space, Tabs, Typography, message } from 'antd' +import CareTable from '../../components/CareTable' +import { PlusOutlined, SettingOutlined } from '@ant-design/icons' +import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client' + +type CompanyInfo = { + name?: string + managerName?: string + mobile?: string + businessName?: string + businessNumber?: string + ceo?: string + bizType?: string + bizItem?: string + address?: string +} + +type AccessIps = { + enabled?: boolean + ips?: string[] +} + +type Masking = { + enabled?: boolean + name?: boolean + phone?: boolean + rrn?: boolean + address?: boolean +} + +type StockSetting = { + duplicateSerialCheck?: boolean + allowNegative?: boolean + autoRecallDays?: number +} + +type OpenSetting = { + requireUsim?: boolean + requirePhone?: boolean + defaultOpenHow?: string + autoSettle?: boolean +} + +type Preference = { + company?: CompanyInfo + accessIps?: AccessIps + masking?: Masking + outCategories?: string[] + stock?: StockSetting + open?: OpenSetting +} + +const TAB_KEYS = [ + { key: 'company', label: '사업자정보' }, + { key: 'accessIp', label: '접속아이피' }, + { key: 'masking', label: '개인정보마스킹' }, + { key: 'outCategory', label: '출고처분류' }, + { key: 'stock', label: '재고설정' }, + { key: 'open', label: '개통설정' }, +] as const + +type TabKey = (typeof TAB_KEYS)[number]['key'] + +const splitPhone = (mobile?: string) => { + const digits = String(mobile || '').replace(/\D/g, '') + if (digits.length >= 10) { + return { + p1: digits.slice(0, 3), + p2: digits.slice(3, 7), + p3: digits.slice(7, 11) } + } + const parts = String(mobile || '').split('-') + return { p1: parts[0] || '010', p2: parts[1] || '', p3: parts[2] || '' } +} + +const splitBizNo = (no?: string) => { + const parts = String(no || '').split('-') + if (parts.length >= 3) return { b1: parts[0], b2: parts[1], b3: parts[2] } + const digits = String(no || '').replace(/\D/g, '') + return { + b1: digits.slice(0, 3), + b2: digits.slice(3, 5), + b3: digits.slice(5, 10) } +} + +function SectionHead({ title, desc }: { title: string; desc: string }) { + return ( +
+

{title}

+

{desc}

+
+ ) +} + +export default function PreferencePage() { + const qc = useQueryClient() + const [tab, setTab] = useState('company') + const [companyForm] = Form.useForm() + const [maskForm] = Form.useForm() + const [stockForm] = Form.useForm() + const [openForm] = Form.useForm() + const [ipInput, setIpInput] = useState('') + const [categoryInput, setCategoryInput] = useState('') + const [localIps, setLocalIps] = useState([]) + const [ipEnabled, setIpEnabled] = useState(false) + const [categories, setCategories] = useState([]) + const sectionRefs = useRef>({ + company: null, + accessIp: null, + masking: null, + outCategory: null, + stock: null, + open: null }) + + const query = useQuery({ + queryKey: ['preference'], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/settings/preference`) + return unwrap(data) + } }) + + useEffect(() => { + if (!query.data) return + const c = query.data.company || {} + const phone = splitPhone(c.mobile) + const biz = splitBizNo(c.businessNumber) + companyForm.setFieldsValue({ + name: c.name, + managerName: c.managerName, + phone1: phone.p1 || '010', + phone2: phone.p2, + phone3: phone.p3, + businessName: c.businessName, + biz1: biz.b1, + biz2: biz.b2, + biz3: biz.b3, + ceo: c.ceo, + bizType: c.bizType, + bizItem: c.bizItem, + address: c.address }) + setLocalIps(query.data.accessIps?.ips || []) + setIpEnabled(!!query.data.accessIps?.enabled) + maskForm.setFieldsValue(query.data.masking || {}) + setCategories(query.data.outCategories || []) + stockForm.setFieldsValue(query.data.stock || {}) + openForm.setFieldsValue(query.data.open || {}) + }, [query.data, companyForm, maskForm, stockForm, openForm]) + + const save = useMutation({ + mutationFn: async (patch: Preference) => { + const { data } = await client.put>(`${apiPrefix()}/settings/preference`, patch) + return unwrap(data) + }, + onSuccess: (data) => { + qc.setQueryData(['preference'], data) + message.success('적용했습니다.') + }, + onError: (e: Error) => message.error(e.message) }) + + const onTabChange = (key: string) => { + const k = key as TabKey + setTab(k) + sectionRefs.current[k]?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + + const saveCompany = async () => { + const v = await companyForm.validateFields() + const mobile = [v.phone1, v.phone2, v.phone3].filter(Boolean).join('-') + const businessNumber = [v.biz1, v.biz2, v.biz3].filter(Boolean).join('-') + save.mutate({ + company: { + name: v.name, + managerName: v.managerName, + mobile, + businessName: v.businessName, + businessNumber, + ceo: v.ceo, + bizType: v.bizType, + bizItem: v.bizItem, + address: v.address } }) + } + + const saveAccessIps = () => { + save.mutate({ accessIps: { enabled: ipEnabled, ips: localIps } }) + } + + const saveMasking = async () => { + const v = await maskForm.validateFields() + save.mutate({ masking: v }) + } + + const saveCategories = () => { + save.mutate({ outCategories: categories }) + } + + const saveStock = async () => { + const v = await stockForm.validateFields() + save.mutate({ stock: v }) + } + + const saveOpen = async () => { + const v = await openForm.validateFields() + save.mutate({ open: v }) + } + + const addIp = () => { + const ip = ipInput.trim() + if (!ip) { + message.warning('아이피를 입력하세요.') + return + } + if (localIps.includes(ip)) { + message.warning('이미 등록된 아이피입니다.') + return + } + setLocalIps((prev) => [...prev, ip]) + setIpInput('') + } + + const addCategory = () => { + const name = categoryInput.trim() + if (!name) { + message.warning('분류명을 입력하세요.') + return + } + if (categories.includes(name)) { + message.warning('이미 등록된 분류입니다.') + return + } + setCategories((prev) => [...prev, name]) + setCategoryInput('') + } + + return ( +
+
+
+

기본설정

+

에이전트 이용에 필요한 기본적인 설정을 하실 수 있습니다.

+
+
+ + + ({ key: t.key, label: t.label }))} + /> + +
{ sectionRefs.current.company = el }} + className="pref-section" + id="pref-company" + > + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
업체명 + + + +
빌링에이전트 담당자 + + + + 휴대폰 + + + + + + + + +
상호 + + + +
사업자등록번호 + + + + + - + + + + - + + + + + 대표 + + + +
업태 + + + + 종목 + + + +
주소 + + + +
+
+ +
+
+
+ +
{ sectionRefs.current.accessIp = el }} + className="pref-section" + id="pref-accessIp" + > + +
+
+ 접속제한 + setIpEnabled(e.target.value)} + options={[ + { value: true, label: '사용' }, + { value: false, label: '미사용' }, + ]} + /> +
+
+ 아이피 등록 + + setIpInput(e.target.value)} + onPressEnter={addIp} + /> + + +
+ ({ ip }))} + columns={[ + { title: 'No', width: 60, align: 'center' as const, render: (_: unknown, __: { ip: string }, i: number) => i + 1 }, + { title: '아이피', dataIndex: 'ip' }, + { + title: '관리', + width: 90, + align: 'center' as const, + render: (_: unknown, row: { ip: string }) => ( + + ) }, + ]} + locale={{ emptyText: '등록된 아이피가 없습니다.' }} + /> +
+ +
+
+
+ +
{ sectionRefs.current.masking = el }} + className="pref-section" + id="pref-masking" + > + +
+
+ 마스킹 사용 + + + +
+
+ 마스킹 항목 + + + 고객명 + + + 연락처 + + + 주민번호 + + + 주소 + + +
+
+ +
+
+
+ +
{ sectionRefs.current.outCategory = el }} + className="pref-section" + id="pref-outCategory" + > + +
+
+ 분류 등록 + + setCategoryInput(e.target.value)} + onPressEnter={addCategory} + /> + + +
+ ({ name }))} + columns={[ + { title: 'No', width: 60, align: 'center' as const, render: (_: unknown, __: { name: string }, i: number) => i + 1 }, + { title: '분류명', dataIndex: 'name' }, + { + title: '관리', + width: 90, + align: 'center' as const, + render: (_: unknown, row: { name: string }) => ( + + ) }, + ]} + locale={{ emptyText: '등록된 분류가 없습니다.' }} + /> +
+ +
+
+
+ +
{ sectionRefs.current.stock = el }} + className="pref-section" + id="pref-stock" + > + +
+
+ 일련번호 중복검사 + + + +
+
+ 마이너스 재고 허용 + + + +
+
+ 자동회수 기준일 + + + + + 일 (0이면 미사용) + +
+
+ +
+
+
+ +
{ sectionRefs.current.open = el }} + className="pref-section" + id="pref-open" + > + +
+
+ 유심 필수 + + + +
+
+ 단말기 필수 + + + +
+
+ 기본 개통유형 + + + + + + + + + + + + +
+ {editing && ( + { remove.mutate(editing.id); setOpen(false) }}> + + + )} +
+ + +
+
+ + + + setPolicyTarget(null)} + footer={null} + width={640} + destroyOnHidden + > +
addPolicy.mutate(v)} + initialValues={{ amount: 100000 }} + > + + + + + + + +
+ ( + removePolicy.mutate(row.id)}> + + + ) }, + ]} + /> +
+
+ ) +} diff --git a/frontend/src/pages/care/ProductInternetPage.tsx b/frontend/src/pages/care/ProductInternetPage.tsx new file mode 100644 index 0000000..5e0082e --- /dev/null +++ b/frontend/src/pages/care/ProductInternetPage.tsx @@ -0,0 +1,388 @@ +import { useMemo, useState } from 'react' +import { useSearchParams } from 'react-router-dom' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + Button, Card, Checkbox, Form, Input, InputNumber, Popconfirm, Select, Space, Tabs, message } from 'antd' +import CareTable from '../../components/CareTable' +import { + AppstoreOutlined, FormOutlined, PlusSquareOutlined, SettingOutlined, SyncOutlined } from '@ant-design/icons' +import Modal from '../../components/DraggableModal' +import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client' +import { money, options, type PageResult, type Row } from './homesShared' + +const CATEGORIES = ['인터넷', 'TV', '전화', '와이파이', '스마트홈', '기타'] as const +const COMPANIES = ['KT', 'SKB', 'LGU+', '기타'] +const MONTH_OPTIONS = [ + { value: 12, label: '1년(12개월)' }, + { value: 24, label: '2년(24개월)' }, + { value: 36, label: '3년(36개월)' }, + { value: 48, label: '4년(48개월)' }, + { value: 60, label: '5년(60개월)' }, +] + +function formatMonths(v: unknown) { + const months = Number(v) + if (!months) return '-' + if (months % 12 === 0) return `${months / 12}년(${months}개월)` + return `${months}개월` +} + +export default function ProductInternetPage() { + const qc = useQueryClient() + const [searchParams] = useSearchParams() + const [form] = Form.useForm() + const [policyForm] = Form.useForm() + const initialCategory = CATEGORIES.includes(searchParams.get('category') as (typeof CATEGORIES)[number]) + ? (searchParams.get('category') as string) + : '인터넷' + const [category, setCategory] = useState(initialCategory) + const [page, setPage] = useState(0) + const [size, setSize] = useState(15) + const [selected, setSelected] = useState([]) + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [policyTarget, setPolicyTarget] = useState(null) + + const filters = useMemo( + () => ({ kind: 'INTERNET', category, page, size }), + [category, page, size], + ) + + const query = useQuery({ + queryKey: ['homes/products', filters], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/homes/products`, { params: filters }) + return unwrap(data) + } }) + + const policies = useQuery({ + queryKey: ['homes/product-policies', policyTarget?.id], + queryFn: async () => { + const { data } = await client.get>( + `${apiPrefix()}/homes/products/${policyTarget!.id}/policies`, + ) + return unwrap(data) + }, + enabled: Boolean(policyTarget) }) + + const save = useMutation({ + mutationFn: async (values: Record) => { + const payload = { kind: 'INTERNET', ...values } + if (editing) { + const { data } = await client.put(`${apiPrefix()}/homes/products/${editing.id}`, payload) + return unwrap(data) + } + const { data } = await client.post(`${apiPrefix()}/homes/products`, payload) + return unwrap(data) + }, + onSuccess: () => { + message.success(editing ? '수정했습니다.' : '등록했습니다.') + setOpen(false) + setEditing(null) + form.resetFields() + qc.invalidateQueries({ queryKey: ['homes/products'] }) + }, + onError: (e: Error) => message.error(e.message) }) + + const remove = useMutation({ + mutationFn: async (id: number) => { + const { data } = await client.delete(`${apiPrefix()}/homes/products/${id}`) + return unwrap(data) + }, + onSuccess: () => { + message.success('삭제했습니다.') + qc.invalidateQueries({ queryKey: ['homes/products'] }) + }, + onError: (e: Error) => message.error(e.message) }) + + const sync = useMutation({ + mutationFn: async () => { + const { data } = await client.post>(`${apiPrefix()}/homes/products/sync`, { kind: 'INTERNET' }) + return unwrap(data) + }, + onSuccess: (res) => { + message.success(`기본상품을 동기화했습니다. (+${res?.added ?? 0})`) + qc.invalidateQueries({ queryKey: ['homes/products'] }) + }, + onError: (e: Error) => message.error(e.message) }) + + const addPolicy = useMutation({ + mutationFn: async (values: Record) => { + const { data } = await client.post(`${apiPrefix()}/homes/products/${policyTarget!.id}/policies`, values) + return unwrap(data) + }, + onSuccess: () => { + message.success('정책을 등록했습니다.') + policyForm.resetFields() + policyForm.setFieldsValue({ amount: 50000 }) + qc.invalidateQueries({ queryKey: ['homes/product-policies', policyTarget?.id] }) + qc.invalidateQueries({ queryKey: ['homes/products'] }) + }, + onError: (e: Error) => message.error(e.message) }) + + const removePolicy = useMutation({ + mutationFn: async (id: number) => { + const { data } = await client.delete(`${apiPrefix()}/homes/products/policies/${id}`) + return unwrap(data) + }, + onSuccess: () => { + message.success('정책을 삭제했습니다.') + qc.invalidateQueries({ queryKey: ['homes/product-policies', policyTarget?.id] }) + qc.invalidateQueries({ queryKey: ['homes/products'] }) + }, + onError: (e: Error) => message.error(e.message) }) + + const openCreate = () => { + setEditing(null) + form.resetFields() + form.setFieldsValue({ category: category === 'ALL' ? '인터넷' : category, months: 36 }) + setOpen(true) + } + + const openEdit = (row: Row) => { + setEditing(row) + form.setFieldsValue({ + category: row.category, + company: row.company, + name: row.name, + months: row.months, + memo: row.memo }) + setOpen(true) + } + + const counts = query.data?.counts + const list = query.data?.list || [] + + const columns = [ + { + title: ( + 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) => ( + { + setSelected((prev) => (e.target.checked ? [...prev, row.id] : prev.filter((id) => id !== row.id))) + }} + /> + ) }, + { title: '회사', dataIndex: 'company', width: 90 }, + { title: '상품명', dataIndex: 'name', ellipsis: true }, + { + title: '기본약정', + dataIndex: 'months', + width: 130, + render: formatMonths }, + { title: '비고', dataIndex: 'memo', width: 160, ellipsis: true, render: (v: unknown) => v || '' }, + { + title: '정책설정', + width: 130, + align: 'center' as const, + render: (_: unknown, row: Row) => { + const count = Number(row.policyCount || 0) + return ( + + ) + } }, + { + title: '관리', + width: 64, + align: 'center' as const, + render: (_: unknown, row: Row) => ( + + + +
+ + +
+ { + setCategory(key) + setPage(0) + setSelected([]) + }} + items={CATEGORIES.map((c) => ({ + key: c, + label: counts?.[c] != null ? `${c} ${counts[c]}` : c }))} + /> +
+ + +
+
+ + { + setPage(p - 1) + setSize(s) + setSelected([]) + }, + showTotal: (t) => `총 ${t}건` }} + /> +
+ + setOpen(false)} + footer={null} + width={520} + destroyOnHidden + className="product-internet-modal" + > +
save.mutate(values)} + className="product-internet-form" + > + + + + + + + + + +
+ {editing && ( + { remove.mutate(editing.id); setOpen(false) }}> + + + )} +
+ + +
+
+
+
+ + setPolicyTarget(null)} + footer={null} + width={640} + destroyOnHidden + > +
addPolicy.mutate(v)} + initialValues={{ amount: 50000 }} + > + + + + + + + +
+ ( + removePolicy.mutate(row.id)}> + + + ) }, + ]} + /> +
+
+ ) +} diff --git a/frontend/src/pages/care/ProductListPage.tsx b/frontend/src/pages/care/ProductListPage.tsx new file mode 100644 index 0000000..4e664f9 --- /dev/null +++ b/frontend/src/pages/care/ProductListPage.tsx @@ -0,0 +1,144 @@ +import { useEffect, useState } from 'react' +import { useNavigate, useLocation } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { Button, Card, Form, Input, Select, Tabs } from 'antd' +import CareTable from '../../components/CareTable' +import { FileTextOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, ShoppingOutlined } from '@ant-design/icons' +import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client' + +const CATEGORIES = ['인터넷', 'TV', '집전화', '렌탈', '정수기', '공기청정기', '비데', '홈IOT'] as const +const categoryFromPath: Record = { + '/care/product/internet': '인터넷', + '/care/product/tv': 'TV', + '/care/product/phone': '집전화', + '/care/product/rental': '렌탈', + '/care/product/water': '정수기', + '/care/product/air': '공기청정기', + '/care/product/bidet': '비데', + '/care/product/iot': '홈IOT' } + +const options = (values: string[]) => values.map((value) => ({ value, label: value })) +const money = (v: unknown) => (v == null || v === '' ? '-' : Number(v).toLocaleString()) + +type Product = Record & { id: number } +type SearchResult = { total: number; list: Product[]; counts?: Record } + +const initial = { page: 0, size: 15, category: 'ALL' } as Record + +export default function ProductListPage() { + const [form] = Form.useForm() + const navigate = useNavigate() + const location = useLocation() + const pathCategory = categoryFromPath[location.pathname] + const [filters, setFilters] = useState>({ + ...initial, + category: pathCategory ?? 'ALL' }) + + useEffect(() => { + setFilters((v) => ({ ...v, category: pathCategory ?? 'ALL', page: 0 })) + }, [pathCategory]) + + const query = useQuery({ + queryKey: ['products-list', filters], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/products`, { params: filters }) + return unwrap(data) + } }) + + const onSearch = (values: Record) => { + setFilters({ + ...initial, + ...values, + category: filters.category, + page: 0, + size: values.size ?? filters.size ?? 15 }) + } + + const reset = () => { + form.resetFields() + form.setFieldsValue({ size: 15 }) + setFilters({ ...initial, category: pathCategory ?? 'ALL' }) + } + + const columns = [ + { title: '분류', dataIndex: 'category', width: 100 }, + { title: '상품명', dataIndex: 'name', width: 180, ellipsis: true, sorter: true }, + { title: '통신사', dataIndex: 'telecom', width: 70 }, + { title: '요금제/모델', dataIndex: 'plan', width: 140, ellipsis: true }, + { title: '월정액', dataIndex: 'monthlyFee', width: 100, align: 'right' as const, render: money }, + { title: '판매가', dataIndex: 'price', width: 100, align: 'right' as const, render: money }, + { title: '상태', dataIndex: 'status', width: 80 }, + { + title: '관리', width: 58, fixed: 'right' as const, + render: (_: unknown, row: Product) => ( + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+ ) +} diff --git a/frontend/src/pages/care/ReceptionListPage.tsx b/frontend/src/pages/care/ReceptionListPage.tsx new file mode 100644 index 0000000..7ab99f8 --- /dev/null +++ b/frontend/src/pages/care/ReceptionListPage.tsx @@ -0,0 +1,830 @@ +import { useMemo, useState } from 'react' +import Modal from '../../components/DraggableModal' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import dayjs from 'dayjs' +import { + Button, + Checkbox, + DatePicker, + Dropdown, + Form, + Input, + Select, + message, +} from 'antd' +import ReceptionApplyModal from '../../components/ReceptionApplyModal' +import ReceptionBulkModal from '../../components/ReceptionBulkModal' +import ReceptionProgressModal from '../../components/ReceptionProgressModal' +import { DownOutlined } from '@ant-design/icons' +import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client' +import { options } from './homesShared' + +type ReceptionRow = { + id: number + orderDate?: string + today?: boolean + counselor?: string + employeeName?: string + branch?: string + branchOfficeName?: string + branchGroup?: string + branchGroupName?: string + carrier?: string + ispName?: string + giftCategory?: string + giftType?: string + productCategory?: string + productType?: string + productName?: string + customerName?: string + phone?: string + region?: string + sido?: string + progressStatus?: string + status?: string + settleStatus?: string + centerStatus?: string + centerName?: string + openDate?: string + opened?: string + cancelDate?: string + canceled?: string + giftDate?: string + giftSended?: string + giftStatus?: string + giftPlace?: string + giftAmount?: number + agreementName?: string + contractNumber?: string + identifyNumber?: string + postIt?: string + postAdd?: string + amount?: number +} + +type ReceptionResult = { + total: number + list: ReceptionRow[] + progressStatuses?: string[] + settleStatuses?: string[] + giftStatuses?: string[] + carriers?: string[] + branches?: string[] + counselors?: string[] + processors?: string[] + regions?: string[] + giftCategories?: string[] + productCategories?: string[] +} + +const SIDO_LIST = [ + '서울', '경기', '인천', '강원', '충북', '충남', '대전', '전북', '전남', '광주', + '경북', '대구', '경남', '울산', '부산', '제주', +] + +const GIFT_PLACE_LABEL: Record = { + b01: '본사요청', + b02: '자체발송', + 본사: '본사발송', + 영업점: '영업점발송', +} + +const initialFilters = () => ({ + page: 0, + size: 50, + orderDateStart: dayjs().startOf('month').format('YYYY-MM-DD'), + orderDateEnd: dayjs().format('YYYY-MM-DD'), + sorting: 'order_date', + postIt: '', + agreementName: '', + customerSsid: '', + customerEmail: '', + customerName: '', + customerPhone: '', + contractNumber: '', + productName: '', + ispName: '', + branchEmployee: '', + branchName: '', + giftType: undefined as string | undefined, + productType: undefined as string | undefined, + progressStatus: undefined as string | undefined, + giftStatus: undefined as string | undefined, + giftPlace: undefined as string | undefined, + customerSido: undefined as string | undefined, + carrier: undefined as string | undefined, + branch: undefined as string | undefined, + settleStatus: undefined as string | undefined, + opended: undefined as string | undefined, + giftSended: undefined as string | undefined, +}) + +const num = (n?: number | null) => (n == null ? '' : Number(n).toLocaleString()) + +const giftPlaceLabel = (v?: string) => { + if (!v) return '' + return GIFT_PLACE_LABEL[v] || v +} + +const splitDt = (v?: string) => { + if (!v) return ['', ''] + const s = String(v).replace('T', ' ') + const [d, t] = s.split(' ') + return [d || '', (t || '').substring(0, 8)] +} + +export default function ReceptionListPage() { + const [editForm] = Form.useForm() + const qc = useQueryClient() + const [draft, setDraft] = useState(initialFilters) + const [filters, setFilters] = useState(initialFilters) + const [selected, setSelected] = useState([]) + const [detail, setDetail] = useState(null) + const [creating, setCreating] = useState(false) + const [bulkOpen, setBulkOpen] = useState(false) + const [progressId, setProgressId] = useState(null) + const [applyIsp, setApplyIsp] = useState<{ id: number; name: string; telecom: string; isSktb?: boolean } | null>(null) + + const ispQuery = useQuery({ + queryKey: ['homes-reception-isps'], + queryFn: async () => { + const { data } = await client.get>( + `${apiPrefix()}/homes/reception-isps`, + ) + return unwrap(data) + }, + }) + + const query = useQuery({ + queryKey: ['homes-receptions', filters], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/homes/receptions`, { + params: { + page: filters.page, + size: filters.size, + orderDateStart: filters.orderDateStart || undefined, + orderDateEnd: filters.orderDateEnd || undefined, + sorting: filters.sorting, + postIt: filters.postIt || undefined, + agreementName: filters.agreementName || undefined, + customerSsid: filters.customerSsid || undefined, + customerEmail: filters.customerEmail || undefined, + customerName: filters.customerName || undefined, + customerPhone: filters.customerPhone || undefined, + contractNumber: filters.contractNumber || undefined, + productName: filters.productName || undefined, + ispName: filters.ispName || undefined, + branchEmployee: filters.branchEmployee || undefined, + branchName: filters.branchName || undefined, + giftType: filters.giftType || undefined, + productType: filters.productType || undefined, + progressStatus: filters.progressStatus || undefined, + giftStatus: filters.giftStatus || undefined, + giftPlace: filters.giftPlace || undefined, + customerSido: filters.customerSido || undefined, + carrier: filters.carrier || undefined, + branch: filters.branch || undefined, + settleStatus: filters.settleStatus || undefined, + opended: filters.opended || undefined, + giftSended: filters.giftSended || undefined, + }, + }) + return unwrap(data) + }, + }) + + const refresh = () => { + qc.invalidateQueries({ queryKey: ['homes-receptions'] }) + setSelected([]) + } + + const progressStatuses = query.data?.progressStatuses || [] + const settleStatuses = query.data?.settleStatuses || [] + const giftStatuses = query.data?.giftStatuses || [] + const list = query.data?.list || [] + + const setD = (patch: Partial>) => setDraft((p) => ({ ...p, ...patch })) + const applyF = (patch: Partial>) => { + const next = { ...draft, ...patch, page: 0 } + setDraft(next) + setFilters(next) + } + + const search = () => setFilters({ ...draft, page: 0 }) + const reset = () => { + const next = initialFilters() + setDraft(next) + setFilters(next) + setSelected([]) + } + + const quick = (key: string) => { + const today = dayjs() + let start = today + let end = today + switch (key) { + case 'today': + break + case 'yesterday': + start = today.subtract(1, 'day') + end = start + break + case 'thisWeek': + start = today.startOf('week') + break + case 'lastWeek': + start = today.subtract(1, 'week').startOf('week') + end = today.subtract(1, 'week').endOf('week') + break + case 'thisMonth': + start = today.startOf('month') + break + case 'lastMonth': + start = today.subtract(1, 'month').startOf('month') + end = today.subtract(1, 'month').endOf('month') + break + case 'thisYear': + start = today.startOf('year') + break + case 'lastYear': + start = today.subtract(1, 'year').startOf('year') + end = today.subtract(1, 'year').endOf('year') + break + default: + break + } + applyF({ + orderDateStart: start.format('YYYY-MM-DD'), + orderDateEnd: end.format('YYYY-MM-DD'), + }) + } + + const saveMut = useMutation({ + mutationFn: async (payload: Record) => { + if (detail?.id && !creating) { + const { data } = await client.put(`${apiPrefix()}/homes/receptions/${detail.id}`, payload) + return unwrap(data) + } + const { data } = await client.post(`${apiPrefix()}/homes/receptions`, payload) + return unwrap(data) + }, + onSuccess: () => { + message.success(creating ? '등록했습니다.' : '수정했습니다.') + setCreating(false) + setDetail(null) + refresh() + }, + onError: () => message.error('저장에 실패했습니다.'), + }) + + const deleteMut = useMutation({ + mutationFn: async (ids: number[]) => { + const { data } = await client.post(`${apiPrefix()}/homes/receptions/delete`, { ids }) + return unwrap(data) + }, + onSuccess: () => { + message.success('삭제했습니다.') + refresh() + }, + onError: () => message.error('삭제에 실패했습니다.'), + }) + + const openEditFromProgress = async (id: number) => { + try { + const { data } = await client.get>(`${apiPrefix()}/homes/receptions/${id}`) + const full = unwrap(data) as ReceptionRow + setProgressId(null) + setCreating(false) + setDetail(full) + editForm.setFieldsValue({ + ...full, + orderDate: full.orderDate ? dayjs(full.orderDate) : undefined, + openDate: full.openDate || full.opened ? dayjs(full.openDate || full.opened) : undefined, + giftDate: full.giftDate ? dayjs(full.giftDate) : undefined, + }) + } catch { + message.error('상세 조회에 실패했습니다.') + } + } + + const downloadExcel = async () => { + try { + const { data } = await client.get(`${apiPrefix()}/homes/receptions/excel`, { + params: { ...filters, page: undefined, size: undefined }, + 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 allChecked = list.length > 0 && selected.length === list.length + const toggleAll = (checked: boolean) => setSelected(checked ? list.map((r) => r.id) : []) + + const productTypeLabel = (row: ReceptionRow) => { + const t = row.productType + if (t === 'e01') return '인터넷' + if (t === 'e02') return '전화' + if (t === 'e03') return 'TV' + if (t === 'e04') return '렌탈' + return row.productCategory || '-' + } + + const rows = useMemo(() => list, [list]) + + return ( +
+
+
+

접수관리 - 접수리스트

+

접수 내역을 조회하고 업무 진행을 관리합니다.

+
+
+ ( +
+
신규신청
+
    + {(ispQuery.data || []).map((isp) => ( +
  • + +
  • + ))} + {!ispQuery.data?.length && !ispQuery.isLoading ? ( +
  • 등록된 통신사가 없습니다.
  • + ) : null} +
+
+ )} + > + +
+ + +
+
+ +
+ + + + +
+ +
+
+ setD({ orderDateStart: d ? d.format('YYYY-MM-DD') : '' })} + /> + ~ + setD({ orderDateEnd: d ? d.format('YYYY-MM-DD') : '' })} + /> +
+ {(['today', 'yesterday', 'thisWeek', 'lastWeek', 'thisMonth', 'lastMonth', 'thisYear', 'lastYear'] as const).map((k) => ( + + ))} +
+ + 검색 결과: 총 {(query.data?.total ?? 0).toLocaleString()} 건 +
+ +
+ setD({ postIt: e.target.value })} /> + setD({ agreementName: e.target.value })} /> + setD({ customerSsid: e.target.value })} /> + setD({ customerEmail: e.target.value })} /> + + setD({ branchEmployee: e.target.value })} /> + + setD({ branchName: e.target.value })} /> + setD({ ispName: e.target.value })} /> + setD({ productName: e.target.value })} /> +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {query.isLoading && ( + + )} + {!query.isLoading && rows.length === 0 && ( + + )} + {rows.map((o, idx) => { + const rowNum = Number(filters.page) * Number(filters.size) + idx + 1 + const [od, ot] = splitDt(o.orderDate) + const giftAmt = o.giftAmount ?? null + return ( + setProgressId(o.id)}> + + + + + + + + + + + + + + + + + + ) + })} + +
+ toggleAll(e.target.checked)} /> + 접수번호접수일입력처그룹
영업점
통신사사은품종류
상품종류
상품명고객명
(포스트잇)
약정
지역
주민번호
연락처
담당자명
진행현황
개통번호
개통일자
지급처
지급날짜
사은품현황
금액
입력처
진행현황
   + + + + + +   + setD({ customerName: e.target.value })} + onKeyDown={(e) => e.key === 'Enter' && search()} + /> + + + setD({ customerPhone: e.target.value })} + onKeyDown={(e) => e.key === 'Enter' && search()} + /> + + + setD({ contractNumber: e.target.value })} + onKeyDown={(e) => e.key === 'Enter' && search()} + /> + + +  
불러오는 중...
검색 결과가 없습니다.
e.stopPropagation()}> + setSelected((prev) => (e.target.checked ? [...prev, o.id] : prev.filter((id) => id !== o.id)))} + /> + +

{o.id.toLocaleString()}

+

({rowNum})

+
+

{od}

+

{ot}

+
+

{o.centerName || '본사'}

+
+

{o.branchGroupName || o.branchGroup || ''}

+

{o.branchOfficeName || o.branch || ''}

+
{o.ispName || o.carrier || '-'} +

{o.giftType || o.giftCategory || '-'}

+

{productTypeLabel(o)}

+
{o.productName || '-'} +

{o.customerName || '-'}

+ {o.postIt ?

({o.postIt})

: null} + {o.postAdd ?

({o.postAdd})

: null} +
+

{o.agreementName || '-'}

+

{o.sido || o.region || ''}

+
+

{o.identifyNumber || ''}

+

{o.phone || ''}

+
+

{o.employeeName || o.counselor || ''}

+

{o.status || o.progressStatus || ''}

+
+

개통일: {o.opened || o.openDate || '-'}

+

해지일: {o.canceled || '-'}

+

정산상태: {o.settleStatus || o.centerStatus || '-'}

+
+
+

{o.contractNumber || ''}

+

{o.opened || o.openDate || ''}

+
+

{giftPlaceLabel(o.giftPlace)}

+

{o.giftSended || o.giftDate || ''}

+
+

{o.giftStatus || ''}

+

{num(giftAmt)}

+
+ {o.centerStatus || o.settleStatus || ''} +
+

정산상태: {o.settleStatus || '-'}

+

개통일: {o.opened || o.openDate || '-'}

+

해지일: {o.canceled || o.cancelDate || '-'}

+
+
+
+ +
+ + + {Number(filters.page) + 1} / {Math.max(1, Math.ceil((query.data?.total || 0) / Number(filters.size || 50)))} + + +
+ + { setCreating(false); setDetail(null) }} + footer={null} + destroyOnHidden + width={720} + > +
{ + saveMut.mutate({ + ...values, + orderDate: values.orderDate ? dayjs(values.orderDate).format('YYYY-MM-DD HH:mm:ss') : undefined, + openDate: values.openDate ? dayjs(values.openDate).format('YYYY-MM-DD') : undefined, + giftDate: values.giftDate ? dayjs(values.giftDate).format('YYYY-MM-DD') : undefined, + }) + }} + > +
+ + + + + + + + + + + + + + + + + + + + )} + + + + + ({ value: v, label: v }))} /> + + + + + + + + + +
+ + + + { + if (row._summary) return 'open-stat-summary' + if (mode === 'daily' && query.data?.today != null && row.key === query.data.today) return 'open-stat-today' + if (mode === 'monthly' && query.data?.currentMonth != null && row.key === query.data.currentMonth) return 'open-stat-today' + return '' + }} + locale={{ emptyText: '등록되어 있는 자료가 없습니다.' }} + /> + +
+ ) +} diff --git a/frontend/src/pages/care/ReportOutPage.tsx b/frontend/src/pages/care/ReportOutPage.tsx new file mode 100644 index 0000000..5998a21 --- /dev/null +++ b/frontend/src/pages/care/ReportOutPage.tsx @@ -0,0 +1,215 @@ +import { useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import dayjs from 'dayjs' +import { Button, Card, Form, Select, Space, message } from 'antd' +import CareTable from '../../components/CareTable' +import { BarChartOutlined, FileTextOutlined, SearchOutlined } from '@ant-design/icons' +import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client' +import { resourceApi } from '../../api/resources' + +type OutStatRow = { + id: number + region: string + name: string + openCount: number + openAmount: number + afterCount: number + afterAmount: number + homeCount: number + homeAmount: number + realSettleAmount: number + margin: number + _summary?: boolean +} + +type OutStatData = { + year: number + month: number + title: string + total: number + rows: OutStatRow[] + totals: OutStatRow +} + +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 money = (v: unknown) => (num(v) === 0 ? '' : num(v).toLocaleString()) + +export default function ReportOutPage() { + const now = useMemo(() => dayjs(), []) + const [form] = Form.useForm() + const [filters, setFilters] = useState>({ + year: now.year(), + month: now.month() + 1 }) + + const companies = useQuery({ + queryKey: ['out-stats-companies'], + queryFn: () => resourceApi.list('companies', { size: 200 }) }) + + const query = useQuery({ + queryKey: ['outcompany-stats', filters], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/reports/outcompany-stats`, { + params: filters }) + return unwrap(data) + } }) + + const inOptions = useMemo( + () => (companies.data?.items ?? []) + .filter((c) => String(c.type) === 'IN') + .map((c) => ({ value: Number(c.id), label: String(c.name) })), + [companies.data], + ) + + const yearOptions = useMemo(() => { + const years: number[] = [] + for (let y = now.year(); y >= now.year() - 5; y--) years.push(y) + return years.map((y) => ({ value: y, label: `${y}년` })) + }, [now]) + + const monthOptions = useMemo( + () => Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: `${i + 1}월` })), + [], + ) + + const shiftMonth = (delta: number) => { + const year = Number(form.getFieldValue('year') ?? now.year()) + const month = Number(form.getFieldValue('month') ?? now.month() + 1) + const next = dayjs(`${year}-${String(month).padStart(2, '0')}-01`).add(delta, 'month') + form.setFieldsValue({ year: next.year(), month: next.month() + 1 }) + } + + const onSearch = (values: Record) => { + setFilters({ + year: values.year, + month: values.month, + telecom: values.telecom, + incompanyId: values.incompanyId, + outCategory: values.outCategory }) + } + + const onReset = () => { + const init = { year: now.year(), month: now.month() + 1, telecom: undefined, incompanyId: undefined, outCategory: undefined } + form.setFieldsValue(init) + setFilters({ year: init.year, month: init.month }) + } + + const download = async () => { + try { + const { data } = await client.get(`${apiPrefix()}/reports/outcompany-stats/export`, { + params: filters, + responseType: 'blob' }) + const url = URL.createObjectURL(data) + const a = document.createElement('a') + a.href = url + a.download = `출고처통계_${filters.year}-${filters.month}.csv` + a.click() + URL.revokeObjectURL(url) + } catch { + message.error('엑셀 다운로드에 실패했습니다.') + } + } + + const dataSource = useMemo(() => { + const list = query.data?.rows ?? [] + if (!list.length || !query.data?.totals) return list + return [ + ...list, + { + ...query.data.totals, + id: -1, + region: '합계', + name: '', + _summary: true }, + ] + }, [query.data]) + + const dateTitle = `${filters.year}년 ${filters.month}월` + + const columns = [ + { + title: dateTitle, + dataIndex: 'region', + width: 90, + align: 'center' as const, + render: (v: string, row: OutStatRow) => (row._summary ? {v} : v), + onCell: (row: OutStatRow) => (row._summary ? { colSpan: 2 } : {}) }, + { + title: '', + dataIndex: 'name', + width: 140, + align: 'center' as const, + onCell: (row: OutStatRow) => (row._summary ? { colSpan: 0 } : {}), + render: (v: string) => v }, + { title: '개통', dataIndex: 'openCount', width: 80, align: 'center' as const, render: cell }, + { title: '개통금액', dataIndex: 'openAmount', width: 110, align: 'center' as const, render: money }, + { title: '후정산', dataIndex: 'afterCount', width: 80, align: 'center' as const, render: cell }, + { title: '후정산금액', dataIndex: 'afterAmount', width: 110, align: 'center' as const, render: money }, + { title: '홈상품', dataIndex: 'homeCount', width: 80, align: 'center' as const, render: cell }, + { title: '홈상품금액', dataIndex: 'homeAmount', width: 110, align: 'center' as const, render: money }, + { title: '실정산금액', dataIndex: 'realSettleAmount', width: 110, align: 'center' as const, render: money }, + { title: '마진', dataIndex: 'margin', width: 110, align: 'center' as const, render: money }, + ] + + return ( +
+
+
+

출고처통계

+

출고처별로 정산에 대한 통계를 확인하실 수 있습니다.

+
+
+ + +
+
+ + + + + + + + + ({ value: v, label: v }))} /> + + + ({ value: v, label: v }))} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + ({ value: v, label: v }))} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ({ value: v, label: v }))} disabled={!!editing?.isOwner} /> + + + + +
+ + +
+
+ + + { + setMemoOpen(false) + setMemoTarget(null) + }} + footer={null} + width={420} + destroyOnHidden + centered + > +
memoMut.mutate(String(v.memo || ''))}> + + + +
+ + +
+
+
+ + { + setExpoOpen(false) + setExpoTarget(null) + }} + footer={null} + width={480} + destroyOnHidden + centered + > +

메뉴 노출 여부를 설정합니다. 체크된 메뉴만 해당 직원에게 표시됩니다.

+
expoMut.mutate(values as Record)} + > +
+ {MENU_EXPOSURE.map((m) => ( + + {m.label} + + ))} +
+
+ + +
+ +
+
+ ) +} diff --git a/frontend/src/pages/care/SettingPartner1Page.tsx b/frontend/src/pages/care/SettingPartner1Page.tsx new file mode 100644 index 0000000..f9bf73e --- /dev/null +++ b/frontend/src/pages/care/SettingPartner1Page.tsx @@ -0,0 +1,301 @@ +import { useState } from 'react' +import Modal from '../../components/DraggableModal' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + Button, + Card, + Form, + Input, + Space, + Typography, + message } from 'antd' +import CareTable from '../../components/CareTable' +import { + EditOutlined, + PlusSquareOutlined, + SettingOutlined } from '@ant-design/icons' +import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client' + +type PartnerRow = { + id: number + status?: string + name?: string + userid?: string + managerName?: string + phone?: string + registeredDate?: string + memo?: string + partnerLevel?: string +} + +type PartnerResult = { + total: number + list: PartnerRow[] +} + +/** 원본 안내 문구용 잎 아이콘 */ +function LeafOutlined({ className }: { className?: string }) { + return ( + + + + + + ) +} + +export default function SettingPartner1Page() { + const qc = useQueryClient() + const [editForm] = Form.useForm() + const [filters, setFilters] = useState({ page: 0, size: 15, partnerLevel: '1' }) + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const query = useQuery({ + queryKey: ['homes-partners-1', filters], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/homes/partners`, { + params: filters }) + return unwrap(data) + } }) + + const refresh = () => { + qc.invalidateQueries({ queryKey: ['homes-partners-1'] }) + } + + const saveMut = useMutation({ + mutationFn: async (values: Record) => { + const payload = { + partnerLevel: '1', + name: values.name, + userid: values.userid, + managerName: values.managerName, + phone: values.phone, + memo: values.memo, + status: values.status || editing?.status || '대기' } + if (editing?.id) { + const { data } = await client.put(`${apiPrefix()}/homes/partners/${editing.id}`, payload) + return unwrap(data as ApiResponse) + } + const { data } = await client.post(`${apiPrefix()}/homes/partners`, payload) + return unwrap(data as ApiResponse) + }, + onSuccess: () => { + message.success(editing ? '수정했습니다.' : '상부점 등록을 신청했습니다.') + setOpen(false) + setEditing(null) + editForm.resetFields() + refresh() + }, + onError: (e: Error) => message.error(e.message) }) + + const deleteMut = useMutation({ + mutationFn: async (id: number) => { + const { data } = await client.delete(`${apiPrefix()}/homes/partners/${id}`) + return unwrap(data as ApiResponse) + }, + onSuccess: () => { + message.success('삭제했습니다.') + refresh() + }, + onError: (e: Error) => message.error(e.message) }) + + const openCreate = () => { + setEditing(null) + editForm.setFieldsValue({ + name: undefined, + userid: undefined, + managerName: undefined, + phone: undefined, + memo: undefined, + status: '대기' }) + setOpen(true) + } + + const openEdit = (row: PartnerRow) => { + setEditing(row) + editForm.setFieldsValue({ + name: row.name, + userid: row.userid, + managerName: row.managerName, + phone: row.phone, + memo: row.memo, + status: row.status || '대기' }) + setOpen(true) + } + + const columns = [ + { + title: '상태', + dataIndex: 'status', + width: 90, + align: 'center' as const, + render: (v?: string) => { + const status = v || '대기' + const cls = + status === '승인' ? 'partner-status-ok' : status === '거절' ? 'partner-status-deny' : 'partner-status-wait' + return {status} + } }, + { + title: '업체명', + dataIndex: 'name', + width: 180, + ellipsis: true, + render: (v?: string) => v || '' }, + { + title: '아이디', + dataIndex: 'userid', + width: 120, + align: 'center' as const, + render: (v?: string) => v || '' }, + { + title: '담당자', + dataIndex: 'managerName', + width: 100, + align: 'center' as const, + render: (v?: string) => v || '' }, + { + title: '연락처', + dataIndex: 'phone', + width: 130, + align: 'center' as const, + render: (v?: string) => v || '' }, + { + title: '등록일', + dataIndex: 'registeredDate', + width: 120, + align: 'center' as const }, + { + title: '관리', + key: 'manage', + width: 64, + align: 'center' as const, + render: (_: unknown, row: PartnerRow) => ( + +
+ + +
+ + 목록 {query.data?.total ?? 0} + +
+ setFilters((prev) => ({ ...prev, page: page - 1 })) }} + locale={{ emptyText: '등록되어 있는 상부점이 없습니다.' }} + /> +
+ + + 상부점에서 승인처리 후 계약자료 전송이 가능합니다. (또한 상부점에서 등록신청을 삭제할 경우 목록에서 제외됩니다.) + +
+
+ + { + setOpen(false) + setEditing(null) + }} + footer={null} + width={480} + destroyOnHidden + centered + > +
saveMut.mutate(values)} + > + + + + + + + + + + + + + + + +
+ + + {editing ? ( + + ) : null} + + +
+
+
+ + ) +} diff --git a/frontend/src/pages/care/SettingPartner2Page.tsx b/frontend/src/pages/care/SettingPartner2Page.tsx new file mode 100644 index 0000000..5b7cead --- /dev/null +++ b/frontend/src/pages/care/SettingPartner2Page.tsx @@ -0,0 +1,280 @@ +import { useState } from 'react' +import Modal from '../../components/DraggableModal' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + Button, + Card, + Form, + Input, + Radio, + Space, + Typography, + message } from 'antd' +import CareTable from '../../components/CareTable' +import { + EditOutlined, + SettingOutlined } from '@ant-design/icons' +import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client' + +type PartnerRow = { + id: number + status?: string + name?: string + userid?: string + managerName?: string + phone?: string + registeredDate?: string + memo?: string + partnerLevel?: string +} + +type PartnerResult = { + total: number + list: PartnerRow[] +} + +export default function SettingPartner2Page() { + const qc = useQueryClient() + const [editForm] = Form.useForm() + const [filters, setFilters] = useState({ page: 0, size: 15, partnerLevel: '2' }) + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const query = useQuery({ + queryKey: ['homes-partners-2', filters], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/homes/partners`, { + params: filters }) + return unwrap(data) + } }) + + const refresh = () => { + qc.invalidateQueries({ queryKey: ['homes-partners-2'] }) + } + + const saveMut = useMutation({ + mutationFn: async (values: Record) => { + if (!editing?.id) throw new Error('하부점을 선택하세요.') + const payload = { + partnerLevel: '2', + name: values.name ?? editing.name, + userid: values.userid ?? editing.userid, + managerName: values.managerName ?? editing.managerName, + phone: values.phone ?? editing.phone, + memo: values.memo, + status: values.status || editing.status || '대기' } + const { data } = await client.put(`${apiPrefix()}/homes/partners/${editing.id}`, payload) + return unwrap(data as ApiResponse) + }, + onSuccess: () => { + message.success('적용했습니다.') + setOpen(false) + setEditing(null) + editForm.resetFields() + refresh() + }, + onError: (e: Error) => message.error(e.message) }) + + const deleteMut = useMutation({ + mutationFn: async (id: number) => { + const { data } = await client.delete(`${apiPrefix()}/homes/partners/${id}`) + return unwrap(data as ApiResponse) + }, + onSuccess: () => { + message.success('삭제했습니다.') + setOpen(false) + setEditing(null) + refresh() + }, + onError: (e: Error) => message.error(e.message) }) + + const openEdit = (row: PartnerRow) => { + setEditing(row) + editForm.setFieldsValue({ + name: row.name, + userid: row.userid, + managerName: row.managerName, + phone: row.phone, + memo: row.memo, + status: row.status || '대기' }) + setOpen(true) + } + + const columns = [ + { + title: '상태', + dataIndex: 'status', + width: 90, + align: 'center' as const, + render: (v?: string) => { + const status = v || '대기' + const cls = + status === '승인' + ? 'partner-status-ok' + : status === '보류' + ? 'partner-status-hold' + : status === '거절' + ? 'partner-status-deny' + : 'partner-status-wait' + return {status} + } }, + { + title: '업체명', + dataIndex: 'name', + width: 180, + ellipsis: true, + render: (v?: string) => v || '' }, + { + title: '아이디', + dataIndex: 'userid', + width: 120, + align: 'center' as const, + render: (v?: string) => v || '' }, + { + title: '담당자', + dataIndex: 'managerName', + width: 100, + align: 'center' as const, + render: (v?: string) => v || '' }, + { + title: '연락처', + dataIndex: 'phone', + width: 130, + align: 'center' as const, + render: (v?: string) => v || '' }, + { + title: '등록일', + dataIndex: 'registeredDate', + width: 120, + align: 'center' as const }, + { + title: '관리', + key: 'manage', + width: 64, + align: 'center' as const, + render: (_: unknown, row: PartnerRow) => ( + + + {editing ? ( + + ) : null} + + + + + + + ) +} diff --git a/frontend/src/pages/care/SettingPreferencePage.tsx b/frontend/src/pages/care/SettingPreferencePage.tsx new file mode 100644 index 0000000..e8dd30a --- /dev/null +++ b/frontend/src/pages/care/SettingPreferencePage.tsx @@ -0,0 +1,501 @@ +import { useEffect, useRef, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + Button, + Card, + Form, + Input, + InputNumber, + Radio, + Space, + Tabs, + Typography, + message } from 'antd' +import CareTable from '../../components/CareTable' +import { PlusOutlined, SettingOutlined } from '@ant-design/icons' +import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client' + +type ManagerInfo = { name?: string; phone?: string } +type LoginBlock = { mode?: 'none' | 'block_staff' } +type AccessIps = { enabled?: boolean; ips?: string[] } +type ContractSetting = { + autoRegisterCustomer?: boolean + defaultMonthsInternet?: number + defaultMonthsHome?: number + requireAgency?: boolean + autoCalcSettle?: boolean + allowWithdraw?: boolean +} + +type HomesPreference = { + manager?: ManagerInfo + loginBlock?: LoginBlock + accessIps?: AccessIps + contract?: ContractSetting + incentiveRate?: number + autoInvoice?: boolean +} + +const TAB_KEYS = [ + { key: 'manager', label: '담당자정보 설정' }, + { key: 'loginBlock', label: '로그인차단 설정' }, + { key: 'accessIp', label: '접속아이피 설정' }, + { key: 'contract', label: '계약 설정' }, +] as const + +type TabKey = (typeof TAB_KEYS)[number]['key'] + +const splitPhone = (phone?: string) => { + const digits = String(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) } + } + const parts = String(phone || '').split('-') + return { p1: parts[0] || '010', p2: parts[1] || '', p3: parts[2] || '' } +} + +function SectionHead({ title, desc }: { title: string; desc: string }) { + return ( +
+

{title}

+

{desc}

+
+ ) +} + +export default function SettingPreferencePage() { + const qc = useQueryClient() + const [tab, setTab] = useState('manager') + const [managerForm] = Form.useForm() + const [loginForm] = Form.useForm() + const [contractForm] = Form.useForm() + const [ipInput, setIpInput] = useState('') + const [localIps, setLocalIps] = useState([]) + const [ipEnabled, setIpEnabled] = useState(false) + const sectionRefs = useRef>({ + manager: null, + loginBlock: null, + accessIp: null, + contract: null }) + + const query = useQuery({ + queryKey: ['homes-preference'], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/homes/preference`) + return unwrap(data) + } }) + + useEffect(() => { + if (!query.data) return + const phone = splitPhone(query.data.manager?.phone) + managerForm.setFieldsValue({ + name: query.data.manager?.name || '', + phone1: phone.p1 || '010', + phone2: phone.p2, + phone3: phone.p3 }) + loginForm.setFieldsValue({ + mode: query.data.loginBlock?.mode || 'none' }) + setLocalIps(query.data.accessIps?.ips || []) + setIpEnabled(!!query.data.accessIps?.enabled) + contractForm.setFieldsValue({ + autoRegisterCustomer: query.data.contract?.autoRegisterCustomer ?? true, + defaultMonthsInternet: query.data.contract?.defaultMonthsInternet ?? 36, + defaultMonthsHome: query.data.contract?.defaultMonthsHome ?? 60, + requireAgency: query.data.contract?.requireAgency ?? false, + autoCalcSettle: query.data.contract?.autoCalcSettle ?? true, + allowWithdraw: query.data.contract?.allowWithdraw ?? true, + incentiveRate: query.data.incentiveRate ?? 5, + autoInvoice: query.data.autoInvoice ?? true }) + }, [query.data, managerForm, loginForm, contractForm]) + + const save = useMutation({ + mutationFn: async (patch: HomesPreference) => { + const { data } = await client.put>(`${apiPrefix()}/homes/preference`, patch) + return unwrap(data) + }, + onSuccess: (data) => { + qc.setQueryData(['homes-preference'], data) + message.success('적용했습니다.') + }, + onError: (e: Error) => message.error(e.message) }) + + const onTabChange = (key: string) => { + const k = key as TabKey + setTab(k) + sectionRefs.current[k]?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + + const saveManager = async () => { + const v = await managerForm.validateFields() + const phone = [v.phone1, v.phone2, v.phone3].filter(Boolean).join('-') + save.mutate({ manager: { name: v.name, phone } }) + } + + const saveLoginBlock = async () => { + const v = await loginForm.validateFields() + save.mutate({ loginBlock: { mode: v.mode } }) + } + + const saveAccessIps = () => { + save.mutate({ accessIps: { enabled: ipEnabled, ips: localIps } }) + } + + const saveContract = async () => { + const v = await contractForm.validateFields() + save.mutate({ + contract: { + autoRegisterCustomer: v.autoRegisterCustomer, + defaultMonthsInternet: v.defaultMonthsInternet, + defaultMonthsHome: v.defaultMonthsHome, + requireAgency: v.requireAgency, + autoCalcSettle: v.autoCalcSettle, + allowWithdraw: v.allowWithdraw }, + incentiveRate: v.incentiveRate, + autoInvoice: v.autoInvoice }) + } + + const addIp = () => { + const ip = ipInput.trim() + if (!ip) { + message.warning('아이피를 입력하세요.') + return + } + if (localIps.includes(ip)) { + message.warning('이미 등록된 아이피입니다.') + return + } + setLocalIps((prev) => [...prev, ip]) + setIpInput('') + } + + return ( +
+
+
+

+ + 기본설정 +

+

매니저 이용에 필요한 기본적인 설정을 하실 수 있습니다.

+
+
+ + + ({ key: t.key, label: t.label }))} + /> + +
{ sectionRefs.current.manager = el }} + className="pref-section" + id="pref-manager" + > + +
+ + + + + + + + + + + + + + + + + +
구분내용
담당자명 + + + +
연락처 + + + + + + + + + + + +
+
+ +
+
+
+ +
{ sectionRefs.current.loginBlock = el }} + className="pref-section" + id="pref-loginBlock" + > + +
+ + + + + + + + + + + + + +
구분내용
로그인 차단권한 + + + +
+
+ +
+
+
+ +
{ sectionRefs.current.accessIp = el }} + className="pref-section" + id="pref-accessIp" + > + + + + + + + + + + + + + + + + + + +
구분내용
접속제한 + setIpEnabled(e.target.value)} + options={[ + { value: false, label: '미사용' }, + { value: true, label: '사용' }, + ]} + /> +
아이피 등록 + + setIpInput(e.target.value)} + onPressEnter={addIp} + /> + + +
+ ({ ip }))} + columns={[ + { + title: 'No', + width: 60, + align: 'center' as const, + render: (_: unknown, __: { ip: string }, i: number) => i + 1 }, + { title: '아이피', dataIndex: 'ip' }, + { + title: '관리', + width: 90, + align: 'center' as const, + render: (_: unknown, row: { ip: string }) => ( + + ) }, + ]} + locale={{ emptyText: '등록된 아이피가 없습니다.' }} + /> +
+ +
+
+ +
{ sectionRefs.current.contract = el }} + className="pref-section" + id="pref-contract" + > + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
구분내용
고객 자동등록 + + + +
기본 약정개월(인터넷) + + + + + 개월 + +
기본 약정개월(홈렌탈) + + + + + 개월 + +
거래처 필수 + + + +
정산금액 자동계산 + + + +
철회 허용 + + + +
인센티브율 + + + + + % + +
월말 자동정산서 + + + +
+
+ +
+
+
+
+
+ ) +} diff --git a/frontend/src/pages/care/SitemapPage.tsx b/frontend/src/pages/care/SitemapPage.tsx new file mode 100644 index 0000000..12ed120 --- /dev/null +++ b/frontend/src/pages/care/SitemapPage.tsx @@ -0,0 +1,263 @@ +import { useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { Card } from 'antd' +import { AppstoreOutlined } from '@ant-design/icons' +import { SITEMAP_TITLE_TO_ID } from '../../menu' +import { fetchMyMenus } from '../../api/auth' + +type SitemapChild = { + label: string + to?: string + children?: { label: string; to: string }[] +} + +type SitemapSection = { + letter: string + title: string + to?: string + items?: SitemapChild[] +} + +/** 비즈케어홈즈 원본 전체보기(사이트맵) 구성 */ +const SITEMAP_COLUMNS: SitemapSection[][] = [ + [ + { letter: 'A', title: '대시보드', to: '/care/main' }, + { + letter: 'B', + title: '상품관리', + items: [ + { + label: '인터넷상품', + to: '/care/product/internet', + children: [ + { label: '인터넷', to: '/care/product/internet?category=인터넷' }, + { label: 'TV', to: '/care/product/internet?category=TV' }, + { label: '전화', to: '/care/product/internet?category=전화' }, + { label: '와이파이', to: '/care/product/internet?category=와이파이' }, + { label: '스마트홈', to: '/care/product/internet?category=스마트홈' }, + { label: '기타', to: '/care/product/internet?category=기타' }, + ] }, + { + label: '홈렌탈상품', + to: '/care/product/home', + children: [ + { label: '정수기', to: '/care/product/home?category=정수기' }, + { label: '공기청정기', to: '/care/product/home?category=공기청정기' }, + { label: '비데', to: '/care/product/home?category=비데' }, + { label: '매트리스', to: '/care/product/home?category=매트리스' }, + { label: '가전', to: '/care/product/home?category=가전' }, + { label: '기타', to: '/care/product/home?category=기타' }, + ] }, + { label: '상품이력', to: '/care/product/log' }, + ] }, + ], + [ + { + letter: 'C', + title: '계약관리', + items: [ + { label: '인터넷접수', to: '/care/contract/internet' }, + { label: '홈렌탈접수', to: '/care/contract/home' }, + { label: '계약이력', to: '/care/contract/log' }, + ] }, + { + letter: 'R', + title: '접수관리', + items: [ + { label: '접수리스트', to: '/care/reception/list' }, + ] }, + { + letter: 'D', + title: '정산관리', + items: [ + { label: '정산서발행', to: '/care/invoice/publish' }, + { label: '나의정산서', to: '/care/invoice/my' }, + { label: '정산서이력', to: '/care/invoice/log' }, + ] }, + { + letter: 'E', + title: '일정관리', + items: [ + { label: '캘린더', to: '/care/pending/calendar' }, + { label: '일정관리', to: '/care/pending/pending' }, + { label: '일정이력', to: '/care/pending/log' }, + ] }, + { + letter: 'F', + title: '예약관리', + items: [ + { label: '예약관리', to: '/care/booking/booking' }, + { label: '예약이력', to: '/care/booking/log' }, + ] }, + ], + [ + { + letter: 'G', + title: '고객관리', + items: [ + { label: '고객관리', to: '/care/customer/customer' }, + { label: '고객이력', to: '/care/customer/log' }, + ] }, + { + letter: 'H', + title: '장부관리', + items: [ + { + label: '간편장부', + to: '/care/abooks/abooks-mon', + children: [ + { label: '월별 간편장부', to: '/care/abooks/abooks-mon?view=month' }, + { label: '상세 간편장부', to: '/care/abooks/abooks-mon?view=detail' }, + ] }, + { label: '장부이력', to: '/care/abooks/log' }, + ] }, + { + letter: 'I', + title: '통계관리', + items: [ + { label: '원클릭리포트', to: '/care/report/daily' }, + { label: '월간리포트', to: '/care/report/monthly' }, + { label: '인터넷통계', to: '/care/report/internet' }, + { label: '홈렌탈통계', to: '/care/report/home' }, + { label: '직원별통계', to: '/care/report/member' }, + { label: '거래처별통계', to: '/care/report/agency' }, + ] }, + ], + [ + { + letter: 'J', + title: '환경설정', + items: [ + { label: '거래처관리', to: '/care/setting/agency' }, + { label: '직원관리', to: '/care/setting/member' }, + { label: '기본설정', to: '/care/setting/preference' }, + { label: '권한설정', to: '/care/setting/level' }, + { label: '상부점관리', to: '/care/setting/partner1' }, + { label: '하부점관리', to: '/care/setting/partner2' }, + ] }, + { + letter: 'L', + title: '고객센터', + items: [ + { label: '공지사항', to: '/care/cs/notice' }, + { label: '사용문의', to: '/care/cs/question' }, + ] }, + { + letter: 'M', + title: '게시판', + items: [{ label: '사내게시판', to: '/care/bbs/bbs' }] }, + ], +] + +function SectionTitle({ section }: { section: SitemapSection }) { + const text = `${section.letter}. ${section.title}` + if (section.to) { + return ( +

+ {text} +

+ ) + } + return

{text}

+} + +function SitemapItem({ item }: { item: SitemapChild }) { + return ( +
  • + {item.to ? ( + + {item.label} + + ) : ( + {item.label} + )} + {item.children && item.children.length > 0 && ( +
      + {item.children.map((child) => ( +
    • + - + + {child.label} + +
    • + ))} +
    + )} +
  • + ) +} + +export default function SitemapPage() { + const [allowedMenus, setAllowedMenus] = useState(null) + + useEffect(() => { + let cancelled = false + fetchMyMenus() + .then((res) => { + if (!cancelled) setAllowedMenus(res.menus || []) + }) + .catch(() => { + if (cancelled) return + try { + const cached = localStorage.getItem('allowedMenus') + if (cached) setAllowedMenus(JSON.parse(cached) as string[]) + else setAllowedMenus(null) + } catch { + setAllowedMenus(null) + } + }) + return () => { cancelled = true } + }, []) + + const columns = useMemo(() => { + if (!allowedMenus) return SITEMAP_COLUMNS + const set = new Set(allowedMenus) + return SITEMAP_COLUMNS.map((col) => + col.filter((section) => { + const id = SITEMAP_TITLE_TO_ID[section.title] + return !id || set.has(id) + }), + ).filter((col) => col.length > 0) + }, [allowedMenus]) + + return ( +
    +
    +
    +

    + + 전체보기 +

    +

    비즈케어홈즈의 모든 서비스를 소개드립니다. 원하시는 메뉴를 클릭해주세요.

    +
    +
    + + +
    + {columns.map((col, idx) => ( +
    + {col.map((section) => ( +
    + + {section.items && section.items.length > 0 && ( +
      + {section.items.map((item) => ( + + ))} +
    + )} +
    + ))} +
    + ))} +
    +
    + +
    +

    (주) 비즈케어 | 사업자등록번호 : 000-00-00000

    +

    연락처 : 0000-0000

    +

    Copyright @ 2025 BizCare All Right Reversed.

    +
    +
    + ) +} diff --git a/frontend/src/pages/care/SmsAddressPage.tsx b/frontend/src/pages/care/SmsAddressPage.tsx new file mode 100644 index 0000000..eac5de8 --- /dev/null +++ b/frontend/src/pages/care/SmsAddressPage.tsx @@ -0,0 +1,546 @@ +import { useMemo, useState } from 'react' +import Modal from '../../components/DraggableModal' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import { + Button, + Card, + Form, + Input, + Select, + Space, + Typography, + message } from 'antd' +import CareTable from '../../components/CareTable' +import { + DownloadOutlined, EditOutlined, PlusOutlined, SearchOutlined, SendOutlined } from '@ant-design/icons' +import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client' + +type GroupRow = { + id?: number | null + name: string + count: number + all?: boolean +} + +type ContactRow = { + id: number + addressGroupId?: number + groupName?: string + name?: string + phone?: string + phoneDisplay?: string +} + +type ContactData = { + total: number + list: ContactRow[] +} + +export default function SmsAddressPage() { + const qc = useQueryClient() + const navigate = useNavigate() + const [form] = Form.useForm() + const [contactForm] = Form.useForm() + const [groupForm] = Form.useForm() + const [changeForm] = Form.useForm() + const [bulkForm] = Form.useForm() + const [selectedGroupId, setSelectedGroupId] = useState(undefined) + const [selected, setSelected] = useState([]) + const [contactOpen, setContactOpen] = useState(false) + const [editingContact, setEditingContact] = useState(null) + const [groupOpen, setGroupOpen] = useState(false) + const [editingGroup, setEditingGroup] = useState(null) + const [changeOpen, setChangeOpen] = useState(false) + const [bulkOpen, setBulkOpen] = useState(false) + const [filters, setFilters] = useState>({ + searchType: '이름', + page: 0, + size: 15 }) + + const groupsQuery = useQuery({ + queryKey: ['sms-address-groups'], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/sms/address/groups`) + return unwrap(data) + } }) + + const contactsQuery = useQuery({ + queryKey: ['sms-address-contacts', filters, selectedGroupId], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/sms/address/contacts`, { + params: { + ...filters, + addressGroupId: selectedGroupId ?? undefined } }) + return unwrap(data) + } }) + + const groupOptions = useMemo( + () => (groupsQuery.data?.list ?? []) + .filter((g) => !g.all) + .map((g) => ({ value: g.id as number, label: g.name })), + [groupsQuery.data], + ) + + const refresh = () => { + setSelected([]) + qc.invalidateQueries({ queryKey: ['sms-address-groups'] }) + qc.invalidateQueries({ queryKey: ['sms-address-contacts'] }) + } + + const saveGroup = useMutation({ + mutationFn: async (values: { name: string }) => { + if (editingGroup?.id) { + const { data } = await client.put(`${apiPrefix()}/sms/address/groups/${editingGroup.id}`, values) + return unwrap(data) + } + const { data } = await client.post(`${apiPrefix()}/sms/address/groups`, values) + return unwrap(data) + }, + onSuccess: () => { + message.success(editingGroup ? '그룹을 수정했습니다.' : '그룹을 등록했습니다.') + setGroupOpen(false) + setEditingGroup(null) + groupForm.resetFields() + refresh() + }, + onError: (e: Error) => message.error(e.message) }) + + const saveContact = useMutation({ + mutationFn: async (values: Record) => { + const phone = [values.phone1, values.phone2, values.phone3].filter(Boolean).join('') + const body = { + addressGroupId: values.addressGroupId, + name: values.name, + phone } + if (editingContact?.id) { + const { data } = await client.put(`${apiPrefix()}/sms/address/contacts/${editingContact.id}`, body) + return unwrap(data) + } + const { data } = await client.post(`${apiPrefix()}/sms/address/contacts`, body) + return unwrap(data) + }, + onSuccess: () => { + message.success(editingContact ? '연락처를 수정했습니다.' : '연락처를 등록했습니다.') + setContactOpen(false) + setEditingContact(null) + contactForm.resetFields() + refresh() + }, + onError: (e: Error) => message.error(e.message) }) + + const deleteContacts = useMutation({ + mutationFn: async (ids: number[]) => { + const { data } = await client.post(`${apiPrefix()}/sms/address/contacts/delete`, { ids }) + return unwrap(data) + }, + onSuccess: () => { message.success('삭제했습니다.'); refresh() }, + onError: () => message.error('삭제에 실패했습니다.') }) + + const changeGroup = useMutation({ + mutationFn: async (addressGroupId: number) => { + const { data } = await client.post(`${apiPrefix()}/sms/address/contacts/change-group`, { + ids: selected, + addressGroupId }) + return unwrap(data) + }, + onSuccess: () => { + message.success('그룹을 변경했습니다.') + setChangeOpen(false) + changeForm.resetFields() + refresh() + }, + onError: (e: Error) => message.error(e.message) }) + + const bulkCreate = useMutation({ + mutationFn: async (values: Record) => { + const { data } = await client.post(`${apiPrefix()}/sms/address/contacts/bulk`, values) + return unwrap(data) + }, + onSuccess: () => { + message.success('대량등록이 완료되었습니다.') + setBulkOpen(false) + bulkForm.resetFields() + refresh() + }, + onError: (e: Error) => message.error(e.message) }) + + const cartAdd = useMutation({ + mutationFn: async (phones: string[]) => { + const { data } = await client.post(`${apiPrefix()}/sms/cart`, { phones }) + return unwrap(data) as { count: number } + }, + onSuccess: (data) => { + message.success(`문자 장바구니에 담았습니다. (총 ${data.count}건)`) + navigate('/care/sms/history') + }, + onError: () => message.error('장바구니 담기에 실패했습니다.') }) + + const onSearch = (values: Record) => { + 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 }) + setSelectedGroupId(undefined) + setFilters({ searchType: '이름', page: 0, size: 15 }) + } + + const openContactCreate = () => { + setEditingContact(null) + contactForm.setFieldsValue({ + addressGroupId: selectedGroupId ?? undefined, + name: undefined, + phone1: '010', + phone2: '', + phone3: '' }) + setContactOpen(true) + } + + const openContactEdit = (row: ContactRow) => { + setEditingContact(row) + const digits = (row.phone || '').replace(/\D/g, '') + contactForm.setFieldsValue({ + addressGroupId: row.addressGroupId, + name: row.name, + phone1: digits.slice(0, 3) || '010', + phone2: digits.slice(3, 7), + phone3: digits.slice(7) }) + setContactOpen(true) + } + + const openGroupCreate = () => { + setEditingGroup(null) + groupForm.setFieldsValue({ name: undefined }) + setGroupOpen(true) + } + + const openGroupEdit = (row: GroupRow) => { + setEditingGroup(row) + groupForm.setFieldsValue({ name: row.name }) + setGroupOpen(true) + } + + const download = async () => { + const { data } = await client.get(`${apiPrefix()}/sms/address/contacts/export`, { + params: { ...filters, addressGroupId: selectedGroupId ?? undefined }, + 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 = (action: () => void) => { + if (!selected.length) { + message.warning('항목을 선택하세요.') + return + } + action() + } + + const groupColumns = [ + { + title: '그룹명', + dataIndex: 'name', + render: (v: string, row: GroupRow) => ( + + ) }, + { title: '연락처', dataIndex: 'count', width: 80, align: 'center' as const }, + { + title: '관리', + key: 'manage', + width: 60, + align: 'center' as const, + render: (_: unknown, row: GroupRow) => ( + row.all ? null : + + + + + + + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + {!editingContact && ( + + )} +
    + + +
    +
    + + + + { setGroupOpen(false); setEditingGroup(null) }} + footer={null} + width={400} + destroyOnHidden + centered + > +
    saveGroup.mutate(values)}> + + + +
    + + +
    +
    +
    + + setChangeOpen(false)} + footer={null} + width={400} + destroyOnHidden + centered + > +
    changeGroup.mutate(Number(values.addressGroupId))} + > + + + + + + +
    + + +
    +
    +
    +
    + ) +} diff --git a/frontend/src/pages/care/SmsHistoryPage.tsx b/frontend/src/pages/care/SmsHistoryPage.tsx new file mode 100644 index 0000000..1b40654 --- /dev/null +++ b/frontend/src/pages/care/SmsHistoryPage.tsx @@ -0,0 +1,388 @@ +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, + DatePicker, + Form, + Input, + Select, + Space, + Typography, + message } from 'antd' +import CareTable from '../../components/CareTable' +import { EditOutlined, SearchOutlined, SendOutlined } from '@ant-design/icons' +import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client' + +const { RangePicker } = DatePicker +const { TextArea } = Input + +type SmsRow = { + id: number + sentAt?: string + msgType?: string + sendCount?: number + deductCount?: number + phone?: string + message?: string + senderNumber?: string + senderName?: string +} + +type SmsData = { + total: number + list: SmsRow[] + balance?: number +} + +const defaultRange = (): [Dayjs, Dayjs] => [dayjs().subtract(1, 'month').startOf('month'), dayjs()] + +const smsBytes = (text: string) => { + let bytes = 0 + for (let i = 0; i < text.length; i += 1) { + bytes += text.charCodeAt(i) <= 0x7f ? 1 : 2 + } + return bytes +} + +export default function SmsHistoryPage() { + const qc = useQueryClient() + const [form] = Form.useForm() + const [open, setOpen] = useState(false) + const [selected, setSelected] = useState([]) + const [phoneInput, setPhoneInput] = useState('') + const [phones, setPhones] = useState([]) + const [content, setContent] = useState('') + const [filters, setFilters] = useState>(() => { + const [from, to] = defaultRange() + return { + dateFrom: from.format('YYYY-MM-DD'), + dateTo: to.format('YYYY-MM-DD'), + searchType: '수신번호', + page: 0, + size: 15 } + }) + + const query = useQuery({ + queryKey: ['sms-history', filters], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/sms/history`, { params: filters }) + return unwrap(data) + } }) + + const balanceQuery = useQuery({ + queryKey: ['sms-balance'], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/sms/balance`) + return unwrap(data) + } }) + + const balance = balanceQuery.data?.balance ?? query.data?.balance ?? 996 + const senderNumber = balanceQuery.data?.senderNumber ?? '1600-3903' + const bytes = smsBytes(content) + const msgType = bytes <= 90 ? '단문' : '장문' + const perDeduct = bytes <= 90 ? 1 : Math.max(3, Math.ceil(bytes / 90)) + const deduct = phones.length === 0 ? 0 : perDeduct * phones.length + + const sendMut = useMutation({ + mutationFn: async () => { + const { data } = await client.post(`${apiPrefix()}/sms/send`, { + phones, + message: content, + senderNumber }) + return unwrap(data) + }, + onSuccess: () => { + message.success('문자를 발송했습니다.') + setOpen(false) + setPhones([]) + setPhoneInput('') + setContent('') + qc.invalidateQueries({ queryKey: ['sms-history'] }) + qc.invalidateQueries({ queryKey: ['sms-balance'] }) + }, + onError: (e: Error) => message.error(e.message || '발송에 실패했습니다.') }) + + const deleteMut = useMutation({ + mutationFn: async (ids: number[]) => { + const { data } = await client.post(`${apiPrefix()}/sms/history/delete`, { ids }) + return unwrap(data) + }, + onSuccess: () => { + message.success('삭제했습니다.') + setSelected([]) + qc.invalidateQueries({ queryKey: ['sms-history'] }) + }, + onError: () => message.error('삭제에 실패했습니다.') }) + + const chargeMut = useMutation({ + mutationFn: async () => { + const { data } = await client.post(`${apiPrefix()}/sms/charge`, { amount: 100 }) + return unwrap(data) as { balance: number } + }, + onSuccess: (data) => { + message.success('SMS가 충전되었습니다.') + qc.setQueryData(['sms-balance'], (prev: { balance: number; senderNumber: string } | undefined) => ({ + balance: data.balance, + senderNumber: prev?.senderNumber ?? senderNumber })) + qc.invalidateQueries({ queryKey: ['sms-balance'] }) + } }) + + const onSearch = (values: Record) => { + const dates = values.dates as [Dayjs, Dayjs] | undefined + if (!dates?.[0] || !dates?.[1]) { + message.warning('날짜를 선택하세요.') + return + } + setFilters((prev) => ({ + ...prev, + dateFrom: dates[0].format('YYYY-MM-DD'), + dateTo: dates[1].format('YYYY-MM-DD'), + searchType: values.searchType || '수신번호', + keyword: values.keyword, + page: 0, + size: Number(values.size ?? prev.size ?? 15) })) + } + + const onReset = () => { + const [from, to] = defaultRange() + form.setFieldsValue({ + dates: [from, to], + searchType: '수신번호', + keyword: undefined, + size: 15 }) + setFilters({ + dateFrom: from.format('YYYY-MM-DD'), + dateTo: to.format('YYYY-MM-DD'), + searchType: '수신번호', + page: 0, + size: 15 }) + } + + const addPhone = () => { + const phone = phoneInput.replace(/[^\d]/g, '') + if (!phone) { + message.warning('연락처를 입력하세요.') + return + } + if (phones.includes(phone)) { + message.warning('이미 추가된 번호입니다.') + return + } + setPhones((prev) => [...prev, phone]) + setPhoneInput('') + } + + const openSend = () => { + setPhones([]) + setPhoneInput('') + setContent('') + setOpen(true) + balanceQuery.refetch() + } + + const columns = useMemo(() => [ + { title: '발송일', dataIndex: 'sentAt', width: 140, align: 'center' as const, sorter: true }, + { title: '구분', dataIndex: 'msgType', width: 70, align: 'center' as const }, + { title: '발송', dataIndex: 'sendCount', width: 60, align: 'center' as const }, + { title: '차감', dataIndex: 'deductCount', width: 60, align: 'center' as const }, + { title: '발송명단', dataIndex: 'phone', width: 140, ellipsis: true }, + { title: '내용', dataIndex: 'message', ellipsis: true }, + { title: '발신번호', dataIndex: 'senderNumber', width: 110, align: 'center' as const }, + { title: '발송자', dataIndex: 'senderName', width: 90, align: 'center' as const }, + ], []) + + return ( +
    +
    +
    +

    문자발송내역

    +

    발송하신 문자(SMS) 내역을 확인하실 수 있습니다.

    +
    + + + + +
    + + +
    +
    + + + + + + + + + + + + + + setPhoneInput(e.target.value)} + onPressEnter={addPhone} + placeholder="연락처 입력" + /> + + +
    + {phones.length + ? phones.map((p) => ( + + )) + : '받는 사람 연락처를 추가해주세요.'} +
    +
    + + 총 {phones.length} 건 +
    +
    +
    + +
    +
    발송내용 필수
    +
    +