commit 832c9f096f7855705a84ec8940feda932622795a Author: pray4skylark Date: Tue Jun 2 13:50:39 2026 +0900 init: SR System — Helpdesk & ITAM Portal diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..94ae932 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.git +.next +*.md +backend/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..63e9515 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# PostgreSQL +POSTGRES_DB=sr_system +POSTGRES_USER=sr_admin +POSTGRES_PASSWORD= + +# JWT (최소 256bit) +JWT_SECRET= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..45f4e4c --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build +backend/build/ +backend/.gradle/ +backend/out/ +*.jar + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env +.env.local +.env.production + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/.opencode/tier-routing.md b/.opencode/tier-routing.md new file mode 100644 index 0000000..1ee330f --- /dev/null +++ b/.opencode/tier-routing.md @@ -0,0 +1,47 @@ +# Agent Tier Routing Guide + +3-tier agent system: **LITE → MID → MAX** + +## Decision Flow + +1. **LITE first** for simple/cheap tasks +2. **MID** for normal complexity +3. **MAX** only when MID fails 3+ times + +## Per-role routing + +### Architecture +| Task | Agent | +|------|-------| +| Simple design sketch, brief review | @architect-lite (LITE) | +| Full architecture, domain modeling, DB schema | @architect (MID) | +| Unsolvable architecture problem | @escalation (MAX) | + +### Code Analysis +| Task | Agent | +|------|-------| +| Quick file find, simple grep | @context-manager-lite (LITE) | +| Deep codebase analysis, refactoring scope | @context-manager (MID) | +| Beyond MID capability | @escalation (MAX) | + +### Implementation +| Task | Agent | +|------|-------| +| DTO/Entity boilerplate, simple CRUD, basic test | @worker-lite (LITE) | +| Business logic, API binding, complex test | @worker (MID) | +| Beyond MID capability | @escalation (MAX) | + +### Error Fixing +| Task | Agent | +|------|-------| +| Missing import, syntax fix, format lint | @inspector-lite (LITE) | +| Multi-step compile error, runtime bug | @inspector (MID) | +| 3+ failures on same bug | @escalation (MAX) | + +## Cost Awareness +- LITE: deepseek-v4-flash / mimo-v2.5 — $0.14/$0.28 per 1M tokens +- MID: deepseek-v4-pro / mimo-v2.5-pro / qwen3.6-plus — $0.50~$1.74/$3.48 +- MAX: glm-5.1 — $1.40/$4.40 (880 req/5hr limit, use sparingly) + +## Rule +Never use MAX for problems that LITE or MID can handle. Let the failed agent's prompt guide escalation. diff --git a/.opencode/token-efficiency.md b/.opencode/token-efficiency.md new file mode 100644 index 0000000..bf2a7b8 --- /dev/null +++ b/.opencode/token-efficiency.md @@ -0,0 +1,27 @@ +# Token Efficiency — Caveman Default + +Load skill: `~/.agents/skills/caveman/SKILL.md` + +## Default Mode + +Output in **caveman full** level. Exceptions: + +| Context | Mode | Reason | +|---------|------|--------| +| Code/blocks | Normal | Accuracy critical | +| Commit msg | Normal | Conventional commits | +| Security warnings | Normal | Clarity required | +| Irreversible confirm | Normal | Safety | +| User asks "normal mode" | Normal | User override | + +## Rules + +Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms. Technical terms exact. Code blocks unchanged. Errors quoted exact. + +Pattern: `[thing] [action] [reason]. [next step].` + +## Intensity Switching +- `/caveman lite` — no filler/hedging, keep sentences +- `/caveman full` — default, classic caveman +- `/caveman ultra` — max compression +- `stop caveman` / `normal mode` — revert diff --git a/.opencoderules b/.opencoderules new file mode 100644 index 0000000..03cf479 --- /dev/null +++ b/.opencoderules @@ -0,0 +1,90 @@ +# Context Caching Optimization Specification + +## 1. Prompt Structure (Strict) +Prompts MUST follow this sequence: + +### [A. STATIC REGION] - Top (Always Cached) +1. System Persona & Rules (e.g., .geminirules) +2. Background Knowledge (API specs, architecture, DB schema) +3. Output Format Definitions + +### [B. DYNAMIC REGION] - Bottom (Cache Boundary) +1. Variable Parameters (Current Time, Session Metadata) +2. User Request / Query + +## 2. Token Compression +- Data Flattening: Use Markdown tables, CSV, or JSON instead of prose +- Metadata Sanitization: Remove code comments, redundant empty lines, duplicate spacing, and non-essential tracking properties + +## 3. Session Controls +- History Truncation: Do not concatenate raw dialog turns (invalidates cache) +- Checkpoint Aggregation: Summarize history at checkpoints; cache summary, flush raw turns +- Request Batching: Align multi-agent tasks within same TTL cycles for Cache Hit ratio + +# Agentic Coding Workflow - Model Orchestration + +Role-based model dispatching strategy for rate limit optimization. + +## 1. Agent Architecture +4-layer workflow routing by context size, reasoning difficulty, speed, and remaining quota: + +- Layer 1. Architect Agent (Design / Bug Tracking) + Models: DeepSeek V4 Pro, Qwen3.6 Plus (Sub: GLM-5.1) + Role: Requirements analysis and structural design specification + +- Layer 2. Repository Context Agent (Large-scale Source Analysis) + Models: MiMo-V2.5-Pro, Kimi K2.6 + Role: Long context codebase analysis and task splitting + +- Layer 3. Worker Agent (Code Generation / Boilerplate) + Models: Qwen3.5 Plus, MiniMax M2.7 + Role: Business logic and function-level implementation + +- Layer 4. Inspector Agent (Compile/Lint Error Self-Correction) + Models: DeepSeek V4 Flash + Role: Build monitoring, syntax error fix, output format validation + +## 2. Layer Routing & Limits + +| Layer | Models | Hourly | Weekly | Primary Tasks | +|---|---|---|---|---| +| Architect | DeepSeek V4 Pro, Qwen3.6 Plus, GLM-5.1 | 3450, 3300, 880 | 8550, 8200, 2150 | Domain modeling, DB schema, complex debugging. GLM-5.1: approval only | +| Context Manager | MiMo-V2.5-Pro, Kimi K2.6 | 1290, 1150 | 3225, 2880 | Long context analysis, legacy refactoring scope, API doc ingestion | +| Worker | Qwen3.5 Plus, MiniMax M2.7 | 10200, 3400 | 25200, 8500 | Unit function impl, API binding, DTO/Entity boilerplate, unit tests | +| Inspector | DeepSeek V4 Flash | 31650 | 79050 | Compile/lint error fix, output validation, self-correction loop | + +## 3. Implementation Guide + +```python +architect_llm = CustomLLM("deepseek-v4-pro") +context_llm = CustomLLM("mimo-v2.5-pro") +worker_llm = CustomLLM("qwen-3.5-plus") +inspector_llm = CustomLLM("deepseek-v4-flash") + +architect = Agent(role="Software Architect", llm=architect_llm) +code_worker = Agent(role="Senior Backend Developer", llm=worker_llm) +linter_inspector = Agent(role="QA & Code Inspector", llm=inspector_llm) +``` + +## 4. Operational Rules + +1. DeepSeek V4 Flash Firewall + - Typos, semicolons, import errors: NEVER escalate to higher models + - Minimum 3 self-correction loops must resolve within Flash (31650/hr) + +2. GLM-5.1 Escalation Threshold + - 880/hr limit is tight. Exclude from default pipeline + - Use only as last-resort fallback after 3+ failures from other models + +3. Context Split for Token Savings + - After MiMo/Kimi scans full repo, extract only needed snippets for Qwen3.5 Plus + +4. MiniMax Surge Switching + - During boilerplate/test code surges, temporarily route Worker from Qwen3.5 Plus to MiniMax M2.5 (6300/hr) or M2.7 to prevent weekly/monthly lock + +## 5. Development Docs +1. Base docs: ./docs/ +2. Real-time dev logs: ./work_logs/ +3. New session: check ./work_logs/work_log_*.md to resume +4. Milestone complete: use /graphify to map milestone relationships +5. Design elements: ./premium_enterprise/DESIGN.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ea5fdc3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:20-alpine AS build +ARG API_BACKEND_URL +ENV API_BACKEND_URL=$API_BACKEND_URL +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +COPY --from=build /app/.next/standalone ./ +COPY --from=build /app/.next/static ./.next/static +COPY --from=build /app/public ./public +EXPOSE 3000 +ENV NODE_ENV=production +CMD ["node", "server.js"] diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..ada8498 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,5 @@ +.gradle +build +*.md +.gradle/ +build/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..b72a30f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,13 @@ +FROM eclipse-temurin:17-jdk AS build +WORKDIR /app +COPY gradlew build.gradle settings.gradle ./ +COPY gradle gradle +RUN chmod +x gradlew && ./gradlew dependencies --no-daemon +COPY src src +RUN ./gradlew bootJar --no-daemon + +FROM eclipse-temurin:17-jre +WORKDIR /app +COPY --from=build /app/build/libs/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/backend/build.gradle b/backend/build.gradle new file mode 100644 index 0000000..1635e29 --- /dev/null +++ b/backend/build.gradle @@ -0,0 +1,45 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.0' + id 'io.spring.dependency-management' version '1.1.4' +} + +group = 'com' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + implementation 'org.flywaydb:flyway-core' + implementation 'io.jsonwebtoken:jjwt-api:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' + implementation 'org.springframework.boot:spring-boot-starter-batch' + implementation 'org.postgresql:postgresql' + implementation 'org.projectlombok:lombok:1.18.38' + annotationProcessor 'org.projectlombok:lombok:1.18.38' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/backend/gradle/wrapper/gradle-wrapper.jar b/backend/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/backend/gradle/wrapper/gradle-wrapper.jar differ diff --git a/backend/gradle/wrapper/gradle-wrapper.properties b/backend/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..680d395 --- /dev/null +++ b/backend/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/backend/gradlew b/backend/gradlew new file mode 100755 index 0000000..b9bb139 --- /dev/null +++ b/backend/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +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 + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/backend/gradlew.bat b/backend/gradlew.bat new file mode 100644 index 0000000..aa5f10b --- /dev/null +++ b/backend/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/backend/settings.gradle b/backend/settings.gradle new file mode 100644 index 0000000..509a9cb --- /dev/null +++ b/backend/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'sr-system-backend' diff --git a/backend/src/main/java/com/srsystem/SrsystemApplication.java b/backend/src/main/java/com/srsystem/SrsystemApplication.java new file mode 100644 index 0000000..4ab2d05 --- /dev/null +++ b/backend/src/main/java/com/srsystem/SrsystemApplication.java @@ -0,0 +1,12 @@ +package com.srsystem; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SrsystemApplication { + + public static void main(String[] args) { + SpringApplication.run(SrsystemApplication.class, args); + } +} diff --git a/backend/src/main/java/com/srsystem/batch/SlaExpirationJob.java b/backend/src/main/java/com/srsystem/batch/SlaExpirationJob.java new file mode 100644 index 0000000..9efc5e1 --- /dev/null +++ b/backend/src/main/java/com/srsystem/batch/SlaExpirationJob.java @@ -0,0 +1,67 @@ +package com.srsystem.batch; + +import com.srsystem.dto.NotificationEvent; +import com.srsystem.entity.Ticket; +import com.srsystem.entity.enums.TicketStatus; +import com.srsystem.listener.NotificationPublisher; +import com.srsystem.repository.TicketRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Component +@RequiredArgsConstructor +@Slf4j +public class SlaExpirationJob { + + private final TicketRepository ticketRepository; + private final NotificationPublisher notificationPublisher; + + private static final List EXCLUDED_STATUSES = List.of(TicketStatus.RESOLVED, TicketStatus.CLOSED); + + @Transactional + public int processExpiredTickets() { + List expiredTickets = ticketRepository + .findBySlaWarnFlagAndStatusNotIn('N', EXCLUDED_STATUSES); + + int count = 0; + for (Ticket ticket : expiredTickets) { + if (ticket.getDueDate() != null && ticket.getDueDate().isBefore(LocalDateTime.now())) { + ticket.setSlaWarnFlag('Y'); + ticketRepository.save(ticket); + + if (ticket.getRequester() != null) { + notificationPublisher.publish(new NotificationEvent( + "SLA_WARNING", + "SLA Expired: Ticket #" + ticket.getTicketId(), + "Ticket \"" + ticket.getTitle() + "\" has exceeded its SLA deadline.", + "/issues/" + ticket.getTicketId(), + ticket.getRequester().getEmpId() + )); + } + + if (ticket.getAssignee() != null) { + notificationPublisher.publish(new NotificationEvent( + "SLA_WARNING", + "SLA Expired: Ticket #" + ticket.getTicketId(), + "Assigned ticket \"" + ticket.getTitle() + "\" has exceeded its SLA deadline.", + "/issues/" + ticket.getTicketId(), + ticket.getAssignee().getEmpId() + )); + } + + count++; + } + } + + if (count > 0) { + log.info("SLA Expiration Job: {} tickets marked as overdue", count); + } + + return count; + } +} diff --git a/backend/src/main/java/com/srsystem/batch/SlaScheduler.java b/backend/src/main/java/com/srsystem/batch/SlaScheduler.java new file mode 100644 index 0000000..acef59a --- /dev/null +++ b/backend/src/main/java/com/srsystem/batch/SlaScheduler.java @@ -0,0 +1,25 @@ +package com.srsystem.batch; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class SlaScheduler { + + private final SlaExpirationJob slaExpirationJob; + + @Scheduled(cron = "0 0 * * * *") + public void runSlaCheck() { + log.info("SLA Scheduler: Starting hourly SLA expiration check"); + try { + int processed = slaExpirationJob.processExpiredTickets(); + log.info("SLA Scheduler: Completed. {} tickets processed.", processed); + } catch (Exception e) { + log.error("SLA Scheduler: Error during SLA check", e); + } + } +} diff --git a/backend/src/main/java/com/srsystem/config/AsyncConfig.java b/backend/src/main/java/com/srsystem/config/AsyncConfig.java new file mode 100644 index 0000000..3ffeada --- /dev/null +++ b/backend/src/main/java/com/srsystem/config/AsyncConfig.java @@ -0,0 +1,9 @@ +package com.srsystem.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +@Configuration +@EnableAsync +public class AsyncConfig { +} diff --git a/backend/src/main/java/com/srsystem/config/BatchConfig.java b/backend/src/main/java/com/srsystem/config/BatchConfig.java new file mode 100644 index 0000000..e444868 --- /dev/null +++ b/backend/src/main/java/com/srsystem/config/BatchConfig.java @@ -0,0 +1,9 @@ +package com.srsystem.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class BatchConfig { +} diff --git a/backend/src/main/java/com/srsystem/config/JpaConfig.java b/backend/src/main/java/com/srsystem/config/JpaConfig.java new file mode 100644 index 0000000..de2b57f --- /dev/null +++ b/backend/src/main/java/com/srsystem/config/JpaConfig.java @@ -0,0 +1,10 @@ +package com.srsystem.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfig { + +} diff --git a/backend/src/main/java/com/srsystem/config/SecurityConfig.java b/backend/src/main/java/com/srsystem/config/SecurityConfig.java new file mode 100644 index 0000000..519d977 --- /dev/null +++ b/backend/src/main/java/com/srsystem/config/SecurityConfig.java @@ -0,0 +1,75 @@ +package com.srsystem.config; + +import com.srsystem.config.jwt.JwtAuthenticationFilter; +import com.srsystem.config.jwt.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.List; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtTokenProvider jwtTokenProvider; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/auth/**", "/api/v1/health", "/api/v1/wake").permitAll() + .requestMatchers("/api/v1/**").authenticated() + .anyRequest().permitAll() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager() { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setPasswordEncoder(passwordEncoder()); + return new ProviderManager(provider); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/backend/src/main/java/com/srsystem/config/WebConfig.java b/backend/src/main/java/com/srsystem/config/WebConfig.java new file mode 100644 index 0000000..47b5147 --- /dev/null +++ b/backend/src/main/java/com/srsystem/config/WebConfig.java @@ -0,0 +1,34 @@ +package com.srsystem.config; + +import com.srsystem.config.jwt.CurrentUserArgumentResolver; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final CurrentUserArgumentResolver currentUserArgumentResolver; + + public WebConfig(CurrentUserArgumentResolver currentUserArgumentResolver) { + this.currentUserArgumentResolver = currentUserArgumentResolver; + } + + @Value("${file.upload-dir:./uploads}") + private String uploadDir; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(currentUserArgumentResolver); + } + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + registry.addResourceHandler("/uploads/**") + .addResourceLocations("file:" + uploadDir + "/"); + } +} diff --git a/backend/src/main/java/com/srsystem/config/jwt/CurrentUser.java b/backend/src/main/java/com/srsystem/config/jwt/CurrentUser.java new file mode 100644 index 0000000..c45f8a6 --- /dev/null +++ b/backend/src/main/java/com/srsystem/config/jwt/CurrentUser.java @@ -0,0 +1,11 @@ +package com.srsystem.config.jwt; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface CurrentUser { +} diff --git a/backend/src/main/java/com/srsystem/config/jwt/CurrentUserArgumentResolver.java b/backend/src/main/java/com/srsystem/config/jwt/CurrentUserArgumentResolver.java new file mode 100644 index 0000000..4d1dae3 --- /dev/null +++ b/backend/src/main/java/com/srsystem/config/jwt/CurrentUserArgumentResolver.java @@ -0,0 +1,47 @@ +package com.srsystem.config.jwt; + +import com.srsystem.entity.Employee; +import com.srsystem.service.AuthService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { + + private final AuthService authService; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(CurrentUser.class) && + parameter.getParameterType().equals(Employee.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + log.warn("No authentication found"); + return null; + } + String loginId = authentication.getName(); + log.info("Resolving current user for loginId: {}", loginId); + Employee emp = authService.getEmployeeByLoginId(loginId); + if (emp == null) { + log.warn("Employee not found for loginId: {}", loginId); + return null; + } + log.info("Found employee: empId={}, name={}", emp.getEmpId(), emp.getName()); + return emp; + } +} diff --git a/backend/src/main/java/com/srsystem/config/jwt/JwtAuthenticationFilter.java b/backend/src/main/java/com/srsystem/config/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..11f80af --- /dev/null +++ b/backend/src/main/java/com/srsystem/config/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,56 @@ +package com.srsystem.config.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String token = resolveToken(request); + + if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { + String loginId = jwtTokenProvider.getLoginId(token); + String role = jwtTokenProvider.getRole(token); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + loginId, + null, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role)) + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + log.debug("Security context set for user: {}", loginId); + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/backend/src/main/java/com/srsystem/config/jwt/JwtTokenProvider.java b/backend/src/main/java/com/srsystem/config/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..a45b529 --- /dev/null +++ b/backend/src/main/java/com/srsystem/config/jwt/JwtTokenProvider.java @@ -0,0 +1,90 @@ +package com.srsystem.config.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Slf4j +@Component +public class JwtTokenProvider { + + private final SecretKey secretKey; + private final long accessTokenExpiration; + private final long refreshTokenExpiration; + + public JwtTokenProvider( + @Value("${jwt.secret}") String secret, + @Value("${jwt.access-token-expiration}") long accessTokenExpiration, + @Value("${jwt.refresh-token-expiration}") long refreshTokenExpiration) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessTokenExpiration = accessTokenExpiration; + this.refreshTokenExpiration = refreshTokenExpiration; + } + + public String generateAccessToken(String loginId, String role) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + accessTokenExpiration); + + return Jwts.builder() + .subject(loginId) + .claim("role", role) + .issuedAt(now) + .expiration(expiryDate) + .signWith(secretKey) + .compact(); + } + + public String generateRefreshToken(String loginId) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + refreshTokenExpiration); + + return Jwts.builder() + .subject(loginId) + .issuedAt(now) + .expiration(expiryDate) + .signWith(secretKey) + .compact(); + } + + public String getLoginId(String token) { + return getClaims(token).getSubject(); + } + + public String getRole(String token) { + return getClaims(token).get("role", String.class); + } + + public boolean validateToken(String token) { + try { + getClaims(token); + return true; + } catch (ExpiredJwtException e) { + log.warn("Expired JWT token: {}", e.getMessage()); + } catch (UnsupportedJwtException e) { + log.warn("Unsupported JWT token: {}", e.getMessage()); + } catch (MalformedJwtException e) { + log.warn("Malformed JWT token: {}", e.getMessage()); + } catch (IllegalArgumentException e) { + log.warn("Invalid JWT token: {}", e.getMessage()); + } + return false; + } + + private Claims getClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} diff --git a/backend/src/main/java/com/srsystem/controller/AuthController.java b/backend/src/main/java/com/srsystem/controller/AuthController.java new file mode 100644 index 0000000..59c90c2 --- /dev/null +++ b/backend/src/main/java/com/srsystem/controller/AuthController.java @@ -0,0 +1,39 @@ +package com.srsystem.controller; + +import com.srsystem.dto.ApiResponse; +import com.srsystem.dto.LoginRequest; +import com.srsystem.dto.LoginResponse; +import com.srsystem.dto.RefreshTokenRequest; +import com.srsystem.service.AuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Authentication", description = "인증 관련 API") +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @Operation(summary = "로그인", description = "아이디와 비밀번호로 로그인하여 액세스 토큰과 리프레시 토큰을 발급받습니다") + @PostMapping("/login") + public ResponseEntity> login(@Valid @RequestBody LoginRequest request) { + LoginResponse response = authService.login(request); + return ResponseEntity.ok(ApiResponse.ok(response)); + } + + @Operation(summary = "토큰 갱신", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다") + @PostMapping("/refresh") + public ResponseEntity> refresh(@RequestBody RefreshTokenRequest request) { + LoginResponse response = authService.refresh(request.refreshToken()); + return ResponseEntity.ok(ApiResponse.ok(response)); + } +} diff --git a/backend/src/main/java/com/srsystem/controller/CalendarController.java b/backend/src/main/java/com/srsystem/controller/CalendarController.java new file mode 100644 index 0000000..9e4e50d --- /dev/null +++ b/backend/src/main/java/com/srsystem/controller/CalendarController.java @@ -0,0 +1,40 @@ +package com.srsystem.controller; + +import com.srsystem.config.jwt.CurrentUser; +import com.srsystem.dto.ApiResponse; +import com.srsystem.dto.CalendarEvent; +import com.srsystem.entity.Employee; +import com.srsystem.service.CalendarService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; + +@Tag(name = "Calendar", description = "통합 일정 조회 API") +@RestController +@RequestMapping("/api/v1/calendar") +@RequiredArgsConstructor +@SecurityRequirement(name = "bearerAuth") +public class CalendarController { + + private final CalendarService calendarService; + + @Operation(summary = "통합 일정 조회", description = "기간 내 모든 일정 (티켓 마감, 보고서 제출, 파견 기간) 을 조회합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @GetMapping("/events") + public ResponseEntity>> getEvents( + @RequestParam String start, + @RequestParam String end, + @CurrentUser Employee currentUser) { + LocalDate startDate = LocalDate.parse(start); + LocalDate endDate = LocalDate.parse(end); + List events = calendarService.getEvents(currentUser.getEmpId(), currentUser.getRoleCode(), startDate, endDate); + return ResponseEntity.ok(ApiResponse.ok(events)); + } +} diff --git a/backend/src/main/java/com/srsystem/controller/CommonCodeController.java b/backend/src/main/java/com/srsystem/controller/CommonCodeController.java new file mode 100644 index 0000000..ca7e0ef --- /dev/null +++ b/backend/src/main/java/com/srsystem/controller/CommonCodeController.java @@ -0,0 +1,64 @@ +package com.srsystem.controller; + +import com.srsystem.dto.ApiResponse; +import com.srsystem.dto.CommonCodeRequest; +import com.srsystem.entity.CommonCode; +import com.srsystem.service.CommonCodeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "Common Code", description = "공통 코드 관리 API") +@RestController +@RequestMapping("/api/v1/common-codes") +@RequiredArgsConstructor +public class CommonCodeController { + + private final CommonCodeService commonCodeService; + + @Operation(summary = "공통 코드 목록 조회", description = "그룹 코드에 해당하는 공통 코드 목록을 조회합니다") + @GetMapping + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + public ResponseEntity>> getCodes(@RequestParam(required = false) String groupCode) { + List codes = commonCodeService.getCodes(groupCode); + return ResponseEntity.ok(ApiResponse.ok(codes)); + } + + @Operation(summary = "공통 코드 그룹 목록 조회", description = "모든 공통 코드 그룹 목록을 조회합니다") + @GetMapping("/groups") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + public ResponseEntity>> getGroups() { + List groups = commonCodeService.getGroupCodes(); + return ResponseEntity.ok(ApiResponse.ok(groups)); + } + + @Operation(summary = "공통 코드 생성", description = "새로운 공통 코드를 생성합니다 (ADMIN 전용)") + @PreAuthorize("hasRole('ADMIN')") + @PostMapping + public ResponseEntity> createCode(@Valid @RequestBody CommonCodeRequest request) { + CommonCode code = commonCodeService.createCode(request); + return ResponseEntity.ok(ApiResponse.ok("공통 코드가 생성되었습니다.", code)); + } + + @Operation(summary = "공통 코드 수정", description = "기존 공통 코드를 수정합니다 (ADMIN 전용)") + @PreAuthorize("hasRole('ADMIN')") + @PutMapping("/{id}") + public ResponseEntity> updateCode(@PathVariable Long id, @Valid @RequestBody CommonCodeRequest request) { + CommonCode code = commonCodeService.updateCode(id, request); + return ResponseEntity.ok(ApiResponse.ok("공통 코드가 수정되었습니다.", code)); + } + + @Operation(summary = "공통 코드 삭제", description = "공통 코드를 삭제합니다 (ADMIN 전용)") + @PreAuthorize("hasRole('ADMIN')") + @DeleteMapping("/{id}") + public ResponseEntity> deleteCode(@PathVariable Long id) { + commonCodeService.deleteCode(id); + return ResponseEntity.ok(ApiResponse.ok("공통 코드가 삭제되었습니다.", null)); + } +} diff --git a/backend/src/main/java/com/srsystem/controller/DispatchHistoryController.java b/backend/src/main/java/com/srsystem/controller/DispatchHistoryController.java new file mode 100644 index 0000000..d5ad594 --- /dev/null +++ b/backend/src/main/java/com/srsystem/controller/DispatchHistoryController.java @@ -0,0 +1,82 @@ +package com.srsystem.controller; + +import com.srsystem.config.jwt.CurrentUser; +import com.srsystem.dto.ApiResponse; +import com.srsystem.dto.CreateDispatchRequest; +import com.srsystem.entity.DispatchHistory; +import com.srsystem.entity.Employee; +import com.srsystem.service.DispatchHistoryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "Dispatch History", description = "파견 이력 관리 API") +@RestController +@RequestMapping("/api/v1/dispatch-histories") +@RequiredArgsConstructor +@SecurityRequirement(name = "bearerAuth") +public class DispatchHistoryController { + + private final DispatchHistoryService dispatchHistoryService; + + @Operation(summary = "파견 이력 생성", description = "직원을 프로젝트에 배정합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @PostMapping + public ResponseEntity> createDispatch( + @Valid @RequestBody CreateDispatchRequest request) { + DispatchHistory history = dispatchHistoryService.createDispatch(request); + return ResponseEntity.ok(ApiResponse.ok("파견이 등록되었습니다", history)); + } + + @Operation(summary = "파견 이력 종료", description = "파견을 종료합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @PutMapping("/{dispatchId}/end") + public ResponseEntity> endDispatch( + @PathVariable Long dispatchId) { + DispatchHistory history = dispatchHistoryService.endDispatch(dispatchId); + return ResponseEntity.ok(ApiResponse.ok("파견이 종료되었습니다", history)); + } + + @Operation(summary = "현재 파견 중인 직원 목록", description = "파견중인 직원 목록을 조회합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @GetMapping("/active") + public ResponseEntity>> getActiveDispatches() { + List list = dispatchHistoryService.getActiveDispatches(); + return ResponseEntity.ok(ApiResponse.ok(list)); + } + + @Operation(summary = "파견 이력 전체 목록", description = "페이징 및 필터 조건으로 파견 이력 목록을 조회합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @GetMapping + public ResponseEntity>> findAll( + @PageableDefault(size = 20) Pageable pageable, + @RequestParam(required = false) Long empId, + @RequestParam(required = false) Long projectId) { + return ResponseEntity.ok(ApiResponse.ok(dispatchHistoryService.findAll(pageable, empId, projectId))); + } + + @Operation(summary = "파견 이력 단건 조회", description = "파견 이력 ID로 단건 조회합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @GetMapping("/{dispatchId}") + public ResponseEntity> findById(@PathVariable Long dispatchId) { + return ResponseEntity.ok(ApiResponse.ok(dispatchHistoryService.findById(dispatchId))); + } + + @Operation(summary = "파견 이력 삭제", description = "파견 이력을 삭제합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @DeleteMapping("/{dispatchId}") + public ResponseEntity> delete(@PathVariable Long dispatchId) { + dispatchHistoryService.delete(dispatchId); + return ResponseEntity.ok(ApiResponse.ok("파견 이력이 삭제되었습니다", null)); + } +} diff --git a/backend/src/main/java/com/srsystem/controller/EmployeeController.java b/backend/src/main/java/com/srsystem/controller/EmployeeController.java new file mode 100644 index 0000000..e234dce --- /dev/null +++ b/backend/src/main/java/com/srsystem/controller/EmployeeController.java @@ -0,0 +1,164 @@ +package com.srsystem.controller; + +import com.srsystem.config.jwt.CurrentUser; +import com.srsystem.dto.ApiResponse; +import com.srsystem.dto.ChangePasswordRequest; +import com.srsystem.dto.EmployeeSearchResult; +import com.srsystem.dto.EmployeeSummary; +import com.srsystem.dto.PortfolioResponse; +import com.srsystem.dto.UpdateAchievementsRequest; +import com.srsystem.entity.DispatchHistory; +import com.srsystem.entity.Employee; +import com.srsystem.entity.enums.EmpStatus; +import com.srsystem.service.DispatchHistoryService; +import com.srsystem.service.EmployeeService; +import com.srsystem.service.PortfolioService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import org.springframework.transaction.annotation.Transactional; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; + +import java.util.List; + +@Tag(name = "Employee", description = "직원 관리 API") +@RestController +@RequestMapping("/api/v1/employees") +@RequiredArgsConstructor +@SecurityRequirement(name = "bearerAuth") +public class EmployeeController { + + private final EmployeeService employeeService; + private final DispatchHistoryService dispatchHistoryService; + private final PortfolioService portfolioService; + + @Operation(summary = "직원 목록 조회", description = "페이징된 직원 목록을 조회합니다. 상태 필터 옵션 포함") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @GetMapping + public ResponseEntity>> findAll( + @RequestParam(required = false) EmpStatus status, + @PageableDefault(size = 20) Pageable pageable) { + Page employees = employeeService.findAll(status, pageable); + return ResponseEntity.ok(ApiResponse.ok(employees)); + } + + @Operation(summary = "직원 단건 조회", description = "직원 ID 로 단일 직원 정보를 조회합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @GetMapping("/{empId}") + public ResponseEntity> findById(@PathVariable Long empId) { + Employee employee = employeeService.findById(empId); + return ResponseEntity.ok(ApiResponse.ok(employee)); + } + + @Operation(summary = "현재 사용자 프로필 조회", description = "로그인한 사용자의 프로필 정보를 조회합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @GetMapping("/me") + public ResponseEntity> getMe(@CurrentUser Employee employee) { + EmployeeSummary summary = employeeService.toSummary(employee); + return ResponseEntity.ok(ApiResponse.ok(summary)); + } + + @Operation(summary = "직원 검색 자동완성", description = "이름 키워드로 직원을 검색합니다 (디바운싱 300ms 권장)") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @GetMapping("/search") + public ResponseEntity>> search( + @RequestParam String keyword) { + List results = employeeService.searchByName(keyword); + return ResponseEntity.ok(ApiResponse.ok(results)); + } + + @Operation(summary = "직원 생성", description = "새로운 직원 정보를 생성합니다") + @PreAuthorize("hasRole('ADMIN')") + @PostMapping + public ResponseEntity> create(@RequestBody Employee employee) { + Employee created = employeeService.create(employee); + return ResponseEntity.ok(ApiResponse.ok("직원이 등록되었습니다", created)); + } + + @Operation(summary = "직원 정보 수정", description = "직원 정보를 수정합니다 (퇴사자는 수정 불가)") + @PreAuthorize("hasRole('ADMIN')") + @PutMapping("/{empId}") + public ResponseEntity> update( + @PathVariable Long empId, + @RequestBody Employee employee) { + Employee updated = employeeService.update(empId, employee); + return ResponseEntity.ok(ApiResponse.ok("직원 정보가 수정되었습니다", updated)); + } + + @Operation(summary = "직원 삭제", description = "직원을 삭제합니다 (Soft Delete, 퇴사자는 삭제 불가)") + @PreAuthorize("hasRole('ADMIN')") + @DeleteMapping("/{empId}") + public ResponseEntity> delete(@PathVariable Long empId) { + employeeService.delete(empId); + return ResponseEntity.ok(ApiResponse.ok("직원이 삭제되었습니다", null)); + } + + @Operation(summary = "포트폴리오 조회", description = "직원의 파견 이력 (포트폴리오) 을 조회합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @Transactional(readOnly = true) + @GetMapping("/{empId}/dispatch-histories") + public ResponseEntity>> getDispatchHistories( + @PathVariable Long empId, + @Parameter(hidden = true) @CurrentUser Employee currentUser) { + + if (!currentUser.getEmpId().equals(empId) && + !currentUser.getRoleCode().name().equals("ADMIN") && + !currentUser.getRoleCode().name().equals("MANAGER")) { + return ResponseEntity.status(403).body(ApiResponse.error("권한이 없습니다")); + } + + List histories = dispatchHistoryService.findByEmployeeId(empId); + return ResponseEntity.ok(ApiResponse.ok(histories)); + } + + @Operation(summary = "포트폴리오 조회", description = "직원의 포트폴리오를 조회합니다. 파견 이력별로 보고서 요약과 해결 SR 티켓이 자동 바인딩됩니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @GetMapping("/{empId}/portfolio") + public ResponseEntity>> getPortfolio( + @PathVariable Long empId, + @Parameter(hidden = true) @CurrentUser Employee currentUser) { + if (!currentUser.getEmpId().equals(empId) && + !currentUser.getRoleCode().name().equals("ADMIN") && + !currentUser.getRoleCode().name().equals("MANAGER")) { + return ResponseEntity.status(403).body(ApiResponse.error("권한이 없습니다")); + } + List portfolio = portfolioService.getPortfolio(empId); + return ResponseEntity.ok(ApiResponse.ok(portfolio)); + } + + @Operation(summary = "포트폴리오 성과 저장", description = "파견 이력의 Achievements 필드를 업데이트합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @PutMapping("/{empId}/dispatch-histories/{dispatchId}/achievements") + public ResponseEntity> updateAchievements( + @PathVariable Long empId, + @PathVariable Long dispatchId, + @RequestBody UpdateAchievementsRequest request, + @Parameter(hidden = true) @CurrentUser Employee currentUser) { + if (!currentUser.getEmpId().equals(empId) && + !currentUser.getRoleCode().name().equals("ADMIN") && + !currentUser.getRoleCode().name().equals("MANAGER")) { + return ResponseEntity.status(403).body(ApiResponse.error("권한이 없습니다")); + } + portfolioService.updateAchievements(dispatchId, request.getAchievements()); + return ResponseEntity.ok(ApiResponse.ok("성과가 저장되었습니다", null)); + } + + @Operation(summary = "비밀번호 변경", description = "현재 사용자의 비밀번호를 변경합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @PutMapping("/me/password") + public ResponseEntity> changePassword( + @RequestBody @Valid ChangePasswordRequest request, + @CurrentUser Employee currentUser) { + employeeService.changePassword(currentUser.getEmpId(), request); + return ResponseEntity.ok(ApiResponse.ok("비밀번호가 변경되었습니다", null)); + } +} diff --git a/backend/src/main/java/com/srsystem/controller/EquipmentController.java b/backend/src/main/java/com/srsystem/controller/EquipmentController.java new file mode 100644 index 0000000..129902b --- /dev/null +++ b/backend/src/main/java/com/srsystem/controller/EquipmentController.java @@ -0,0 +1,161 @@ +package com.srsystem.controller; + +import com.srsystem.dto.ApiResponse; +import com.srsystem.dto.CreateEquipmentRequest; +import com.srsystem.dto.CreateMaintenanceRequest; +import com.srsystem.dto.EquipmentSummary; +import com.srsystem.dto.UpdateEquipmentRequest; +import com.srsystem.entity.Equipment; +import com.srsystem.entity.EquipmentAssignment; +import com.srsystem.entity.EquipmentMaintenance; +import com.srsystem.entity.enums.EquipmentStatus; +import com.srsystem.entity.enums.EquipmentType; +import com.srsystem.service.EquipmentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.Map; + +@Tag(name = "Equipment", description = "IT 자산 관리 API") +@RestController +@RequestMapping("/api/v1/equipment") +@RequiredArgsConstructor +@SecurityRequirement(name = "bearerAuth") +public class EquipmentController { + + private final EquipmentService equipmentService; + + @Operation(summary = "장비 목록 조회", description = "필터 조건으로 장비 목록을 조회합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @GetMapping + public ResponseEntity>> getEquipment( + @RequestParam(required = false) String type, + @RequestParam(required = false) EquipmentStatus status, + @RequestParam(required = false) String keyword, + @PageableDefault(size = 20) Pageable pageable) { + Page equipment = equipmentService.getEquipment(type, status, keyword, pageable); + return ResponseEntity.ok(ApiResponse.ok(equipment)); + } + + @Operation(summary = "장비 상태별 카운트", description = "장비 상태별 개수를 조회합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @GetMapping("/summary") + public ResponseEntity> getSummary() { + Map counts = equipmentService.getSummary(); + EquipmentSummary summary = new EquipmentSummary(counts); + return ResponseEntity.ok(ApiResponse.ok(summary)); + } + + @Operation(summary = "장비 단건 조회", description = "장비 ID 로 단일 장비 정보를 조회합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @GetMapping("/{equipId}") + public ResponseEntity> getEquipment(@PathVariable Long equipId) { + Equipment equipment = equipmentService.getEquipment(equipId); + return ResponseEntity.ok(ApiResponse.ok(equipment)); + } + + @Operation(summary = "장비 생성", description = "새로운 장비를 생성합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @PostMapping + public ResponseEntity> createEquipment(@Valid @RequestBody CreateEquipmentRequest request) { + Equipment equipment = equipmentService.createEquipment(request); + return ResponseEntity.ok(ApiResponse.ok("장비가 등록되었습니다", equipment)); + } + + @Operation(summary = "장비 정보 수정", description = "장비 정보를 수정합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @PutMapping("/{equipId}") + public ResponseEntity> updateEquipment( + @PathVariable Long equipId, + @RequestBody UpdateEquipmentRequest request) { + Equipment equipment = equipmentService.updateEquipment(equipId, request); + return ResponseEntity.ok(ApiResponse.ok("장비 정보가 수정되었습니다", equipment)); + } + + @Operation(summary = "장비 삭제", description = "장비를 삭제합니다 (Soft Delete)") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @DeleteMapping("/{equipId}") + public ResponseEntity> deleteEquipment(@PathVariable Long equipId) { + equipmentService.deleteEquipment(equipId); + return ResponseEntity.ok(ApiResponse.ok("장비가 삭제되었습니다", null)); + } + + @Operation(summary = "장비 지급", description = "사용자에게 장비를 지급합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @PostMapping("/{equipId}/assign") + public ResponseEntity> assignEquipment( + @PathVariable Long equipId, + @RequestBody Map body) { + Long userId = ((Number) body.get("userId")).longValue(); + String condition = (String) body.get("condition"); + EquipmentAssignment assignment = equipmentService.assignEquipment(equipId, userId, condition); + return ResponseEntity.ok(ApiResponse.ok("장비가 지급되었습니다", assignment)); + } + + @Operation(summary = "장비 반납", description = "장비를 반납합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @PutMapping("/{equipId}/return") + public ResponseEntity> returnEquipment( + @PathVariable Long equipId, + @RequestBody Map body) { + EquipmentAssignment assignment = equipmentService.returnEquipment(equipId, body.get("condition")); + return ResponseEntity.ok(ApiResponse.ok("장비가 반납되었습니다", assignment)); + } + + @Operation(summary = "장비 지급 이력 조회", description = "장비의 지급/반납 이력을 조회합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @GetMapping("/{equipId}/assignments") + public ResponseEntity>> getAssignments(@PathVariable Long equipId) { + List assignments = equipmentService.getAssignments(equipId); + return ResponseEntity.ok(ApiResponse.ok(assignments)); + } + + @Operation(summary = "장비 유지보수 이력 조회", description = "장비의 유지보수 이력을 조회합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @GetMapping("/{equipId}/maintenances") + public ResponseEntity>> getMaintenanceHistory(@PathVariable Long equipId) { + List history = equipmentService.getMaintenanceHistory(equipId); + return ResponseEntity.ok(ApiResponse.ok(history)); + } + + @Operation(summary = "장비 유지보수 기록 추가", description = "장비의 유지보수 기록을 추가합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @PostMapping("/{equipId}/maintenance") + public ResponseEntity> addMaintenance( + @PathVariable Long equipId, + @Valid @RequestBody CreateMaintenanceRequest request) { + EquipmentMaintenance maintenance = equipmentService.addMaintenance(equipId, request); + return ResponseEntity.ok(ApiResponse.ok("유지보수 기록이 등록되었습니다", maintenance)); + } + + @Operation(summary = "장비 이미지 추가", description = "장비에 이미지를 추가합니다 (JSONB 배열)") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @PostMapping("/{equipmentId}/images") + public ResponseEntity> addImage( + @PathVariable Long equipmentId, + @RequestParam("file") MultipartFile file) { + Equipment equipment = equipmentService.addImage(equipmentId, file); + return ResponseEntity.ok(ApiResponse.ok("이미지가 추가되었습니다", equipment)); + } + + @Operation(summary = "장비 이미지 삭제", description = "장비에서 특정 이미지를 삭제합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @DeleteMapping("/{equipmentId}/images/{imageIndex}") + public ResponseEntity> removeImage( + @PathVariable Long equipmentId, + @PathVariable int imageIndex) { + Equipment equipment = equipmentService.removeImage(equipmentId, imageIndex); + return ResponseEntity.ok(ApiResponse.ok("이미지가 삭제되었습니다", equipment)); + } +} diff --git a/backend/src/main/java/com/srsystem/controller/FileUploadController.java b/backend/src/main/java/com/srsystem/controller/FileUploadController.java new file mode 100644 index 0000000..2dba13b --- /dev/null +++ b/backend/src/main/java/com/srsystem/controller/FileUploadController.java @@ -0,0 +1,61 @@ +package com.srsystem.controller; + +import com.srsystem.config.jwt.CurrentUser; +import com.srsystem.dto.ApiResponse; +import com.srsystem.entity.Employee; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.annotation.PostConstruct; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; + +@Tag(name = "File", description = "파일 업로드 API") +@RestController +@RequestMapping("/api/v1/files") +@RequiredArgsConstructor +@SecurityRequirement(name = "bearerAuth") +public class FileUploadController { + + @Value("${file.upload-dir:./uploads}") + private String uploadDir; + + @PostConstruct + public void init() { + File dir = new File(uploadDir); + if (!dir.exists()) dir.mkdirs(); + } + + @Operation(summary = "파일 업로드", description = "이미지 파일을 업로드하고 URL 을 반환합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @PostMapping("/upload") + public ResponseEntity> uploadFile( + @RequestParam("file") MultipartFile file, + @CurrentUser Employee currentUser) { + try { + String originalName = file.getOriginalFilename(); + String ext = ""; + if (originalName != null && originalName.contains(".")) { + ext = originalName.substring(originalName.lastIndexOf(".")); + } + String storedName = UUID.randomUUID().toString() + ext; + Path targetPath = Paths.get(uploadDir, storedName); + Files.copy(file.getInputStream(), targetPath); + String url = "/uploads/" + storedName; + return ResponseEntity.ok(ApiResponse.ok("파일 업로드 성공", url)); + } catch (IOException e) { + return ResponseEntity.internalServerError().body(ApiResponse.error("파일 업로드 실패: " + e.getMessage())); + } + } +} diff --git a/backend/src/main/java/com/srsystem/controller/NotificationController.java b/backend/src/main/java/com/srsystem/controller/NotificationController.java new file mode 100644 index 0000000..5622986 --- /dev/null +++ b/backend/src/main/java/com/srsystem/controller/NotificationController.java @@ -0,0 +1,51 @@ +package com.srsystem.controller; + +import com.srsystem.config.jwt.CurrentUser; +import com.srsystem.dto.ApiResponse; +import com.srsystem.entity.Employee; +import com.srsystem.entity.Notification; +import com.srsystem.service.NotificationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@Tag(name = "Notification", description = "알림 관리 API") +@RestController +@RequestMapping("/api/v1/notifications") +@RequiredArgsConstructor +@SecurityRequirement(name = "bearerAuth") +public class NotificationController { + + private final NotificationService notificationService; + + @Operation(summary = "내 알림 목록 조회", description = "로그인한 사용자의 알림 목록을 조회합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @GetMapping + public ResponseEntity>> getMyNotifications(@CurrentUser Employee currentUser) { + List notifications = notificationService.getMyNotifications(currentUser.getEmpId()); + return ResponseEntity.ok(ApiResponse.ok(notifications)); + } + + @Operation(summary = "안읽은 알림 카운트", description = "안읽은 알림 개수를 조회합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @GetMapping("/unread") + public ResponseEntity>> getUnreadCount(@CurrentUser Employee currentUser) { + long count = notificationService.getUnreadCount(currentUser.getEmpId()); + return ResponseEntity.ok(ApiResponse.ok(Map.of("count", count))); + } + + @Operation(summary = "알림 읽음 처리", description = "알림을 읽음으로 표시합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @PutMapping("/{notificationId}/read") + public ResponseEntity> markAsRead(@PathVariable Long notificationId) { + notificationService.markAsRead(notificationId); + return ResponseEntity.ok(ApiResponse.ok("알림이 읽음 처리되었습니다", null)); + } +} diff --git a/backend/src/main/java/com/srsystem/controller/ParkingController.java b/backend/src/main/java/com/srsystem/controller/ParkingController.java new file mode 100644 index 0000000..6e923df --- /dev/null +++ b/backend/src/main/java/com/srsystem/controller/ParkingController.java @@ -0,0 +1,39 @@ +package com.srsystem.controller; + +import com.srsystem.dto.ApiResponse; +import com.srsystem.dto.HealthResponse; +import com.srsystem.dto.WakeRequest; +import com.srsystem.dto.WakeResponse; +import com.srsystem.service.ParkingService; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1") +@Tag(name = "Parking") +public class ParkingController { + + private final ParkingService parkingService; + + public ParkingController(ParkingService parkingService) { + this.parkingService = parkingService; + } + + @GetMapping("/health") + public ResponseEntity> checkHealth() { + HealthResponse response = parkingService.checkStatus(); + return ResponseEntity.ok(ApiResponse.ok(response)); + } + + @PostMapping("/wake") + public ResponseEntity> requestWake(@Valid @RequestBody WakeRequest request) { + WakeResponse response = parkingService.requestWake(request); + return ResponseEntity.ok(ApiResponse.ok("Wake request processed successfully", response)); + } +} diff --git a/backend/src/main/java/com/srsystem/controller/ProjectController.java b/backend/src/main/java/com/srsystem/controller/ProjectController.java new file mode 100644 index 0000000..41b8852 --- /dev/null +++ b/backend/src/main/java/com/srsystem/controller/ProjectController.java @@ -0,0 +1,84 @@ +package com.srsystem.controller; + +import com.srsystem.config.jwt.CurrentUser; +import com.srsystem.dto.ApiResponse; +import com.srsystem.dto.CreateProjectRequest; +import com.srsystem.entity.Employee; +import com.srsystem.entity.Project; +import com.srsystem.entity.enums.ProjectStatus; +import com.srsystem.service.ProjectService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "Project", description = "프로젝트 관리 API") +@RestController +@RequestMapping("/api/v1/projects") +@RequiredArgsConstructor +@SecurityRequirement(name = "bearerAuth") +public class ProjectController { + + private final ProjectService projectService; + + @Operation(summary = "내 프로젝트 목록 조회", description = "로그인한 사용자가 참여 중인 프로젝트 목록을 조회합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @GetMapping("/my") + public ResponseEntity>> getMyProjects(@CurrentUser Employee currentUser) { + List projects = projectService.getMyProjects(currentUser.getEmpId()); + return ResponseEntity.ok(ApiResponse.ok(projects)); + } + + @Operation(summary = "프로젝트 단건 조회", description = "프로젝트 ID 로 단일 프로젝트 정보를 조회합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @GetMapping("/{projectId}") + public ResponseEntity> getProject(@PathVariable Long projectId) { + Project project = projectService.getProject(projectId); + return ResponseEntity.ok(ApiResponse.ok(project)); + } + + @Operation(summary = "프로젝트 목록 조회", description = "전체 프로젝트 목록을 조회합니다. 상태 필터 옵션 포함") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @Transactional(readOnly = true) + @GetMapping + public ResponseEntity>> findAll( + @RequestParam(required = false) ProjectStatus status) { + List projects = projectService.findAll(status); + return ResponseEntity.ok(ApiResponse.ok(projects)); + } + + @Operation(summary = "프로젝트 생성", description = "새로운 프로젝트를 등록합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @PostMapping + public ResponseEntity> create( + @RequestBody @Valid CreateProjectRequest request) { + Project project = projectService.create(request); + return ResponseEntity.ok(ApiResponse.ok("프로젝트가 등록되었습니다", project)); + } + + @Operation(summary = "프로젝트 수정", description = "프로젝트 정보를 수정합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @PutMapping("/{projectId}") + public ResponseEntity> update( + @PathVariable Long projectId, + @RequestBody @Valid CreateProjectRequest request) { + Project project = projectService.update(projectId, request); + return ResponseEntity.ok(ApiResponse.ok("프로젝트가 수정되었습니다", project)); + } + + @Operation(summary = "프로젝트 삭제", description = "프로젝트를 삭제합니다 (Soft Delete)") + @PreAuthorize("hasRole('ADMIN')") + @DeleteMapping("/{projectId}") + public ResponseEntity> delete( + @PathVariable Long projectId) { + projectService.delete(projectId); + return ResponseEntity.ok(ApiResponse.ok("프로젝트가 삭제되었습니다", null)); + } +} diff --git a/backend/src/main/java/com/srsystem/controller/ReportController.java b/backend/src/main/java/com/srsystem/controller/ReportController.java new file mode 100644 index 0000000..b0783f6 --- /dev/null +++ b/backend/src/main/java/com/srsystem/controller/ReportController.java @@ -0,0 +1,129 @@ +package com.srsystem.controller; + +import com.srsystem.config.jwt.CurrentUser; +import com.srsystem.dto.ApiResponse; +import com.srsystem.dto.CreateReportRequest; +import com.srsystem.dto.ReportSummary; +import com.srsystem.dto.ReviewReportRequest; +import com.srsystem.dto.TicketSummary; +import com.srsystem.dto.WorkReportResponse; +import com.srsystem.entity.Employee; +import com.srsystem.entity.enums.RoleCode; +import com.srsystem.entity.enums.ReportStatus; +import com.srsystem.entity.enums.ReportType; +import com.srsystem.service.ReportService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@Tag(name = "Report", description = "업무 보고 관리 API") +@RestController +@RequestMapping("/api/v1/reports") +@RequiredArgsConstructor +@SecurityRequirement(name = "bearerAuth") +public class ReportController { + + private final ReportService reportService; + + @Operation(summary = "보고서 목록 조회", description = "필터 조건으로 보고서 목록을 조회합니다. EMPLOYEE 는 본인만, MANAGER+ 는 전체") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @GetMapping + public ResponseEntity>> getReports( + @RequestParam(required = false) ReportStatus status, + @RequestParam(required = false) String type, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate targetDate, + @PageableDefault(size = 20) Pageable pageable, + @CurrentUser Employee currentUser) { + + Page reports; + + if (currentUser.getRoleCode() == RoleCode.EMPLOYEE) { + // Employee sees only their own reports + reports = reportService.findByUserId(currentUser.getEmpId(), status, type, targetDate, pageable); + } else { + // Manager/Admin see all reports, with SUBMITTED first + reports = reportService.getReports(status, type, targetDate, null, pageable); + } + + return ResponseEntity.ok(ApiResponse.ok(reports)); + } + + @Operation(summary = "보고서 단건 조회", description = "보고서 ID 로 단일 보고서 정보를 조회합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @GetMapping("/{reportId}") + public ResponseEntity> getReport(@PathVariable Long reportId) { + WorkReportResponse report = reportService.getReport(reportId); + return ResponseEntity.ok(ApiResponse.ok(report)); + } + + @Operation(summary = "보고서 작성", description = "새로운 업무 보고서를 작성합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @PostMapping + public ResponseEntity> createReport( + @Valid @RequestBody CreateReportRequest request, + @CurrentUser Employee currentUser) { + WorkReportResponse report = reportService.createReport(currentUser.getEmpId(), request); + return ResponseEntity.ok(ApiResponse.ok("보고서가 작성되었습니다", report)); + } + + @Operation(summary = "보고서 상태 변경", description = "보고서 상태를 변경합니다 (DRAFT→SUBMITTED)") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @PutMapping("/{reportId}/status") + public ResponseEntity> updateStatus( + @PathVariable Long reportId, + @RequestBody Map body, + @CurrentUser Employee currentUser) { + ReportStatus newStatus = ReportStatus.valueOf(body.get("status")); + WorkReportResponse report = reportService.updateStatus(reportId, currentUser.getEmpId(), newStatus); + return ResponseEntity.ok(ApiResponse.ok("보고서 상태가 변경되었습니다", report)); + } + + @Operation(summary = "보고서 결재", description = "관리자가 보고서를 승인/반려합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @PutMapping("/{reportId}/review") + public ResponseEntity> reviewReport( + @PathVariable Long reportId, + @Valid @RequestBody ReviewReportRequest request, + @CurrentUser Employee currentUser) { + WorkReportResponse report = reportService.reviewReport(reportId, currentUser.getEmpId(), request); + return ResponseEntity.ok(ApiResponse.ok("보고서가 결재되었습니다", report)); + } + + @Operation(summary = "보고서 삭제", description = "보고서를 삭제합니다 (LOCKED 상태는 불가)") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @DeleteMapping("/{reportId}") + public ResponseEntity> deleteReport(@PathVariable Long reportId) { + reportService.deleteReport(reportId); + return ResponseEntity.ok(ApiResponse.ok("보고서가 삭제되었습니다", null)); + } + + @Operation(summary = "보고서 결재 요약 조회", description = "상태별 보고서 개수를 반환합니다 (MANAGER/ADMIN)") + @PreAuthorize("hasAnyRole('MANAGER','ADMIN')") + @GetMapping("/summary") + public ResponseEntity> getSummary() { + return ResponseEntity.ok(ApiResponse.ok(reportService.getSummary())); + } + + @Operation(summary = "SR 자동 완성", description = "해당 일자의 해결/종료된 SR 내역을 조회합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @GetMapping("/sr-auto-complete") + public ResponseEntity>> getSrAutoComplete( + @RequestParam String date, + @CurrentUser Employee currentUser) { + List tickets = reportService.getResolvedTicketsForDate(currentUser.getEmpId(), date); + return ResponseEntity.ok(ApiResponse.ok(tickets)); + } +} diff --git a/backend/src/main/java/com/srsystem/controller/TicketController.java b/backend/src/main/java/com/srsystem/controller/TicketController.java new file mode 100644 index 0000000..0570c63 --- /dev/null +++ b/backend/src/main/java/com/srsystem/controller/TicketController.java @@ -0,0 +1,131 @@ +package com.srsystem.controller; + +import com.srsystem.config.jwt.CurrentUser; +import com.srsystem.dto.ApiResponse; +import com.srsystem.dto.CreateCommentRequest; +import com.srsystem.dto.CreateTicketRequest; +import com.srsystem.dto.IssueTicket; +import com.srsystem.dto.TicketCommentResponse; +import com.srsystem.entity.Employee; +import com.srsystem.entity.enums.RoleCode; +import com.srsystem.entity.enums.TicketCategory; +import com.srsystem.entity.enums.TicketPriority; +import com.srsystem.entity.enums.TicketStatus; +import com.srsystem.service.TicketService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@Tag(name = "Ticket", description = "SR 티켓 관리 API") +@RestController +@RequestMapping("/api/v1/tickets") +@RequiredArgsConstructor +@SecurityRequirement(name = "bearerAuth") +public class TicketController { + + private final TicketService ticketService; + + @Operation(summary = "티켓 목록 조회", description = "필터 조건으로 티켓 목록을 조회합니다. EMPLOYEE 는 본인 티켓만, MANAGER/ADMIN 은 전체 조회") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @GetMapping + public ResponseEntity>> getTickets( + @RequestParam(required = false) TicketStatus status, + @RequestParam(required = false) String category, + @RequestParam(required = false) String priority, + @RequestParam(required = false) String keyword, + @PageableDefault(size = 20) Pageable pageable, + @CurrentUser Employee currentUser) { + + Long requesterId = currentUser.getRoleCode() == RoleCode.EMPLOYEE ? currentUser.getEmpId() : null; + Page tickets = ticketService.getTickets(status, category, priority, keyword, pageable, requesterId); + return ResponseEntity.ok(ApiResponse.ok(tickets)); + } + + @Operation(summary = "티켓 단건 조회", description = "티켓 ID 로 단일 티켓 정보를 조회합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @GetMapping("/{ticketId}") + public ResponseEntity> getTicket(@PathVariable Long ticketId) { + IssueTicket ticket = ticketService.getTicket(ticketId); + return ResponseEntity.ok(ApiResponse.ok(ticket)); + } + + @Operation(summary = "티켓 생성", description = "새로운 SR 티켓을 생성합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @PostMapping + public ResponseEntity> createTicket( + @Valid @RequestBody CreateTicketRequest request, + @CurrentUser Employee currentUser) { + IssueTicket ticket = ticketService.createTicket(currentUser.getEmpId(), request); + return ResponseEntity.ok(ApiResponse.ok("티켓이 등록되었습니다", ticket)); + } + + @Operation(summary = "티켓 상태 변경", description = "티켓 상태를 변경합니다 (OPEN→ASSIGNED→IN_PROGRESS→RESOLVED→CLOSED)") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @PutMapping("/{ticketId}/status") + public ResponseEntity> updateStatus( + @PathVariable Long ticketId, + @RequestBody Map body, + @CurrentUser Employee currentUser) { + TicketStatus newStatus = TicketStatus.valueOf(body.get("status")); + IssueTicket ticket = ticketService.updateStatus(ticketId, currentUser.getEmpId(), newStatus); + return ResponseEntity.ok(ApiResponse.ok("티켓 상태가 변경되었습니다", ticket)); + } + + @Operation(summary = "티켓 담당자 배정", description = "티켓에 담당자를 배정합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @PutMapping("/{ticketId}/assign") + public ResponseEntity> assignTicket( + @PathVariable Long ticketId, + @RequestBody Map body) { + IssueTicket ticket = ticketService.assignTicket(ticketId, body.get("assigneeId")); + return ResponseEntity.ok(ApiResponse.ok("티켓이 배정되었습니다", ticket)); + } + + @Operation(summary = "티켓 해결 처리", description = "티켓을 해결 처리하고 해결 노트를 기록합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @PutMapping("/{ticketId}/resolve") + public ResponseEntity> resolveTicket( + @PathVariable Long ticketId, + @RequestBody Map body) { + IssueTicket ticket = ticketService.resolveTicket(ticketId, body.get("resolutionNote")); + return ResponseEntity.ok(ApiResponse.ok("티켓이 해결되었습니다", ticket)); + } + + @Operation(summary = "티켓 삭제", description = "티켓을 삭제합니다 (Soft Delete)") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @DeleteMapping("/{ticketId}") + public ResponseEntity> deleteTicket(@PathVariable Long ticketId) { + ticketService.deleteTicket(ticketId); + return ResponseEntity.ok(ApiResponse.ok("티켓이 삭제되었습니다", null)); + } + + @Operation(summary = "댓글 목록 조회", description = "티켓의 댓글 목록을 조회합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @GetMapping("/{ticketId}/comments") + public ResponseEntity>> getComments(@PathVariable Long ticketId) { + List comments = ticketService.getComments(ticketId); + return ResponseEntity.ok(ApiResponse.ok(comments)); + } + + @Operation(summary = "댓글 등록", description = "티켓에 댓글을 등록합니다 (CLOSED 티켓은 불가)") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @PostMapping("/{ticketId}/comments") + public ResponseEntity> addComment( + @PathVariable Long ticketId, + @Valid @RequestBody CreateCommentRequest request, + @CurrentUser Employee currentUser) { + TicketCommentResponse response = ticketService.addComment(ticketId, currentUser.getEmpId(), request); + return ResponseEntity.ok(ApiResponse.ok("댓글이 등록되었습니다", response)); + } +} diff --git a/backend/src/main/java/com/srsystem/controller/TrainingController.java b/backend/src/main/java/com/srsystem/controller/TrainingController.java new file mode 100644 index 0000000..5f2276e --- /dev/null +++ b/backend/src/main/java/com/srsystem/controller/TrainingController.java @@ -0,0 +1,55 @@ +package com.srsystem.controller; + +import com.srsystem.config.jwt.CurrentUser; +import com.srsystem.dto.ApiResponse; +import com.srsystem.dto.TrainingSummary; +import com.srsystem.dto.UpdateProgressRequest; +import com.srsystem.entity.Employee; +import com.srsystem.entity.TrainingProgress; +import com.srsystem.service.TrainingService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "Training", description = "교육 관리 API") +@RestController +@RequestMapping("/api/v1/training") +@RequiredArgsConstructor +@SecurityRequirement(name = "bearerAuth") +public class TrainingController { + + private final TrainingService trainingService; + + @Operation(summary = "내 교육 진도 조회", description = "로그인한 사용자의 교육 진도 목록을 조회합니다") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @GetMapping + public ResponseEntity>> getMyProgress(@CurrentUser Employee currentUser) { + List progress = trainingService.getMyProgress(currentUser.getEmpId()); + return ResponseEntity.ok(ApiResponse.ok(progress)); + } + + @Operation(summary = "교육 진도 업데이트", description = "교육 진도율을 업데이트합니다") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @PutMapping("/{progressId}") + public ResponseEntity> updateProgress( + @PathVariable Long progressId, + @Valid @RequestBody UpdateProgressRequest request) { + TrainingProgress progress = trainingService.updateProgress(progressId, request); + return ResponseEntity.ok(ApiResponse.ok("교육 진도가 업데이트되었습니다", progress)); + } + + @Operation(summary = "전체 교육 현황 조회", description = "모든 사용자의 교육 이수 현황을 조회합니다 (관리자용)") + @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')") + @GetMapping("/summary") + public ResponseEntity>> getSummary() { + List summary = trainingService.getSummary(); + return ResponseEntity.ok(ApiResponse.ok(summary)); + } +} diff --git a/backend/src/main/java/com/srsystem/controller/WeeklyPlanController.java b/backend/src/main/java/com/srsystem/controller/WeeklyPlanController.java new file mode 100644 index 0000000..cb7b640 --- /dev/null +++ b/backend/src/main/java/com/srsystem/controller/WeeklyPlanController.java @@ -0,0 +1,72 @@ +package com.srsystem.controller; + +import com.srsystem.config.jwt.CurrentUser; +import com.srsystem.dto.ApiResponse; +import com.srsystem.entity.Employee; +import com.srsystem.entity.WeeklyPlan; +import com.srsystem.service.WeeklyPlanService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@Tag(name = "Weekly Plan", description = "주간 계획 관리 API") +@RestController +@RequestMapping("/api/v1/weekly-plans") +@RequiredArgsConstructor +@SecurityRequirement(name = "bearerAuth") +public class WeeklyPlanController { + + private final WeeklyPlanService weeklyPlanService; + + @GetMapping + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @Operation(summary = "내 주간 계획 목록 조회", description = "현재 사용자의 모든 주간 계획 목록을 조회합니다.") + public ResponseEntity>> getMyPlans(@CurrentUser Employee currentUser) { + return ResponseEntity.ok(ApiResponse.ok(weeklyPlanService.getMyPlans(currentUser.getEmpId()))); + } + + @GetMapping("/{planId}") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @Operation(summary = "주간 계획 상세 조회", description = "특정 주간 계획의 상세 정보를 조회합니다.") + public ResponseEntity> getPlan(@PathVariable Long planId) { + return ResponseEntity.ok(ApiResponse.ok(weeklyPlanService.getPlan(planId))); + } + + @PostMapping + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @Operation(summary = "주간 계획 생성", description = "새로운 주간 계획을 생성합니다.") + public ResponseEntity> createPlan( + @RequestBody Map body, + @CurrentUser Employee currentUser) { + WeeklyPlan plan = weeklyPlanService.createPlan(currentUser.getEmpId(), body.get("tasks")); + return ResponseEntity.ok(ApiResponse.ok("주간 계획이 생성되었습니다", plan)); + } + + @PutMapping("/{planId}") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @Operation(summary = "주간 계획 수정", description = "기존 주간 계획의 태스크와 상태를 수정합니다.") + public ResponseEntity> updatePlan( + @PathVariable Long planId, + @RequestBody Map body) { + String statusStr = body.get("status"); + com.srsystem.entity.enums.WeeklyPlanStatus status = statusStr != null ? + com.srsystem.entity.enums.WeeklyPlanStatus.valueOf(statusStr) : null; + WeeklyPlan plan = weeklyPlanService.updatePlan(planId, body.get("tasks"), status); + return ResponseEntity.ok(ApiResponse.ok("주간 계획이 수정되었습니다", plan)); + } + + @DeleteMapping("/{planId}") + @PreAuthorize("hasAnyRole('EMPLOYEE', 'MANAGER', 'ADMIN')") + @Operation(summary = "주간 계획 삭제", description = "주간 계획을 삭제합니다 (Soft Delete).") + public ResponseEntity> deletePlan(@PathVariable Long planId) { + weeklyPlanService.deletePlan(planId); + return ResponseEntity.ok(ApiResponse.ok("주간 계획이 삭제되었습니다", null)); + } +} diff --git a/backend/src/main/java/com/srsystem/dto/ApiResponse.java b/backend/src/main/java/com/srsystem/dto/ApiResponse.java new file mode 100644 index 0000000..2dfc360 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/ApiResponse.java @@ -0,0 +1,16 @@ +package com.srsystem.dto; + +public record ApiResponse(boolean success, String message, T data) { + + public static ApiResponse ok(T data) { + return new ApiResponse<>(true, "OK", data); + } + + public static ApiResponse ok(String message, T data) { + return new ApiResponse<>(true, message, data); + } + + public static ApiResponse error(String message) { + return new ApiResponse<>(false, message, null); + } +} diff --git a/backend/src/main/java/com/srsystem/dto/CalendarEvent.java b/backend/src/main/java/com/srsystem/dto/CalendarEvent.java new file mode 100644 index 0000000..de3569d --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/CalendarEvent.java @@ -0,0 +1,10 @@ +package com.srsystem.dto; + +public record CalendarEvent( + String id, + String title, + String date, + String type, + String status, + String endDate +) {} diff --git a/backend/src/main/java/com/srsystem/dto/ChangePasswordRequest.java b/backend/src/main/java/com/srsystem/dto/ChangePasswordRequest.java new file mode 100644 index 0000000..74ae54a --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/ChangePasswordRequest.java @@ -0,0 +1,8 @@ +package com.srsystem.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ChangePasswordRequest( + @NotBlank String currentPassword, + @NotBlank String newPassword +) {} diff --git a/backend/src/main/java/com/srsystem/dto/CommonCodeRequest.java b/backend/src/main/java/com/srsystem/dto/CommonCodeRequest.java new file mode 100644 index 0000000..24154c1 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/CommonCodeRequest.java @@ -0,0 +1,14 @@ +package com.srsystem.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record CommonCodeRequest( + @NotBlank String groupCode, + @NotBlank String codeValue, + @NotBlank String codeName, + String optionVal1, + String optionVal2, + @NotNull Integer sortOrder, + @NotNull Character isActive +) {} diff --git a/backend/src/main/java/com/srsystem/dto/CreateCommentRequest.java b/backend/src/main/java/com/srsystem/dto/CreateCommentRequest.java new file mode 100644 index 0000000..18d42a1 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/CreateCommentRequest.java @@ -0,0 +1,9 @@ +package com.srsystem.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CreateCommentRequest( + @NotBlank String commentText, + String attachedFileUrl +) { +} diff --git a/backend/src/main/java/com/srsystem/dto/CreateDispatchRequest.java b/backend/src/main/java/com/srsystem/dto/CreateDispatchRequest.java new file mode 100644 index 0000000..569abe2 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/CreateDispatchRequest.java @@ -0,0 +1,16 @@ +package com.srsystem.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; + +public record CreateDispatchRequest( + @NotNull Long employeeId, + @NotNull Long projectId, + @NotBlank String roleTitle, + @NotNull Integer contributionRate, + @NotBlank String individualTech, + @NotBlank String performedTasks, + LocalDate startDate +) {} diff --git a/backend/src/main/java/com/srsystem/dto/CreateEquipmentRequest.java b/backend/src/main/java/com/srsystem/dto/CreateEquipmentRequest.java new file mode 100644 index 0000000..3bb85f2 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/CreateEquipmentRequest.java @@ -0,0 +1,21 @@ +package com.srsystem.dto; + +import com.srsystem.entity.enums.EquipmentStatus; +import com.srsystem.entity.enums.EquipmentType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; + +public record CreateEquipmentRequest( + @NotNull EquipmentType equipmentType, + @NotBlank String manufacturer, + @NotBlank String modelName, + @NotBlank String serialNumber, + @NotBlank String assetCode, + String specifications, + @NotNull EquipmentStatus status, + @NotNull LocalDate purchaseDate, + LocalDate warrantyExpDate +) { +} diff --git a/backend/src/main/java/com/srsystem/dto/CreateMaintenanceRequest.java b/backend/src/main/java/com/srsystem/dto/CreateMaintenanceRequest.java new file mode 100644 index 0000000..f861b90 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/CreateMaintenanceRequest.java @@ -0,0 +1,16 @@ +package com.srsystem.dto; + +import com.srsystem.entity.enums.MaintenanceType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; + +public record CreateMaintenanceRequest( + @NotNull MaintenanceType maintenanceType, + @NotBlank String issueDescription, + String actionTaken, + @NotNull BigDecimal cost, + @NotBlank String reportedBy +) { +} diff --git a/backend/src/main/java/com/srsystem/dto/CreateProjectRequest.java b/backend/src/main/java/com/srsystem/dto/CreateProjectRequest.java new file mode 100644 index 0000000..3c67b1d --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/CreateProjectRequest.java @@ -0,0 +1,44 @@ +package com.srsystem.dto; + +import com.srsystem.entity.enums.ContractType; +import com.srsystem.entity.enums.ProjectType; +import com.srsystem.entity.enums.WorkLocationType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDate; + +public record CreateProjectRequest( + @NotBlank @Size(max = 255) + String projectName, + + @NotNull + ProjectType projectType, + + @NotBlank @Size(max = 255) + String contractCompany, + + @Size(max = 255) + String endUserCompany, + + @NotNull + ContractType contractType, + + @NotNull + LocalDate startDate, + + LocalDate endDate, + + @NotNull + WorkLocationType workLocationType, + + @Size(max = 500) + String workAddress, + + String techEnvironment, + + String mainTaskDesc, + + Long internalManagerId +) {} diff --git a/backend/src/main/java/com/srsystem/dto/CreateReportRequest.java b/backend/src/main/java/com/srsystem/dto/CreateReportRequest.java new file mode 100644 index 0000000..bc02d3a --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/CreateReportRequest.java @@ -0,0 +1,23 @@ +package com.srsystem.dto; + +import com.srsystem.entity.enums.ReportType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public record CreateReportRequest( + Long dispatchId, + @NotNull ReportType reportType, + @NotNull LocalDate targetDate, + @NotBlank String completedTasks, + @NotBlank String plannedTasks, + String issuesAndRisks, + String supportRequested, + BigDecimal workHours, + String blockerFlag, + String artifacts, + Long weeklyPlanId +) { +} diff --git a/backend/src/main/java/com/srsystem/dto/CreateTicketRequest.java b/backend/src/main/java/com/srsystem/dto/CreateTicketRequest.java new file mode 100644 index 0000000..54219d4 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/CreateTicketRequest.java @@ -0,0 +1,15 @@ +package com.srsystem.dto; + +import com.srsystem.entity.enums.TicketCategory; +import com.srsystem.entity.enums.TicketPriority; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record CreateTicketRequest( + @NotNull Long projectId, + @NotNull TicketCategory category, + @NotNull TicketPriority priority, + @NotBlank String title, + @NotBlank String description +) { +} diff --git a/backend/src/main/java/com/srsystem/dto/EmployeeSearchResult.java b/backend/src/main/java/com/srsystem/dto/EmployeeSearchResult.java new file mode 100644 index 0000000..b82be8b --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/EmployeeSearchResult.java @@ -0,0 +1,9 @@ +package com.srsystem.dto; + +public record EmployeeSearchResult( + Long empId, + String name, + String email, + String department +) { +} diff --git a/backend/src/main/java/com/srsystem/dto/EmployeeSummary.java b/backend/src/main/java/com/srsystem/dto/EmployeeSummary.java new file mode 100644 index 0000000..f615aa9 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/EmployeeSummary.java @@ -0,0 +1,19 @@ +package com.srsystem.dto; + +import com.srsystem.entity.enums.DispatchStatus; +import com.srsystem.entity.enums.EmpStatus; +import com.srsystem.entity.enums.RoleCode; + +import java.time.LocalDate; + +public record EmployeeSummary( + Long empId, + String name, + String email, + String phone, + RoleCode roleCode, + EmpStatus empStatus, + DispatchStatus dispatchStatus, + LocalDate joinedDate, + LocalDate leftDate +) {} diff --git a/backend/src/main/java/com/srsystem/dto/EquipmentSummary.java b/backend/src/main/java/com/srsystem/dto/EquipmentSummary.java new file mode 100644 index 0000000..f4c90e7 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/EquipmentSummary.java @@ -0,0 +1,8 @@ +package com.srsystem.dto; + +import java.util.Map; + +public record EquipmentSummary( + Map counts +) { +} diff --git a/backend/src/main/java/com/srsystem/dto/HealthResponse.java b/backend/src/main/java/com/srsystem/dto/HealthResponse.java new file mode 100644 index 0000000..813dc2e --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/HealthResponse.java @@ -0,0 +1,4 @@ +package com.srsystem.dto; + +public record HealthResponse(boolean parked, String message, String resumeTime, String parkingSchedule) { +} diff --git a/backend/src/main/java/com/srsystem/dto/IssueTicket.java b/backend/src/main/java/com/srsystem/dto/IssueTicket.java new file mode 100644 index 0000000..7a4f54e --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/IssueTicket.java @@ -0,0 +1,25 @@ +package com.srsystem.dto; + +import com.srsystem.entity.enums.TicketCategory; +import com.srsystem.entity.enums.TicketPriority; +import com.srsystem.entity.enums.TicketStatus; + +import java.time.LocalDateTime; + +public record IssueTicket( + Long ticketId, + String projectName, + String requesterName, + String assigneeName, + TicketCategory category, + TicketPriority priority, + String title, + String description, + TicketStatus status, + LocalDateTime dueDate, + LocalDateTime resolvedAt, + String resolutionNote, + boolean slaWarnFlag, + LocalDateTime createdAt +) { +} diff --git a/backend/src/main/java/com/srsystem/dto/LoginRequest.java b/backend/src/main/java/com/srsystem/dto/LoginRequest.java new file mode 100644 index 0000000..c6ebaf5 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/LoginRequest.java @@ -0,0 +1,13 @@ +package com.srsystem.dto; + +import com.srsystem.entity.enums.DispatchStatus; +import com.srsystem.entity.enums.RoleCode; +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank(message = "로그인 ID 는 필수입니다") + String loginId, + @NotBlank(message = "비밀번호는 필수입니다") + String password +) { +} diff --git a/backend/src/main/java/com/srsystem/dto/LoginResponse.java b/backend/src/main/java/com/srsystem/dto/LoginResponse.java new file mode 100644 index 0000000..8b601a8 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/LoginResponse.java @@ -0,0 +1,11 @@ +package com.srsystem.dto; + +import com.srsystem.entity.enums.DispatchStatus; +import com.srsystem.entity.enums.RoleCode; + +public record LoginResponse( + String accessToken, + String refreshToken, + EmployeeSummary employee +) { +} diff --git a/backend/src/main/java/com/srsystem/dto/NotificationEvent.java b/backend/src/main/java/com/srsystem/dto/NotificationEvent.java new file mode 100644 index 0000000..8da08b3 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/NotificationEvent.java @@ -0,0 +1,20 @@ +package com.srsystem.dto; + +import lombok.Getter; + +@Getter +public class NotificationEvent { + private final String type; + private final String title; + private final String message; + private final String linkUrl; + private final Long recipientId; + + public NotificationEvent(String type, String title, String message, String linkUrl, Long recipientId) { + this.type = type; + this.title = title; + this.message = message; + this.linkUrl = linkUrl; + this.recipientId = recipientId; + } +} diff --git a/backend/src/main/java/com/srsystem/dto/PortfolioResponse.java b/backend/src/main/java/com/srsystem/dto/PortfolioResponse.java new file mode 100644 index 0000000..47108c2 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/PortfolioResponse.java @@ -0,0 +1,74 @@ +package com.srsystem.dto; + +import java.time.LocalDate; +import java.util.List; + +public class PortfolioResponse { + + private Long dispatchId; + private String projectName; + private String roleTitle; + private Integer contributionRate; + private String individualTech; + private String performedTasks; + private String achievements; + private LocalDate startDate; + private LocalDate endDate; + private Integer internalRating; + private List reports; + private List resolvedTickets; + + // Inner classes + public static class BoundReport { + public String targetDate; + public String reportType; + public String completedTasks; + } + + public static class BoundTicket { + public Long ticketId; + public String title; + public String category; + public String resolutionNote; + } + + public PortfolioResponse() {} + + // --- Getters & Setters --- + + public Long getDispatchId() { return dispatchId; } + public void setDispatchId(Long dispatchId) { this.dispatchId = dispatchId; } + + public String getProjectName() { return projectName; } + public void setProjectName(String projectName) { this.projectName = projectName; } + + public String getRoleTitle() { return roleTitle; } + public void setRoleTitle(String roleTitle) { this.roleTitle = roleTitle; } + + public Integer getContributionRate() { return contributionRate; } + public void setContributionRate(Integer contributionRate) { this.contributionRate = contributionRate; } + + public String getIndividualTech() { return individualTech; } + public void setIndividualTech(String individualTech) { this.individualTech = individualTech; } + + public String getPerformedTasks() { return performedTasks; } + public void setPerformedTasks(String performedTasks) { this.performedTasks = performedTasks; } + + public String getAchievements() { return achievements; } + public void setAchievements(String achievements) { this.achievements = achievements; } + + public LocalDate getStartDate() { return startDate; } + public void setStartDate(LocalDate startDate) { this.startDate = startDate; } + + public LocalDate getEndDate() { return endDate; } + public void setEndDate(LocalDate endDate) { this.endDate = endDate; } + + public Integer getInternalRating() { return internalRating; } + public void setInternalRating(Integer internalRating) { this.internalRating = internalRating; } + + public List getReports() { return reports; } + public void setReports(List reports) { this.reports = reports; } + + public List getResolvedTickets() { return resolvedTickets; } + public void setResolvedTickets(List resolvedTickets) { this.resolvedTickets = resolvedTickets; } +} diff --git a/backend/src/main/java/com/srsystem/dto/RefreshTokenRequest.java b/backend/src/main/java/com/srsystem/dto/RefreshTokenRequest.java new file mode 100644 index 0000000..cfdcfab --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/RefreshTokenRequest.java @@ -0,0 +1,9 @@ +package com.srsystem.dto; + +import jakarta.validation.constraints.NotBlank; + +public record RefreshTokenRequest( + @NotBlank(message = "리프레시 토큰은 필수입니다") + String refreshToken +) { +} diff --git a/backend/src/main/java/com/srsystem/dto/ReportSummary.java b/backend/src/main/java/com/srsystem/dto/ReportSummary.java new file mode 100644 index 0000000..6fca4d9 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/ReportSummary.java @@ -0,0 +1,8 @@ +package com.srsystem.dto; + +public record ReportSummary( + long submitted, + long approved, + long rejected, + long draft +) {} diff --git a/backend/src/main/java/com/srsystem/dto/ReviewReportRequest.java b/backend/src/main/java/com/srsystem/dto/ReviewReportRequest.java new file mode 100644 index 0000000..cd37e3c --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/ReviewReportRequest.java @@ -0,0 +1,11 @@ +package com.srsystem.dto; + +import com.srsystem.entity.enums.ReportStatus; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record ReviewReportRequest( + @NotNull ReportStatus status, + String reviewComment +) { +} diff --git a/backend/src/main/java/com/srsystem/dto/TicketCommentResponse.java b/backend/src/main/java/com/srsystem/dto/TicketCommentResponse.java new file mode 100644 index 0000000..c495307 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/TicketCommentResponse.java @@ -0,0 +1,12 @@ +package com.srsystem.dto; + +import java.time.LocalDateTime; + +public record TicketCommentResponse( + Long commentId, + String userName, + String commentText, + String attachedFileUrl, + LocalDateTime createdAt +) { +} diff --git a/backend/src/main/java/com/srsystem/dto/TicketSummary.java b/backend/src/main/java/com/srsystem/dto/TicketSummary.java new file mode 100644 index 0000000..3ff2c86 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/TicketSummary.java @@ -0,0 +1,10 @@ +package com.srsystem.dto; + +public record TicketSummary( + Long ticketId, + String title, + String category, + String priority, + String resolutionNote, + String resolvedAt +) {} diff --git a/backend/src/main/java/com/srsystem/dto/TrainingSummary.java b/backend/src/main/java/com/srsystem/dto/TrainingSummary.java new file mode 100644 index 0000000..559b339 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/TrainingSummary.java @@ -0,0 +1,10 @@ +package com.srsystem.dto; + +public record TrainingSummary( + Long userId, + String userName, + long totalCourses, + long completedCount, + double avgProgressRate +) { +} diff --git a/backend/src/main/java/com/srsystem/dto/UpdateAchievementsRequest.java b/backend/src/main/java/com/srsystem/dto/UpdateAchievementsRequest.java new file mode 100644 index 0000000..12df46a --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/UpdateAchievementsRequest.java @@ -0,0 +1,12 @@ +package com.srsystem.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class UpdateAchievementsRequest { + private String achievements; +} diff --git a/backend/src/main/java/com/srsystem/dto/UpdateEquipmentRequest.java b/backend/src/main/java/com/srsystem/dto/UpdateEquipmentRequest.java new file mode 100644 index 0000000..1f90877 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/UpdateEquipmentRequest.java @@ -0,0 +1,19 @@ +package com.srsystem.dto; + +import com.srsystem.entity.enums.EquipmentStatus; +import com.srsystem.entity.enums.EquipmentType; + +import java.time.LocalDate; + +public record UpdateEquipmentRequest( + EquipmentType equipmentType, + String manufacturer, + String modelName, + String serialNumber, + String assetCode, + String specifications, + EquipmentStatus status, + LocalDate purchaseDate, + LocalDate warrantyExpDate +) { +} diff --git a/backend/src/main/java/com/srsystem/dto/UpdateProgressRequest.java b/backend/src/main/java/com/srsystem/dto/UpdateProgressRequest.java new file mode 100644 index 0000000..d826d99 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/UpdateProgressRequest.java @@ -0,0 +1,11 @@ +package com.srsystem.dto; + +import com.srsystem.entity.enums.TrainingStatus; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +public record UpdateProgressRequest( + @Min(0) @Max(100) Integer progressRate, + TrainingStatus status +) { +} diff --git a/backend/src/main/java/com/srsystem/dto/WakeRequest.java b/backend/src/main/java/com/srsystem/dto/WakeRequest.java new file mode 100644 index 0000000..f25eefb --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/WakeRequest.java @@ -0,0 +1,11 @@ +package com.srsystem.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record WakeRequest( + @NotBlank String requestedBy, + @NotBlank String reason, + @NotNull String requestedAt +) { +} diff --git a/backend/src/main/java/com/srsystem/dto/WakeResponse.java b/backend/src/main/java/com/srsystem/dto/WakeResponse.java new file mode 100644 index 0000000..385b972 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/WakeResponse.java @@ -0,0 +1,4 @@ +package com.srsystem.dto; + +public record WakeResponse(boolean success, String message, String estimatedWakeTime) { +} diff --git a/backend/src/main/java/com/srsystem/dto/WorkReportResponse.java b/backend/src/main/java/com/srsystem/dto/WorkReportResponse.java new file mode 100644 index 0000000..8e77af9 --- /dev/null +++ b/backend/src/main/java/com/srsystem/dto/WorkReportResponse.java @@ -0,0 +1,29 @@ +package com.srsystem.dto; + +import com.srsystem.entity.enums.ReportStatus; +import com.srsystem.entity.enums.ReportType; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +public record WorkReportResponse( + Long reportId, + String userName, + String projectName, + ReportType reportType, + LocalDate targetDate, + String completedTasks, + String plannedTasks, + String issuesAndRisks, + String supportRequested, + ReportStatus status, + String reviewerName, + String reviewComment, + LocalDateTime reviewedAt, + LocalDateTime createdAt, + BigDecimal workHours, + String blockerFlag, + String artifacts +) { +} diff --git a/backend/src/main/java/com/srsystem/entity/BaseEntity.java b/backend/src/main/java/com/srsystem/entity/BaseEntity.java new file mode 100644 index 0000000..40dea3a --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/BaseEntity.java @@ -0,0 +1,38 @@ +package com.srsystem.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@SQLDelete(sql = "UPDATE #{#entityName} SET is_deleted = 'Y' WHERE id = ?") +@SQLRestriction("is_deleted = 'N'") +@Getter +@Setter +public abstract class BaseEntity { + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Column(name = "is_deleted", nullable = false, length = 1) + private char isDeleted = 'N'; + + public void setIsDeleted(char isDeleted) { + this.isDeleted = isDeleted; + } +} diff --git a/backend/src/main/java/com/srsystem/entity/CommonCode.java b/backend/src/main/java/com/srsystem/entity/CommonCode.java new file mode 100644 index 0000000..6f46ce0 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/CommonCode.java @@ -0,0 +1,45 @@ +package com.srsystem.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Table(name = "tb_common_code") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@SQLDelete(sql = "UPDATE tb_common_code SET is_deleted = 'Y' WHERE id = ?") +@SQLRestriction("is_deleted = 'N'") +public class CommonCode extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sq_common_codes") + @SequenceGenerator(name = "sq_common_codes", sequenceName = "sq_common_codes", allocationSize = 50) + @Column(name = "id") + private Long codeId; + + @Column(name = "group_code", nullable = false, length = 50) + private String groupCode; + + @Column(name = "code_value", nullable = false, length = 100) + private String codeValue; + + @Column(name = "code_name", nullable = false, length = 255) + private String codeName; + + @Column(name = "option_val1", length = 255) + private String optionVal1; + + @Column(name = "option_val2", length = 255) + private String optionVal2; + + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @Column(name = "is_active", nullable = false, length = 1) + private char isActive = 'Y'; +} diff --git a/backend/src/main/java/com/srsystem/entity/DailyTaskProgress.java b/backend/src/main/java/com/srsystem/entity/DailyTaskProgress.java new file mode 100644 index 0000000..67eecd0 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/DailyTaskProgress.java @@ -0,0 +1,43 @@ +package com.srsystem.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.math.BigDecimal; + +@Entity +@Table(name = "tb_daily_task_progress") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@SQLDelete(sql = "UPDATE tb_daily_task_progress SET is_deleted = 'Y' WHERE id = ?") +@SQLRestriction("is_deleted = 'N'") +public class DailyTaskProgress extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sq_daily_task_progress") + @SequenceGenerator(name = "sq_daily_task_progress", sequenceName = "sq_daily_task_progress", allocationSize = 50) + @Column(name = "id") + private Long progressId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "report_id", nullable = false) + private Report report; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "weekly_plan_id", nullable = false) + private WeeklyPlan weeklyPlan; + + @Column(name = "task_name", nullable = false, length = 500) + private String taskName; + + @Column(name = "progress_rate", nullable = false) + private Integer progressRate; + + @Column(name = "work_hours", nullable = false) + private BigDecimal workHours; +} diff --git a/backend/src/main/java/com/srsystem/entity/DispatchHistory.java b/backend/src/main/java/com/srsystem/entity/DispatchHistory.java new file mode 100644 index 0000000..e079aa7 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/DispatchHistory.java @@ -0,0 +1,64 @@ +package com.srsystem.entity; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.LocalDate; + +@Entity +@Table(name = "tb_dispatch_history") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@SQLDelete(sql = "UPDATE tb_dispatch_history SET is_deleted = 'Y' WHERE id = ?") +@SQLRestriction("is_deleted = 'N'") +public class DispatchHistory extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sq_dispatch_histories") + @SequenceGenerator(name = "sq_dispatch_histories", sequenceName = "sq_dispatch_histories", allocationSize = 50) + @Column(name = "id") + private Long dispatchId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "employee_id", nullable = false) + @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) + private Employee employee; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id", nullable = false) + @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) + private Project project; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date") + private LocalDate endDate; + + @Column(name = "role_title", nullable = false, length = 255) + private String roleTitle; + + @Column(name = "contribution_rate", nullable = false) + private Integer contributionRate; + + @Column(name = "individual_tech", columnDefinition = "TEXT", nullable = false) + private String individualTech; + + @Column(name = "performed_tasks", columnDefinition = "TEXT", nullable = false) + private String performedTasks; + + @Column(name = "achievements", columnDefinition = "TEXT") + private String achievements; + + @Column(name = "client_feedback", columnDefinition = "TEXT") + private String clientFeedback; + + @Column(name = "internal_rating") + private Integer internalRating; +} diff --git a/backend/src/main/java/com/srsystem/entity/Employee.java b/backend/src/main/java/com/srsystem/entity/Employee.java new file mode 100644 index 0000000..31f0638 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/Employee.java @@ -0,0 +1,63 @@ +package com.srsystem.entity; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.srsystem.entity.enums.DispatchStatus; +import com.srsystem.entity.enums.EmpStatus; +import com.srsystem.entity.enums.RoleCode; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.LocalDate; + +@Entity +@Table(name = "tb_employee") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@SQLDelete(sql = "UPDATE tb_employee SET is_deleted = 'Y' WHERE id = ?") +@SQLRestriction("is_deleted = 'N'") +public class Employee extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sq_employees") + @SequenceGenerator(name = "sq_employees", sequenceName = "sq_employees", allocationSize = 50) + @Column(name = "id") + private Long empId; + + @Column(name = "login_id", nullable = false, unique = true, length = 50) + private String loginId; + + @Column(name = "password", nullable = false) + private String password; + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Column(name = "email", length = 255) + private String email; + + @Column(name = "phone", length = 20) + private String phone; + + @Enumerated(EnumType.STRING) + @Column(name = "role_code", nullable = false, length = 20) + private RoleCode roleCode; + + @Enumerated(EnumType.STRING) + @Column(name = "emp_status", nullable = false, length = 20) + private EmpStatus empStatus; + + @Enumerated(EnumType.STRING) + @Column(name = "dispatch_status", nullable = false, length = 20) + private DispatchStatus dispatchStatus; + + @Column(name = "joined_date") + private LocalDate joinedDate; + + @Column(name = "left_date") + private LocalDate leftDate; +} diff --git a/backend/src/main/java/com/srsystem/entity/Equipment.java b/backend/src/main/java/com/srsystem/entity/Equipment.java new file mode 100644 index 0000000..3fa0a5a --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/Equipment.java @@ -0,0 +1,64 @@ +package com.srsystem.entity; + +import com.srsystem.entity.enums.EquipmentStatus; +import com.srsystem.entity.enums.EquipmentType; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.LocalDate; + +@Entity +@Table(name = "tb_equipment") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@SQLDelete(sql = "UPDATE tb_equipment SET is_deleted = 'Y' WHERE id = ?") +@SQLRestriction("is_deleted = 'N'") +public class Equipment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sq_equipment") + @SequenceGenerator(name = "sq_equipment", sequenceName = "sq_equipment", allocationSize = 50) + @Column(name = "id") + private Long equipmentId; + + @Enumerated(EnumType.STRING) + @Column(name = "equipment_type", nullable = false, length = 20) + private EquipmentType equipmentType; + + @Column(name = "manufacturer", nullable = false, length = 255) + private String manufacturer; + + @Column(name = "model_name", nullable = false, length = 255) + private String modelName; + + @Column(name = "serial_number", nullable = false, unique = true, length = 100) + private String serialNumber; + + @Column(name = "asset_code", nullable = false, unique = true, length = 100) + private String assetCode; + + @Column(name = "specifications", columnDefinition = "JSONB") + private String specifications; + + @Column(name = "images", columnDefinition = "JSONB") + private String images; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private EquipmentStatus status; + + @Column(name = "purchase_date", nullable = false) + private LocalDate purchaseDate; + + @Column(name = "warranty_exp_date") + private LocalDate warrantyExpDate; + + @Version + @Column(name = "version", nullable = false) + private Long version; +} diff --git a/backend/src/main/java/com/srsystem/entity/EquipmentAssignment.java b/backend/src/main/java/com/srsystem/entity/EquipmentAssignment.java new file mode 100644 index 0000000..e17230f --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/EquipmentAssignment.java @@ -0,0 +1,46 @@ +package com.srsystem.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "tb_equipment_assignment") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@SQLDelete(sql = "UPDATE tb_equipment_assignment SET is_deleted = 'Y' WHERE id = ?") +@SQLRestriction("is_deleted = 'N'") +public class EquipmentAssignment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sq_equipment_assignments") + @SequenceGenerator(name = "sq_equipment_assignments", sequenceName = "sq_equipment_assignments", allocationSize = 50) + @Column(name = "id") + private Long assignmentId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "equipment_id", nullable = false) + private Equipment equipment; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Employee user; + + @Column(name = "assigned_date", nullable = false) + private LocalDateTime assignedDate; + + @Column(name = "returned_date") + private LocalDateTime returnedDate; + + @Column(name = "cond_on_assign", columnDefinition = "TEXT", nullable = false) + private String condOnAssign; + + @Column(name = "cond_on_return", columnDefinition = "TEXT") + private String condOnReturn; +} diff --git a/backend/src/main/java/com/srsystem/entity/EquipmentMaintenance.java b/backend/src/main/java/com/srsystem/entity/EquipmentMaintenance.java new file mode 100644 index 0000000..b033a2d --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/EquipmentMaintenance.java @@ -0,0 +1,55 @@ +package com.srsystem.entity; + +import com.srsystem.entity.enums.MaintenanceType; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "tb_equipment_maintenance") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@SQLDelete(sql = "UPDATE tb_equipment_maintenance SET is_deleted = 'Y' WHERE id = ?") +@SQLRestriction("is_deleted = 'N'") +public class EquipmentMaintenance extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sq_equipment_maintenance") + @SequenceGenerator(name = "sq_equipment_maintenance", sequenceName = "sq_equipment_maintenance", allocationSize = 50) + @Column(name = "id") + private Long maintenanceId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "equipment_id", nullable = false) + private Equipment equipment; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reported_by", nullable = false) + private Employee reportedBy; + + @Enumerated(EnumType.STRING) + @Column(name = "maintenance_type", nullable = false, length = 20) + private MaintenanceType maintenanceType; + + @Column(name = "issue_description", columnDefinition = "TEXT", nullable = false) + private String issueDescription; + + @Column(name = "action_taken", columnDefinition = "TEXT") + private String actionTaken; + + @Column(name = "cost", nullable = false, precision = 12, scale = 2) + private BigDecimal cost; + + @Column(name = "reported_date", nullable = false) + private LocalDateTime reportedDate; + + @Column(name = "resolved_date") + private LocalDateTime resolvedDate; +} diff --git a/backend/src/main/java/com/srsystem/entity/Notification.java b/backend/src/main/java/com/srsystem/entity/Notification.java new file mode 100644 index 0000000..aea3a96 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/Notification.java @@ -0,0 +1,43 @@ +package com.srsystem.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Table(name = "tb_notification") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@SQLDelete(sql = "UPDATE tb_notification SET is_deleted = 'Y' WHERE id = ?") +@SQLRestriction("is_deleted = 'N'") +public class Notification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sq_notifications") + @SequenceGenerator(name = "sq_notifications", sequenceName = "sq_notifications", allocationSize = 50) + @Column(name = "id") + private Long notificationId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Employee user; + + @Column(name = "type", nullable = false, length = 50) + private String type; + + @Column(name = "title", nullable = false, length = 255) + private String title; + + @Column(name = "message", columnDefinition = "TEXT", nullable = false) + private String message; + + @Column(name = "is_read", nullable = false, length = 1) + private char isRead = 'N'; + + @Column(name = "link_url", length = 500) + private String linkUrl; +} diff --git a/backend/src/main/java/com/srsystem/entity/Project.java b/backend/src/main/java/com/srsystem/entity/Project.java new file mode 100644 index 0000000..7a05770 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/Project.java @@ -0,0 +1,76 @@ +package com.srsystem.entity; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.srsystem.entity.enums.ContractType; +import com.srsystem.entity.enums.ProjectStatus; +import com.srsystem.entity.enums.ProjectType; +import com.srsystem.entity.enums.WorkLocationType; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.LocalDate; + +@Entity +@Table(name = "tb_project") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@SQLDelete(sql = "UPDATE tb_project SET is_deleted = 'Y' WHERE id = ?") +@SQLRestriction("is_deleted = 'N'") +public class Project extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sq_projects") + @SequenceGenerator(name = "sq_projects", sequenceName = "sq_projects", allocationSize = 50) + @Column(name = "id") + private Long projectId; + + @Enumerated(EnumType.STRING) + @Column(name = "project_type", nullable = false, length = 20) + private ProjectType projectType; + + @Column(name = "project_name", nullable = false, length = 255) + private String projectName; + + @Column(name = "contract_company", nullable = false, length = 255) + private String contractCompany; + + @Column(name = "end_user_company", length = 255) + private String endUserCompany; + + @Enumerated(EnumType.STRING) + @Column(name = "contract_type", nullable = false, length = 20) + private ContractType contractType; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private ProjectStatus status; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date") + private LocalDate endDate; + + @Enumerated(EnumType.STRING) + @Column(name = "work_location_type", nullable = false, length = 20) + private WorkLocationType workLocationType; + + @Column(name = "work_address", length = 500) + private String workAddress; + + @Column(name = "tech_environment", columnDefinition = "TEXT") + private String techEnvironment; + + @Column(name = "main_task_desc", columnDefinition = "TEXT") + private String mainTaskDesc; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "internal_manager_id") + @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) + private Employee internalManager; +} diff --git a/backend/src/main/java/com/srsystem/entity/Report.java b/backend/src/main/java/com/srsystem/entity/Report.java new file mode 100644 index 0000000..8788b7c --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/Report.java @@ -0,0 +1,85 @@ +package com.srsystem.entity; + +import com.srsystem.entity.enums.ReportStatus; +import com.srsystem.entity.enums.ReportType; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "tb_report") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@SQLDelete(sql = "UPDATE tb_report SET is_deleted = 'Y' WHERE id = ?") +@SQLRestriction("is_deleted = 'N'") +public class Report extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sq_reports") + @SequenceGenerator(name = "sq_reports", sequenceName = "sq_reports", allocationSize = 50) + @Column(name = "id") + private Long reportId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "dispatch_id") + private DispatchHistory dispatch; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Employee user; + + @Enumerated(EnumType.STRING) + @Column(name = "report_type", nullable = false, length = 20) + private ReportType reportType; + + @Column(name = "target_date", nullable = false) + private LocalDate targetDate; + + @Column(name = "completed_tasks", columnDefinition = "TEXT", nullable = false) + private String completedTasks; + + @Column(name = "planned_tasks", columnDefinition = "TEXT", nullable = false) + private String plannedTasks; + + @Column(name = "issues_and_risks", columnDefinition = "TEXT") + private String issuesAndRisks; + + @Column(name = "support_requested", columnDefinition = "TEXT") + private String supportRequested; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private ReportStatus status; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reviewer_id") + private Employee reviewer; + + @Column(name = "review_comment", columnDefinition = "TEXT") + private String reviewComment; + + @Column(name = "reviewed_at") + private LocalDateTime reviewedAt; + + @Column(name = "work_hours") + private BigDecimal workHours; + + @Column(name = "blocker_flag", length = 1) + @Builder.Default + private char blockerFlag = 'N'; + + @Column(name = "artifacts", columnDefinition = "JSONB") + private String artifacts; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "weekly_plan_id") + private WeeklyPlan weeklyPlan; +} diff --git a/backend/src/main/java/com/srsystem/entity/Ticket.java b/backend/src/main/java/com/srsystem/entity/Ticket.java new file mode 100644 index 0000000..0bfae23 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/Ticket.java @@ -0,0 +1,71 @@ +package com.srsystem.entity; + +import com.srsystem.entity.enums.TicketCategory; +import com.srsystem.entity.enums.TicketPriority; +import com.srsystem.entity.enums.TicketStatus; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "tb_ticket") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@SQLDelete(sql = "UPDATE tb_ticket SET is_deleted = 'Y' WHERE id = ?") +@SQLRestriction("is_deleted = 'N'") +public class Ticket extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sq_tickets") + @SequenceGenerator(name = "sq_tickets", sequenceName = "sq_tickets", allocationSize = 50) + @Column(name = "id") + private Long ticketId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id", nullable = false) + private Project project; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "requester_id", nullable = false) + private Employee requester; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "assignee_id") + private Employee assignee; + + @Enumerated(EnumType.STRING) + @Column(name = "category", nullable = false, length = 20) + private TicketCategory category; + + @Enumerated(EnumType.STRING) + @Column(name = "priority", nullable = false, length = 20) + private TicketPriority priority; + + @Column(name = "title", nullable = false, length = 500) + private String title; + + @Column(name = "description", columnDefinition = "TEXT", nullable = false) + private String description; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private TicketStatus status; + + @Column(name = "due_date") + private LocalDateTime dueDate; + + @Column(name = "resolved_at") + private LocalDateTime resolvedAt; + + @Column(name = "resolution_note", columnDefinition = "TEXT") + private String resolutionNote; + + @Column(name = "sla_warn_flag", nullable = false, length = 1) + private char slaWarnFlag = 'N'; +} diff --git a/backend/src/main/java/com/srsystem/entity/TicketComment.java b/backend/src/main/java/com/srsystem/entity/TicketComment.java new file mode 100644 index 0000000..e321177 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/TicketComment.java @@ -0,0 +1,38 @@ +package com.srsystem.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Table(name = "tb_ticket_comment") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@SQLDelete(sql = "UPDATE tb_ticket_comment SET is_deleted = 'Y' WHERE id = ?") +@SQLRestriction("is_deleted = 'N'") +public class TicketComment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sq_ticket_comments") + @SequenceGenerator(name = "sq_ticket_comments", sequenceName = "sq_ticket_comments", allocationSize = 50) + @Column(name = "id") + private Long commentId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ticket_id", nullable = false) + private Ticket ticket; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Employee user; + + @Column(name = "comment_text", columnDefinition = "TEXT", nullable = false) + private String commentText; + + @Column(name = "attached_file_url", length = 1000) + private String attachedFileUrl; +} diff --git a/backend/src/main/java/com/srsystem/entity/TrainingProgress.java b/backend/src/main/java/com/srsystem/entity/TrainingProgress.java new file mode 100644 index 0000000..1eaefa2 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/TrainingProgress.java @@ -0,0 +1,39 @@ +package com.srsystem.entity; + +import com.srsystem.entity.enums.TrainingStatus; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Table(name = "tb_training_progress") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@SQLDelete(sql = "UPDATE tb_training_progress SET is_deleted = 'Y' WHERE id = ?") +@SQLRestriction("is_deleted = 'N'") +public class TrainingProgress extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sq_training_progress") + @SequenceGenerator(name = "sq_training_progress", sequenceName = "sq_training_progress", allocationSize = 50) + @Column(name = "id") + private Long progressId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Employee user; + + @Column(name = "course_name", nullable = false, length = 255) + private String courseName; + + @Column(name = "progress_rate", nullable = false) + private Integer progressRate; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private TrainingStatus status; +} diff --git a/backend/src/main/java/com/srsystem/entity/WeeklyPlan.java b/backend/src/main/java/com/srsystem/entity/WeeklyPlan.java new file mode 100644 index 0000000..d1d4e1b --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/WeeklyPlan.java @@ -0,0 +1,41 @@ +package com.srsystem.entity; + +import com.srsystem.entity.enums.WeeklyPlanStatus; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.LocalDate; + +@Entity +@Table(name = "tb_weekly_plan") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@SQLDelete(sql = "UPDATE tb_weekly_plan SET is_deleted = 'Y' WHERE id = ?") +@SQLRestriction("is_deleted = 'N'") +public class WeeklyPlan extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sq_weekly_plans") + @SequenceGenerator(name = "sq_weekly_plans", sequenceName = "sq_weekly_plans", allocationSize = 50) + @Column(name = "id") + private Long planId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private Employee user; + + @Column(name = "week_start", nullable = false) + private LocalDate weekStart; + + @Column(name = "status", nullable = false, length = 20) + @Enumerated(EnumType.STRING) + private WeeklyPlanStatus status; + + @Column(name = "tasks", columnDefinition = "JSONB") + private String tasks; +} diff --git a/backend/src/main/java/com/srsystem/entity/enums/ContractType.java b/backend/src/main/java/com/srsystem/entity/enums/ContractType.java new file mode 100644 index 0000000..fdfcac8 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/enums/ContractType.java @@ -0,0 +1,6 @@ +package com.srsystem.entity.enums; + +public enum ContractType { + TURN_KEY, + MM +} diff --git a/backend/src/main/java/com/srsystem/entity/enums/DispatchStatus.java b/backend/src/main/java/com/srsystem/entity/enums/DispatchStatus.java new file mode 100644 index 0000000..cbd5f7f --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/enums/DispatchStatus.java @@ -0,0 +1,6 @@ +package com.srsystem.entity.enums; + +public enum DispatchStatus { + WAITING, + DISPATCHED +} diff --git a/backend/src/main/java/com/srsystem/entity/enums/EmpStatus.java b/backend/src/main/java/com/srsystem/entity/enums/EmpStatus.java new file mode 100644 index 0000000..0263b8b --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/enums/EmpStatus.java @@ -0,0 +1,7 @@ +package com.srsystem.entity.enums; + +public enum EmpStatus { + ACTIVE, + ON_LEAVE, + RESIGNED +} diff --git a/backend/src/main/java/com/srsystem/entity/enums/EquipmentStatus.java b/backend/src/main/java/com/srsystem/entity/enums/EquipmentStatus.java new file mode 100644 index 0000000..2bb174b --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/enums/EquipmentStatus.java @@ -0,0 +1,9 @@ +package com.srsystem.entity.enums; + +public enum EquipmentStatus { + AVAILABLE, + IN_USE, + REPAIRING, + RETIRED, + LOST +} diff --git a/backend/src/main/java/com/srsystem/entity/enums/EquipmentType.java b/backend/src/main/java/com/srsystem/entity/enums/EquipmentType.java new file mode 100644 index 0000000..801b922 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/enums/EquipmentType.java @@ -0,0 +1,8 @@ +package com.srsystem.entity.enums; + +public enum EquipmentType { + LAPTOP, + MONITOR, + TEST_DEVICE, + ACCESSORY +} diff --git a/backend/src/main/java/com/srsystem/entity/enums/MaintenanceType.java b/backend/src/main/java/com/srsystem/entity/enums/MaintenanceType.java new file mode 100644 index 0000000..fdde9c4 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/enums/MaintenanceType.java @@ -0,0 +1,8 @@ +package com.srsystem.entity.enums; + +public enum MaintenanceType { + REPAIR, + UPGRADE, + INSPECTION, + DISPOSAL +} diff --git a/backend/src/main/java/com/srsystem/entity/enums/ProjectStatus.java b/backend/src/main/java/com/srsystem/entity/enums/ProjectStatus.java new file mode 100644 index 0000000..f8a8dbd --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/enums/ProjectStatus.java @@ -0,0 +1,8 @@ +package com.srsystem.entity.enums; + +public enum ProjectStatus { + PREPARING, + ONGOING, + COMPLETED, + HOLD +} diff --git a/backend/src/main/java/com/srsystem/entity/enums/ProjectType.java b/backend/src/main/java/com/srsystem/entity/enums/ProjectType.java new file mode 100644 index 0000000..40e219c --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/enums/ProjectType.java @@ -0,0 +1,7 @@ +package com.srsystem.entity.enums; + +public enum ProjectType { + SI, + SM, + MIGRATION +} diff --git a/backend/src/main/java/com/srsystem/entity/enums/ReportStatus.java b/backend/src/main/java/com/srsystem/entity/enums/ReportStatus.java new file mode 100644 index 0000000..30b6e26 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/enums/ReportStatus.java @@ -0,0 +1,8 @@ +package com.srsystem.entity.enums; + +public enum ReportStatus { + DRAFT, + SUBMITTED, + APPROVED, + REJECTED +} diff --git a/backend/src/main/java/com/srsystem/entity/enums/ReportType.java b/backend/src/main/java/com/srsystem/entity/enums/ReportType.java new file mode 100644 index 0000000..d00845b --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/enums/ReportType.java @@ -0,0 +1,8 @@ +package com.srsystem.entity.enums; + +public enum ReportType { + DAILY, + WEEKLY, + MONTHLY, + RETURN +} diff --git a/backend/src/main/java/com/srsystem/entity/enums/RoleCode.java b/backend/src/main/java/com/srsystem/entity/enums/RoleCode.java new file mode 100644 index 0000000..511c7b3 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/enums/RoleCode.java @@ -0,0 +1,7 @@ +package com.srsystem.entity.enums; + +public enum RoleCode { + ADMIN, + MANAGER, + EMPLOYEE +} diff --git a/backend/src/main/java/com/srsystem/entity/enums/TicketCategory.java b/backend/src/main/java/com/srsystem/entity/enums/TicketCategory.java new file mode 100644 index 0000000..486b586 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/enums/TicketCategory.java @@ -0,0 +1,8 @@ +package com.srsystem.entity.enums; + +public enum TicketCategory { + BUG, + FEATURE, + SUPPORT, + QA +} diff --git a/backend/src/main/java/com/srsystem/entity/enums/TicketPriority.java b/backend/src/main/java/com/srsystem/entity/enums/TicketPriority.java new file mode 100644 index 0000000..1b7beaa --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/enums/TicketPriority.java @@ -0,0 +1,8 @@ +package com.srsystem.entity.enums; + +public enum TicketPriority { + LOW, + NORMAL, + HIGH, + CRITICAL +} diff --git a/backend/src/main/java/com/srsystem/entity/enums/TicketStatus.java b/backend/src/main/java/com/srsystem/entity/enums/TicketStatus.java new file mode 100644 index 0000000..8ab0cb4 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/enums/TicketStatus.java @@ -0,0 +1,9 @@ +package com.srsystem.entity.enums; + +public enum TicketStatus { + OPEN, + ASSIGNED, + IN_PROGRESS, + RESOLVED, + CLOSED +} diff --git a/backend/src/main/java/com/srsystem/entity/enums/TrainingStatus.java b/backend/src/main/java/com/srsystem/entity/enums/TrainingStatus.java new file mode 100644 index 0000000..c9e6b4b --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/enums/TrainingStatus.java @@ -0,0 +1,7 @@ +package com.srsystem.entity.enums; + +public enum TrainingStatus { + NOT_STARTED, + ONGOING, + COMPLETED +} diff --git a/backend/src/main/java/com/srsystem/entity/enums/WeeklyPlanStatus.java b/backend/src/main/java/com/srsystem/entity/enums/WeeklyPlanStatus.java new file mode 100644 index 0000000..8833211 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/enums/WeeklyPlanStatus.java @@ -0,0 +1,5 @@ +package com.srsystem.entity.enums; + +public enum WeeklyPlanStatus { + DRAFT, ACTIVE, CLOSED +} diff --git a/backend/src/main/java/com/srsystem/entity/enums/WorkLocationType.java b/backend/src/main/java/com/srsystem/entity/enums/WorkLocationType.java new file mode 100644 index 0000000..bb972a9 --- /dev/null +++ b/backend/src/main/java/com/srsystem/entity/enums/WorkLocationType.java @@ -0,0 +1,7 @@ +package com.srsystem.entity.enums; + +public enum WorkLocationType { + ON_SITE, + REMOTE, + HYBRID +} diff --git a/backend/src/main/java/com/srsystem/exception/BusinessException.java b/backend/src/main/java/com/srsystem/exception/BusinessException.java new file mode 100644 index 0000000..c1e4005 --- /dev/null +++ b/backend/src/main/java/com/srsystem/exception/BusinessException.java @@ -0,0 +1,13 @@ +package com.srsystem.exception; + +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException { + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/backend/src/main/java/com/srsystem/exception/ErrorCode.java b/backend/src/main/java/com/srsystem/exception/ErrorCode.java new file mode 100644 index 0000000..e3a4a70 --- /dev/null +++ b/backend/src/main/java/com/srsystem/exception/ErrorCode.java @@ -0,0 +1,38 @@ +package com.srsystem.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum ErrorCode { + RESIGNED_EMPLOYEE(HttpStatus.BAD_REQUEST, "EMP_ERR_001", "퇴사 처리된 직원 정보는 수정할 수 없습니다"), + LOCKED_REPORT(HttpStatus.BAD_REQUEST, "RPT_ERR_001", "결재 완료 보고서는 수정/삭제할 수 없습니다"), + FUTURE_REPORT_DATE(HttpStatus.BAD_REQUEST, "RPT_ERR_002", "미래 날짜의 업무 보고는 작성할 수 없습니다"), + CLOSED_TICKET_MOD(HttpStatus.BAD_REQUEST, "TCK_ERR_001", "종료된 티켓은 댓글/수정이 불가능합니다"), + ASSIGNEE_REQUIRED(HttpStatus.BAD_REQUEST, "TCK_ERR_002", "담당자 미배정 티켓은 IN_PROGRESS 전환 불가"), + EQUIP_NOT_AVAILABLE(HttpStatus.BAD_REQUEST, "EQP_ERR_001", "지급 가능한 재고 상태가 아닙니다"), + INVALID_ASSIGNEE(HttpStatus.BAD_REQUEST, "EQP_ERR_002", "재직 중인 직원에게만 장비 지급 가능"), + SYSTEM_CONCURRENCY(HttpStatus.CONFLICT, "SYS_ERR_001", "동시성 충돌 - 다시 시도해 주세요"), + DUPLICATE_LOGIN_ID(HttpStatus.CONFLICT, "EMP_ERR_002", "이미 사용 중인 로그인 ID 입니다"), + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH_ERR_001", "아이디 또는 비밀번호가 일치하지 않습니다"), + EMPLOYEE_NOT_FOUND(HttpStatus.NOT_FOUND, "EMP_ERR_003", "직원을 찾을 수 없습니다"), + TICKET_NOT_FOUND(HttpStatus.NOT_FOUND, "TCK_ERR_003", "티켓을 찾을 수 없습니다"), + PROJECT_NOT_FOUND(HttpStatus.NOT_FOUND, "PRJ_ERR_001", "프로젝트를 찾을 수 없습니다"), + EQUIPMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "EQP_ERR_003", "장비를 찾을 수 없습니다"), + REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "RPT_ERR_003", "보고서를 찾을 수 없습니다"), + INVALID_ROLE(HttpStatus.FORBIDDEN, "AUTH_ERR_002", "권한이 없습니다"), + DISPATCH_NOT_FOUND(HttpStatus.NOT_FOUND, "DSP_ERR_001", "파견 이력을 찾을 수 없습니다"), + AUTH_INVALID_CREDENTIALS(HttpStatus.BAD_REQUEST, "AUTH_ERR_003", "현재 비밀번호가 일치하지 않습니다"), + CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "COD_ERR_001", "공통코드를 찾을 수 없습니다"), + PLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "PLN_ERR_001", "주간 계획을 찾을 수 없습니다"); + + private final HttpStatus httpStatus; + private final String errorCode; + private final String message; + + ErrorCode(HttpStatus httpStatus, String errorCode, String message) { + this.httpStatus = httpStatus; + this.errorCode = errorCode; + this.message = message; + } +} diff --git a/backend/src/main/java/com/srsystem/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/srsystem/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..21f8e43 --- /dev/null +++ b/backend/src/main/java/com/srsystem/exception/GlobalExceptionHandler.java @@ -0,0 +1,50 @@ +package com.srsystem.exception; + +import com.srsystem.dto.ApiResponse; +import jakarta.persistence.OptimisticLockException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.stream.Collectors; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusinessException(BusinessException ex) { + ErrorCode errorCode = ex.getErrorCode(); + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(ApiResponse.error(errorCode.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationException(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .collect(Collectors.joining(", ")); + return ResponseEntity + .badRequest() + .body(ApiResponse.error(message)); + } + + @ExceptionHandler(OptimisticLockException.class) + public ResponseEntity> handleOptimisticLockException(OptimisticLockException ex) { + log.warn("Optimistic lock conflict detected", ex); + return ResponseEntity + .status(ErrorCode.SYSTEM_CONCURRENCY.getHttpStatus()) + .body(ApiResponse.error(ErrorCode.SYSTEM_CONCURRENCY.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGenericException(Exception ex) { + log.error("Unexpected exception occurred", ex); + return ResponseEntity + .internalServerError() + .body(ApiResponse.error("서버 내부 오류가 발생했습니다.")); + } +} diff --git a/backend/src/main/java/com/srsystem/listener/NotificationListener.java b/backend/src/main/java/com/srsystem/listener/NotificationListener.java new file mode 100644 index 0000000..f4e991d --- /dev/null +++ b/backend/src/main/java/com/srsystem/listener/NotificationListener.java @@ -0,0 +1,57 @@ +package com.srsystem.listener; + +import com.srsystem.dto.NotificationEvent; +import com.srsystem.entity.Employee; +import com.srsystem.entity.Notification; +import com.srsystem.exception.BusinessException; +import com.srsystem.exception.ErrorCode; +import com.srsystem.repository.EmployeeRepository; +import com.srsystem.repository.NotificationRepository; +import com.srsystem.service.notification.EmailSender; +import com.srsystem.service.notification.SlackSender; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +@Slf4j +public class NotificationListener { + + private final NotificationRepository notificationRepository; + private final EmployeeRepository employeeRepository; + private final SlackSender slackSender; + private final EmailSender emailSender; + + @Async + @TransactionalEventListener + public void handleNotification(NotificationEvent event) { + Employee recipient = employeeRepository.findById(event.getRecipientId()) + .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); + + Notification notification = Notification.builder() + .user(recipient) + .type(event.getType()) + .title(event.getTitle()) + .message(event.getMessage()) + .linkUrl(event.getLinkUrl()) + .isRead('N') + .build(); + notificationRepository.save(notification); + + try { + slackSender.send(event.getTitle(), event.getMessage()); + } catch (Exception e) { + log.warn("Slack notification failed: {}", e.getMessage()); + } + + try { + emailSender.send(recipient.getEmail(), event.getTitle(), event.getMessage()); + } catch (Exception e) { + log.warn("Email notification failed: {}", e.getMessage()); + } + } +} diff --git a/backend/src/main/java/com/srsystem/listener/NotificationPublisher.java b/backend/src/main/java/com/srsystem/listener/NotificationPublisher.java new file mode 100644 index 0000000..f0476fe --- /dev/null +++ b/backend/src/main/java/com/srsystem/listener/NotificationPublisher.java @@ -0,0 +1,17 @@ +package com.srsystem.listener; + +import com.srsystem.dto.NotificationEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class NotificationPublisher { + + private final ApplicationEventPublisher eventPublisher; + + public void publish(NotificationEvent event) { + eventPublisher.publishEvent(event); + } +} diff --git a/backend/src/main/java/com/srsystem/repository/CommonCodeRepository.java b/backend/src/main/java/com/srsystem/repository/CommonCodeRepository.java new file mode 100644 index 0000000..efa9e5e --- /dev/null +++ b/backend/src/main/java/com/srsystem/repository/CommonCodeRepository.java @@ -0,0 +1,12 @@ +package com.srsystem.repository; + +import com.srsystem.entity.CommonCode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CommonCodeRepository extends JpaRepository { + List findByGroupCodeAndIsActiveOrderBySortOrder(String groupCode, char isActive); +} diff --git a/backend/src/main/java/com/srsystem/repository/DailyTaskProgressRepository.java b/backend/src/main/java/com/srsystem/repository/DailyTaskProgressRepository.java new file mode 100644 index 0000000..0709623 --- /dev/null +++ b/backend/src/main/java/com/srsystem/repository/DailyTaskProgressRepository.java @@ -0,0 +1,13 @@ +package com.srsystem.repository; + +import com.srsystem.entity.DailyTaskProgress; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface DailyTaskProgressRepository extends JpaRepository { + List findByReport_ReportId(Long reportId); + List findByWeeklyPlan_PlanId(Long planId); +} diff --git a/backend/src/main/java/com/srsystem/repository/DispatchHistoryRepository.java b/backend/src/main/java/com/srsystem/repository/DispatchHistoryRepository.java new file mode 100644 index 0000000..6ed3662 --- /dev/null +++ b/backend/src/main/java/com/srsystem/repository/DispatchHistoryRepository.java @@ -0,0 +1,26 @@ +package com.srsystem.repository; + +import com.srsystem.entity.DispatchHistory; +import com.srsystem.entity.Employee; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface DispatchHistoryRepository extends JpaRepository { + List findByEmployee_EmpId(Long empId); + + List findByEndDateIsNull(); + + @Query("SELECT DISTINCT dh.employee FROM DispatchHistory dh WHERE dh.individualTech LIKE %:keyword%") + List findEmployeesByIndividualTechContaining(@Param("keyword") String keyword); + + Page findByEmployee_EmpId(Long empId, Pageable pageable); + Page findByProject_ProjectId(Long projectId, Pageable pageable); + Page findByEmployee_EmpIdAndProject_ProjectId(Long empId, Long projectId, Pageable pageable); +} diff --git a/backend/src/main/java/com/srsystem/repository/EmployeeRepository.java b/backend/src/main/java/com/srsystem/repository/EmployeeRepository.java new file mode 100644 index 0000000..898c9cf --- /dev/null +++ b/backend/src/main/java/com/srsystem/repository/EmployeeRepository.java @@ -0,0 +1,21 @@ +package com.srsystem.repository; + +import com.srsystem.entity.Employee; +import com.srsystem.entity.enums.EmpStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface EmployeeRepository extends JpaRepository { + @Query("SELECT e FROM Employee e WHERE e.loginId = :loginId") + Optional findByLoginId(@Param("loginId") String loginId); + List findByNameContainingIgnoreCase(String name); + Page findByEmpStatus(EmpStatus status, Pageable pageable); +} diff --git a/backend/src/main/java/com/srsystem/repository/EquipmentAssignmentRepository.java b/backend/src/main/java/com/srsystem/repository/EquipmentAssignmentRepository.java new file mode 100644 index 0000000..292c4b6 --- /dev/null +++ b/backend/src/main/java/com/srsystem/repository/EquipmentAssignmentRepository.java @@ -0,0 +1,14 @@ +package com.srsystem.repository; + +import com.srsystem.entity.EquipmentAssignment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface EquipmentAssignmentRepository extends JpaRepository { + List findByEquipment_EquipmentId(Long equipmentId); + Optional findByEquipment_EquipmentIdAndReturnedDateIsNull(Long equipmentId); +} diff --git a/backend/src/main/java/com/srsystem/repository/EquipmentMaintenanceRepository.java b/backend/src/main/java/com/srsystem/repository/EquipmentMaintenanceRepository.java new file mode 100644 index 0000000..ab27718 --- /dev/null +++ b/backend/src/main/java/com/srsystem/repository/EquipmentMaintenanceRepository.java @@ -0,0 +1,12 @@ +package com.srsystem.repository; + +import com.srsystem.entity.EquipmentMaintenance; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface EquipmentMaintenanceRepository extends JpaRepository { + List findByEquipment_EquipmentId(Long equipmentId); +} diff --git a/backend/src/main/java/com/srsystem/repository/EquipmentRepository.java b/backend/src/main/java/com/srsystem/repository/EquipmentRepository.java new file mode 100644 index 0000000..1989eff --- /dev/null +++ b/backend/src/main/java/com/srsystem/repository/EquipmentRepository.java @@ -0,0 +1,19 @@ +package com.srsystem.repository; + +import com.srsystem.entity.Equipment; +import com.srsystem.entity.enums.EquipmentStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface EquipmentRepository extends JpaRepository { + Optional findBySerialNumber(String serialNumber); + Optional findByAssetCode(String assetCode); + List findByStatus(EquipmentStatus status); + Page findByStatus(EquipmentStatus status, Pageable pageable); +} diff --git a/backend/src/main/java/com/srsystem/repository/NotificationRepository.java b/backend/src/main/java/com/srsystem/repository/NotificationRepository.java new file mode 100644 index 0000000..571151a --- /dev/null +++ b/backend/src/main/java/com/srsystem/repository/NotificationRepository.java @@ -0,0 +1,13 @@ +package com.srsystem.repository; + +import com.srsystem.entity.Notification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface NotificationRepository extends JpaRepository { + List findByUser_EmpIdOrderByCreatedAtDesc(Long empId); + long countByUser_EmpIdAndIsRead(Long empId, char isRead); +} diff --git a/backend/src/main/java/com/srsystem/repository/ProjectRepository.java b/backend/src/main/java/com/srsystem/repository/ProjectRepository.java new file mode 100644 index 0000000..fbcab3f --- /dev/null +++ b/backend/src/main/java/com/srsystem/repository/ProjectRepository.java @@ -0,0 +1,13 @@ +package com.srsystem.repository; + +import com.srsystem.entity.Project; +import com.srsystem.entity.enums.ProjectStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ProjectRepository extends JpaRepository { + List findByStatus(ProjectStatus status); +} diff --git a/backend/src/main/java/com/srsystem/repository/ReportRepository.java b/backend/src/main/java/com/srsystem/repository/ReportRepository.java new file mode 100644 index 0000000..9dae1bd --- /dev/null +++ b/backend/src/main/java/com/srsystem/repository/ReportRepository.java @@ -0,0 +1,23 @@ +package com.srsystem.repository; + +import com.srsystem.entity.Report; +import com.srsystem.entity.enums.ReportStatus; +import com.srsystem.entity.enums.ReportType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +public interface ReportRepository extends JpaRepository { + Page findByUser_EmpId(Long empId, Pageable pageable); + Page findByStatus(ReportStatus status, Pageable pageable); + List findByUser_EmpIdAndTargetDateBetween(Long empId, LocalDate start, LocalDate end); + List findByDispatch_DispatchId(Long dispatchId); + Page findByUser_EmpIdAndStatusAndReportType(Long empId, ReportStatus status, ReportType reportType, LocalDate targetDate, Pageable pageable); + List findByTargetDateBetween(LocalDate start, LocalDate end); + long countByStatus(ReportStatus status); +} diff --git a/backend/src/main/java/com/srsystem/repository/TicketCommentRepository.java b/backend/src/main/java/com/srsystem/repository/TicketCommentRepository.java new file mode 100644 index 0000000..94a3b6b --- /dev/null +++ b/backend/src/main/java/com/srsystem/repository/TicketCommentRepository.java @@ -0,0 +1,12 @@ +package com.srsystem.repository; + +import com.srsystem.entity.TicketComment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface TicketCommentRepository extends JpaRepository { + List findByTicket_TicketIdOrderByCreatedAtAsc(Long ticketId); +} diff --git a/backend/src/main/java/com/srsystem/repository/TicketRepository.java b/backend/src/main/java/com/srsystem/repository/TicketRepository.java new file mode 100644 index 0000000..f7e7c67 --- /dev/null +++ b/backend/src/main/java/com/srsystem/repository/TicketRepository.java @@ -0,0 +1,23 @@ +package com.srsystem.repository; + +import com.srsystem.entity.Ticket; +import com.srsystem.entity.enums.TicketStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface TicketRepository extends JpaRepository { + Page findByStatus(TicketStatus status, Pageable pageable); + Page findByRequester_EmpId(Long empId, Pageable pageable); + Page findByAssignee_EmpId(Long empId, Pageable pageable); + List findBySlaWarnFlagAndStatusNotIn(char slaWarnFlag, List statuses); + List findByProject_ProjectIdAndRequester_EmpIdAndStatus(Long projectId, Long empId, TicketStatus status); + List findByRequester_EmpIdAndStatusAndResolvedAtBetween(Long requesterId, TicketStatus status, LocalDateTime start, LocalDateTime end); + List findByRequester_EmpIdAndDueDateBetween(Long requesterId, LocalDateTime start, LocalDateTime end); + List findByDueDateBetween(LocalDateTime start, LocalDateTime end); +} diff --git a/backend/src/main/java/com/srsystem/repository/TrainingProgressRepository.java b/backend/src/main/java/com/srsystem/repository/TrainingProgressRepository.java new file mode 100644 index 0000000..24dd039 --- /dev/null +++ b/backend/src/main/java/com/srsystem/repository/TrainingProgressRepository.java @@ -0,0 +1,14 @@ +package com.srsystem.repository; + +import com.srsystem.entity.TrainingProgress; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface TrainingProgressRepository extends JpaRepository { + List findByUser_EmpId(Long empId); + Optional findByUser_EmpIdAndCourseName(Long empId, String courseName); +} diff --git a/backend/src/main/java/com/srsystem/repository/WeeklyPlanRepository.java b/backend/src/main/java/com/srsystem/repository/WeeklyPlanRepository.java new file mode 100644 index 0000000..174c226 --- /dev/null +++ b/backend/src/main/java/com/srsystem/repository/WeeklyPlanRepository.java @@ -0,0 +1,15 @@ +package com.srsystem.repository; + +import com.srsystem.entity.WeeklyPlan; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Repository +public interface WeeklyPlanRepository extends JpaRepository { + List findByUser_EmpIdOrderByWeekStartDesc(Long userId); + Optional findByUser_EmpIdAndWeekStart(Long userId, LocalDate weekStart); +} diff --git a/backend/src/main/java/com/srsystem/service/AuthService.java b/backend/src/main/java/com/srsystem/service/AuthService.java new file mode 100644 index 0000000..4cfe3d1 --- /dev/null +++ b/backend/src/main/java/com/srsystem/service/AuthService.java @@ -0,0 +1,87 @@ +package com.srsystem.service; + +import com.srsystem.config.jwt.JwtTokenProvider; +import com.srsystem.dto.EmployeeSummary; +import com.srsystem.dto.LoginRequest; +import com.srsystem.dto.LoginResponse; +import com.srsystem.entity.Employee; +import com.srsystem.exception.BusinessException; +import com.srsystem.exception.ErrorCode; +import com.srsystem.repository.EmployeeRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + + private final EmployeeRepository employeeRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + + @Transactional + public LoginResponse login(LoginRequest request) { + Employee employee = employeeRepository.findByLoginId(request.loginId()) + .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_CREDENTIALS)); + + if (!passwordEncoder.matches(request.password(), employee.getPassword())) { + throw new BusinessException(ErrorCode.INVALID_CREDENTIALS); + } + + String accessToken = jwtTokenProvider.generateAccessToken(employee.getLoginId(), employee.getRoleCode().name()); + String refreshToken = jwtTokenProvider.generateRefreshToken(employee.getLoginId()); + + EmployeeSummary summary = new EmployeeSummary( + employee.getEmpId(), + employee.getName(), + employee.getEmail(), + employee.getPhone(), + employee.getRoleCode(), + employee.getEmpStatus(), + employee.getDispatchStatus(), + employee.getJoinedDate(), + employee.getLeftDate() + ); + + return new LoginResponse(accessToken, refreshToken, summary); + } + + @Transactional + public LoginResponse refresh(String refreshToken) { + if (!jwtTokenProvider.validateToken(refreshToken)) { + throw new BusinessException(ErrorCode.INVALID_CREDENTIALS); + } + + String loginId = jwtTokenProvider.getLoginId(refreshToken); + Employee employee = employeeRepository.findByLoginId(loginId) + .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); + + String newAccessToken = jwtTokenProvider.generateAccessToken(employee.getLoginId(), employee.getRoleCode().name()); + String newRefreshToken = jwtTokenProvider.generateRefreshToken(employee.getLoginId()); + + EmployeeSummary summary = new EmployeeSummary( + employee.getEmpId(), + employee.getName(), + employee.getEmail(), + employee.getPhone(), + employee.getRoleCode(), + employee.getEmpStatus(), + employee.getDispatchStatus(), + employee.getJoinedDate(), + employee.getLeftDate() + ); + + return new LoginResponse(newAccessToken, newRefreshToken, summary); + } + + @Transactional(readOnly = true) + public Employee getEmployeeByLoginId(String loginId) { + return employeeRepository.findByLoginId(loginId) + .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); + } +} diff --git a/backend/src/main/java/com/srsystem/service/CalendarService.java b/backend/src/main/java/com/srsystem/service/CalendarService.java new file mode 100644 index 0000000..d6d248b --- /dev/null +++ b/backend/src/main/java/com/srsystem/service/CalendarService.java @@ -0,0 +1,88 @@ +package com.srsystem.service; + +import com.srsystem.dto.CalendarEvent; +import com.srsystem.entity.DispatchHistory; +import com.srsystem.entity.Report; +import com.srsystem.entity.Ticket; +import com.srsystem.entity.enums.RoleCode; +import com.srsystem.repository.DispatchHistoryRepository; +import com.srsystem.repository.ReportRepository; +import com.srsystem.repository.TicketRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CalendarService { + + private final TicketRepository ticketRepository; + private final ReportRepository reportRepository; + private final DispatchHistoryRepository dispatchHistoryRepository; + + public List getEvents(Long userId, RoleCode role, LocalDate start, LocalDate end) { + List events = new ArrayList<>(); + + LocalDateTime startDT = start.atStartOfDay(); + LocalDateTime endDT = end.atTime(23, 59, 59); + + // Tickets with due dates in range + List tickets; + if (role == RoleCode.EMPLOYEE) { + tickets = ticketRepository.findByRequester_EmpIdAndDueDateBetween(userId, startDT, endDT); + } else { + tickets = ticketRepository.findByDueDateBetween(startDT, endDT); + } + for (Ticket t : tickets) { + events.add(new CalendarEvent( + "ticket-" + t.getTicketId(), + t.getTitle(), + t.getDueDate() != null ? t.getDueDate().toLocalDate().toString() : "", + "ticket", + t.getPriority() != null ? t.getPriority().name() : "NORMAL", + t.getStatus() != null ? t.getStatus().name() : "OPEN" + )); + } + + // Reports with target dates in range + List reports; + if (role == RoleCode.EMPLOYEE) { + reports = reportRepository.findByUser_EmpIdAndTargetDateBetween(userId, start, end); + } else { + reports = reportRepository.findByTargetDateBetween(start, end); + } + for (Report r : reports) { + events.add(new CalendarEvent( + "report-" + r.getReportId(), + r.getReportType() + " Report", + r.getTargetDate().toString(), + "report", + r.getStatus().name(), + "" + )); + } + + // Dispatch history for this user + List dispatches = dispatchHistoryRepository.findByEmployee_EmpId(userId); + for (DispatchHistory d : dispatches) { + if (d.getStartDate() != null && !d.getStartDate().isAfter(end) && (d.getEndDate() == null || !d.getEndDate().isBefore(start))) { + events.add(new CalendarEvent( + "dispatch-" + d.getDispatchId(), + d.getProject().getProjectName() + " (" + d.getRoleTitle() + ")", + d.getStartDate().toString(), + "dispatch", + d.getEndDate() != null ? "ended" : "active", + d.getEndDate() != null ? d.getEndDate().toString() : "" + )); + } + } + + return events; + } +} diff --git a/backend/src/main/java/com/srsystem/service/CommonCodeService.java b/backend/src/main/java/com/srsystem/service/CommonCodeService.java new file mode 100644 index 0000000..18bea86 --- /dev/null +++ b/backend/src/main/java/com/srsystem/service/CommonCodeService.java @@ -0,0 +1,76 @@ +package com.srsystem.service; + +import com.srsystem.dto.CommonCodeRequest; +import com.srsystem.entity.CommonCode; +import com.srsystem.exception.BusinessException; +import com.srsystem.exception.ErrorCode; +import com.srsystem.repository.CommonCodeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommonCodeService { + + private final CommonCodeRepository commonCodeRepository; + + public List getCodes(String groupCode) { + if (groupCode != null) { + return commonCodeRepository.findByGroupCodeAndIsActiveOrderBySortOrder(groupCode, 'Y'); + } + return commonCodeRepository.findAll().stream() + .filter(c -> c.getIsActive() == 'Y') + .sorted(java.util.Comparator.comparing(CommonCode::getGroupCode) + .thenComparing(CommonCode::getSortOrder)) + .collect(java.util.stream.Collectors.toList()); + } + + public List getGroupCodes() { + return commonCodeRepository.findAll().stream() + .filter(c -> c.getIsActive() == 'Y') + .map(CommonCode::getGroupCode) + .distinct() + .sorted() + .collect(java.util.stream.Collectors.toList()); + } + + @Transactional + public CommonCode createCode(CommonCodeRequest request) { + CommonCode code = CommonCode.builder() + .groupCode(request.groupCode()) + .codeValue(request.codeValue()) + .codeName(request.codeName()) + .optionVal1(request.optionVal1()) + .optionVal2(request.optionVal2()) + .sortOrder(request.sortOrder()) + .isActive(request.isActive()) + .build(); + return commonCodeRepository.save(code); + } + + @Transactional + public CommonCode updateCode(Long id, CommonCodeRequest request) { + CommonCode code = commonCodeRepository.findById(id) + .orElseThrow(() -> new BusinessException(ErrorCode.CODE_NOT_FOUND)); + code.setGroupCode(request.groupCode()); + code.setCodeValue(request.codeValue()); + code.setCodeName(request.codeName()); + code.setOptionVal1(request.optionVal1()); + code.setOptionVal2(request.optionVal2()); + code.setSortOrder(request.sortOrder()); + code.setIsActive(request.isActive()); + return commonCodeRepository.save(code); + } + + @Transactional + public void deleteCode(Long id) { + CommonCode code = commonCodeRepository.findById(id) + .orElseThrow(() -> new BusinessException(ErrorCode.CODE_NOT_FOUND)); + code.setIsDeleted('Y'); + commonCodeRepository.save(code); + } +} diff --git a/backend/src/main/java/com/srsystem/service/DispatchHistoryService.java b/backend/src/main/java/com/srsystem/service/DispatchHistoryService.java new file mode 100644 index 0000000..1cd7b37 --- /dev/null +++ b/backend/src/main/java/com/srsystem/service/DispatchHistoryService.java @@ -0,0 +1,91 @@ +package com.srsystem.service; + +import com.srsystem.dto.CreateDispatchRequest; +import com.srsystem.entity.DispatchHistory; +import com.srsystem.entity.Employee; +import com.srsystem.entity.Project; +import com.srsystem.exception.BusinessException; +import com.srsystem.exception.ErrorCode; +import com.srsystem.repository.DispatchHistoryRepository; +import com.srsystem.repository.EmployeeRepository; +import com.srsystem.repository.ProjectRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DispatchHistoryService { + + private final DispatchHistoryRepository dispatchHistoryRepository; + private final EmployeeRepository employeeRepository; + private final ProjectRepository projectRepository; + + public List findByEmployeeId(Long empId) { + employeeRepository.findById(empId) + .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); + return dispatchHistoryRepository.findByEmployee_EmpId(empId); + } + + public List getActiveDispatches() { + return dispatchHistoryRepository.findByEndDateIsNull(); + } + + public Page findAll(Pageable pageable, Long empId, Long projectId) { + if (empId != null && projectId != null) { + return dispatchHistoryRepository.findByEmployee_EmpIdAndProject_ProjectId(empId, projectId, pageable); + } + if (empId != null) { + return dispatchHistoryRepository.findByEmployee_EmpId(empId, pageable); + } + if (projectId != null) { + return dispatchHistoryRepository.findByProject_ProjectId(projectId, pageable); + } + return dispatchHistoryRepository.findAll(pageable); + } + + public DispatchHistory findById(Long dispatchId) { + return dispatchHistoryRepository.findById(dispatchId) + .orElseThrow(() -> new BusinessException(ErrorCode.DISPATCH_NOT_FOUND)); + } + + @Transactional + public void delete(Long dispatchId) { + DispatchHistory dh = findById(dispatchId); + dispatchHistoryRepository.delete(dh); + } + + @Transactional + public DispatchHistory createDispatch(CreateDispatchRequest request) { + Employee employee = employeeRepository.findById(request.employeeId()) + .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); + Project project = projectRepository.findById(request.projectId()) + .orElseThrow(() -> new BusinessException(ErrorCode.PROJECT_NOT_FOUND)); + + DispatchHistory history = DispatchHistory.builder() + .employee(employee) + .project(project) + .roleTitle(request.roleTitle()) + .contributionRate(request.contributionRate()) + .individualTech(request.individualTech()) + .performedTasks(request.performedTasks()) + .startDate(request.startDate() != null ? request.startDate() : LocalDate.now()) + .build(); + + return dispatchHistoryRepository.save(history); + } + + @Transactional + public DispatchHistory endDispatch(Long dispatchId) { + DispatchHistory history = dispatchHistoryRepository.findById(dispatchId) + .orElseThrow(() -> new BusinessException(ErrorCode.DISPATCH_NOT_FOUND)); + history.setEndDate(LocalDate.now()); + return dispatchHistoryRepository.save(history); + } +} diff --git a/backend/src/main/java/com/srsystem/service/EmployeeService.java b/backend/src/main/java/com/srsystem/service/EmployeeService.java new file mode 100644 index 0000000..349e934 --- /dev/null +++ b/backend/src/main/java/com/srsystem/service/EmployeeService.java @@ -0,0 +1,146 @@ +package com.srsystem.service; + +import com.srsystem.dto.ChangePasswordRequest; +import com.srsystem.dto.EmployeeSearchResult; +import com.srsystem.dto.EmployeeSummary; +import com.srsystem.entity.Employee; +import com.srsystem.entity.enums.EmpStatus; +import com.srsystem.exception.BusinessException; +import com.srsystem.exception.ErrorCode; +import com.srsystem.repository.DispatchHistoryRepository; +import com.srsystem.repository.EmployeeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class EmployeeService { + + private final EmployeeRepository employeeRepository; + private final DispatchHistoryRepository dispatchHistoryRepository; + private final PasswordEncoder passwordEncoder; + + public Page findAll(EmpStatus status, Pageable pageable) { + if (status != null) { + return employeeRepository.findByEmpStatus(status, pageable); + } + return employeeRepository.findAll(pageable); + } + + public Employee findById(Long empId) { + return employeeRepository.findById(empId) + .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); + } + + public Employee findByLoginId(String loginId) { + return employeeRepository.findByLoginId(loginId) + .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); + } + + public List searchByName(String keyword) { + List byName = employeeRepository.findByNameContainingIgnoreCase(keyword); + List results = new ArrayList<>(); + Set seen = new HashSet<>(); + for (Employee e : byName) { + if (seen.add(e.getEmpId())) { + results.add(toSearchResult(e)); + } + } + return results; + } + + private EmployeeSearchResult toSearchResult(Employee e) { + return new EmployeeSearchResult( + e.getEmpId(), + e.getName(), + e.getEmail(), + e.getDispatchStatus() != null ? e.getDispatchStatus().name() : null + ); + } + + @Transactional + public Employee create(Employee employee) { + if (employeeRepository.findByLoginId(employee.getLoginId()).isPresent()) { + throw new BusinessException(ErrorCode.DUPLICATE_LOGIN_ID); + } + + employee.setPassword(passwordEncoder.encode(employee.getPassword())); + return employeeRepository.save(employee); + } + + @Transactional + public Employee update(Long empId, Employee updateRequest) { + Employee employee = findById(empId); + + if (employee.getEmpStatus() == EmpStatus.RESIGNED) { + throw new BusinessException(ErrorCode.RESIGNED_EMPLOYEE); + } + + if (!employee.getLoginId().equals(updateRequest.getLoginId())) { + if (employeeRepository.findByLoginId(updateRequest.getLoginId()).isPresent()) { + throw new BusinessException(ErrorCode.DUPLICATE_LOGIN_ID); + } + employee.setLoginId(updateRequest.getLoginId()); + } + + employee.setName(updateRequest.getName()); + employee.setEmail(updateRequest.getEmail()); + employee.setPhone(updateRequest.getPhone()); + employee.setRoleCode(updateRequest.getRoleCode()); + employee.setEmpStatus(updateRequest.getEmpStatus()); + employee.setDispatchStatus(updateRequest.getDispatchStatus()); + employee.setJoinedDate(updateRequest.getJoinedDate()); + employee.setLeftDate(updateRequest.getLeftDate()); + + if (updateRequest.getPassword() != null && !updateRequest.getPassword().isBlank()) { + employee.setPassword(passwordEncoder.encode(updateRequest.getPassword())); + } + + return employee; + } + + @Transactional + public void delete(Long empId) { + Employee employee = findById(empId); + + if (employee.getEmpStatus() == EmpStatus.RESIGNED) { + throw new BusinessException(ErrorCode.RESIGNED_EMPLOYEE); + } + + employeeRepository.delete(employee); + } + + public EmployeeSummary toSummary(Employee employee) { + return new EmployeeSummary( + employee.getEmpId(), + employee.getName(), + employee.getEmail(), + employee.getPhone(), + employee.getRoleCode(), + employee.getEmpStatus(), + employee.getDispatchStatus(), + employee.getJoinedDate(), + employee.getLeftDate() + ); + } + + @Transactional + public void changePassword(Long empId, ChangePasswordRequest request) { + Employee employee = findById(empId); + if (!passwordEncoder.matches(request.currentPassword(), employee.getPassword())) { + throw new BusinessException(ErrorCode.AUTH_INVALID_CREDENTIALS); + } + employee.setPassword(passwordEncoder.encode(request.newPassword())); + employeeRepository.save(employee); + } +} diff --git a/backend/src/main/java/com/srsystem/service/EquipmentService.java b/backend/src/main/java/com/srsystem/service/EquipmentService.java new file mode 100644 index 0000000..645a3ad --- /dev/null +++ b/backend/src/main/java/com/srsystem/service/EquipmentService.java @@ -0,0 +1,279 @@ +package com.srsystem.service; + +import com.srsystem.dto.CreateEquipmentRequest; +import com.srsystem.dto.CreateMaintenanceRequest; +import com.srsystem.dto.EquipmentSummary; +import com.srsystem.dto.UpdateEquipmentRequest; +import com.srsystem.entity.Employee; +import com.srsystem.entity.Equipment; +import com.srsystem.entity.EquipmentAssignment; +import com.srsystem.entity.EquipmentMaintenance; +import com.srsystem.entity.enums.EmpStatus; +import com.srsystem.entity.enums.EquipmentStatus; +import com.srsystem.exception.BusinessException; +import com.srsystem.exception.ErrorCode; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.srsystem.repository.EmployeeRepository; +import com.srsystem.repository.EquipmentAssignmentRepository; +import com.srsystem.repository.EquipmentMaintenanceRepository; +import com.srsystem.repository.EquipmentRepository; +import jakarta.annotation.PostConstruct; +import jakarta.persistence.OptimisticLockException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class EquipmentService { + + private final EquipmentRepository equipmentRepository; + private final EquipmentAssignmentRepository equipmentAssignmentRepository; + private final EquipmentMaintenanceRepository equipmentMaintenanceRepository; + private final EmployeeRepository employeeRepository; + + @Value("${file.upload-dir:./uploads}") + private String uploadDir; + + @PostConstruct + public void initUploadDir() { + File dir = new File(uploadDir); + if (!dir.exists()) dir.mkdirs(); + } + + public Page getEquipment(String type, EquipmentStatus status, String keyword, Pageable pageable) { + if (status != null) { + return equipmentRepository.findByStatus(status, pageable); + } + return equipmentRepository.findAll(pageable); + } + + public Equipment getEquipment(Long equipId) { + return equipmentRepository.findById(equipId) + .orElseThrow(() -> new BusinessException(ErrorCode.EQUIPMENT_NOT_FOUND)); + } + + @Transactional + public Equipment createEquipment(CreateEquipmentRequest request) { + Equipment equipment = Equipment.builder() + .equipmentType(request.equipmentType()) + .manufacturer(request.manufacturer()) + .modelName(request.modelName()) + .serialNumber(request.serialNumber()) + .assetCode(request.assetCode()) + .specifications(request.specifications()) + .status(request.status() != null ? request.status() : EquipmentStatus.AVAILABLE) + .purchaseDate(request.purchaseDate()) + .warrantyExpDate(request.warrantyExpDate()) + .build(); + + return equipmentRepository.save(equipment); + } + + @Transactional + public Equipment updateEquipment(Long equipId, UpdateEquipmentRequest request) { + Equipment equipment = getEquipment(equipId); + + if (request.equipmentType() != null) { + equipment.setEquipmentType(request.equipmentType()); + } + if (request.manufacturer() != null) { + equipment.setManufacturer(request.manufacturer()); + } + if (request.modelName() != null) { + equipment.setModelName(request.modelName()); + } + if (request.serialNumber() != null) { + equipment.setSerialNumber(request.serialNumber()); + } + if (request.assetCode() != null) { + equipment.setAssetCode(request.assetCode()); + } + if (request.specifications() != null) { + equipment.setSpecifications(request.specifications()); + } + if (request.status() != null) { + equipment.setStatus(request.status()); + } + if (request.purchaseDate() != null) { + equipment.setPurchaseDate(request.purchaseDate()); + } + if (request.warrantyExpDate() != null) { + equipment.setWarrantyExpDate(request.warrantyExpDate()); + } + + return equipment; + } + + @Transactional + public void deleteEquipment(Long equipId) { + Equipment equipment = getEquipment(equipId); + equipmentRepository.delete(equipment); + } + + @Transactional + public EquipmentAssignment assignEquipment(Long equipId, Long userId, String condition) { + Equipment equipment = equipmentRepository.findById(equipId) + .orElseThrow(() -> new BusinessException(ErrorCode.EQUIPMENT_NOT_FOUND)); + + if (equipment.getStatus() != EquipmentStatus.AVAILABLE) { + throw new BusinessException(ErrorCode.EQUIP_NOT_AVAILABLE); + } + + Employee user = employeeRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); + + if (user.getEmpStatus() != EmpStatus.ACTIVE) { + throw new BusinessException(ErrorCode.INVALID_ASSIGNEE); + } + + try { + equipment.setStatus(EquipmentStatus.IN_USE); + equipmentRepository.save(equipment); + + EquipmentAssignment assignment = EquipmentAssignment.builder() + .equipment(equipment) + .user(user) + .assignedDate(LocalDateTime.now()) + .condOnAssign(condition) + .build(); + + return equipmentAssignmentRepository.save(assignment); + } catch (OptimisticLockException e) { + throw new BusinessException(ErrorCode.SYSTEM_CONCURRENCY); + } + } + + @Transactional + public EquipmentAssignment returnEquipment(Long equipId, String condition) { + Equipment equipment = equipmentRepository.findById(equipId) + .orElseThrow(() -> new BusinessException(ErrorCode.EQUIPMENT_NOT_FOUND)); + + EquipmentAssignment activeAssignment = equipmentAssignmentRepository + .findByEquipment_EquipmentIdAndReturnedDateIsNull(equipId) + .orElseThrow(() -> new BusinessException(ErrorCode.EQUIPMENT_NOT_FOUND)); + + activeAssignment.setReturnedDate(LocalDateTime.now()); + activeAssignment.setCondOnReturn(condition); + + equipment.setStatus(EquipmentStatus.AVAILABLE); + equipmentRepository.save(equipment); + + return equipmentAssignmentRepository.save(activeAssignment); + } + + public List getAssignments(Long equipId) { + return equipmentAssignmentRepository.findByEquipment_EquipmentId(equipId); + } + + @Transactional + public EquipmentMaintenance addMaintenance(Long equipId, CreateMaintenanceRequest request) { + Equipment equipment = getEquipment(equipId); + + Employee reportedBy = employeeRepository.findByLoginId(request.reportedBy()) + .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); + + EquipmentMaintenance maintenance = EquipmentMaintenance.builder() + .equipment(equipment) + .reportedBy(reportedBy) + .maintenanceType(request.maintenanceType()) + .issueDescription(request.issueDescription()) + .actionTaken(request.actionTaken()) + .cost(request.cost()) + .reportedDate(LocalDateTime.now()) + .build(); + + return equipmentMaintenanceRepository.save(maintenance); + } + + public List getMaintenanceHistory(Long equipId) { + return equipmentMaintenanceRepository.findByEquipment_EquipmentId(equipId); + } + + public Map getSummary() { + List all = equipmentRepository.findAll(); + Map counts = new HashMap<>(); + + for (EquipmentStatus status : EquipmentStatus.values()) { + long count = all.stream() + .filter(e -> e.getStatus() == status) + .count(); + counts.put(status.name(), count); + } + + return counts; + } + + @Transactional + public Equipment addImage(Long equipmentId, MultipartFile file) { + Equipment eq = equipmentRepository.findById(equipmentId) + .orElseThrow(() -> new BusinessException(ErrorCode.EQUIPMENT_NOT_FOUND)); + try { + String originalName = file.getOriginalFilename(); + String ext = ""; + if (originalName != null && originalName.contains(".")) { + ext = originalName.substring(originalName.lastIndexOf(".")); + } + String storedName = UUID.randomUUID().toString() + ext; + Path targetPath = Paths.get(uploadDir, storedName); + Files.copy(file.getInputStream(), targetPath); + String url = "/uploads/" + storedName; + + ObjectMapper mapper = new ObjectMapper(); + List> images = new ArrayList<>(); + if (eq.getImages() != null && !eq.getImages().isEmpty() && !eq.getImages().equals("[]")) { + images = mapper.readValue(eq.getImages(), new TypeReference>>() {}); + } + Map newImage = new HashMap<>(); + newImage.put("url", url); + newImage.put("name", originalName != null ? originalName : "unknown"); + newImage.put("uploadedAt", LocalDateTime.now().toString()); + images.add(newImage); + eq.setImages(mapper.writeValueAsString(images)); + return equipmentRepository.save(eq); + } catch (IOException e) { + throw new RuntimeException("Failed to upload image", e); + } + } + + @Transactional + public Equipment removeImage(Long equipmentId, int imageIndex) { + Equipment eq = equipmentRepository.findById(equipmentId) + .orElseThrow(() -> new BusinessException(ErrorCode.EQUIPMENT_NOT_FOUND)); + try { + ObjectMapper mapper = new ObjectMapper(); + List> images = new ArrayList<>(); + if (eq.getImages() != null && !eq.getImages().isEmpty()) { + images = mapper.readValue(eq.getImages(), new TypeReference>>() {}); + } + if (imageIndex >= 0 && imageIndex < images.size()) { + images.remove(imageIndex); + } + eq.setImages(mapper.writeValueAsString(images)); + return equipmentRepository.save(eq); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to parse images", e); + } + } +} diff --git a/backend/src/main/java/com/srsystem/service/NotificationService.java b/backend/src/main/java/com/srsystem/service/NotificationService.java new file mode 100644 index 0000000..b32ea2e --- /dev/null +++ b/backend/src/main/java/com/srsystem/service/NotificationService.java @@ -0,0 +1,54 @@ +package com.srsystem.service; + +import com.srsystem.entity.Employee; +import com.srsystem.entity.Notification; +import com.srsystem.exception.BusinessException; +import com.srsystem.exception.ErrorCode; +import com.srsystem.repository.EmployeeRepository; +import com.srsystem.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final EmployeeRepository employeeRepository; + + public List getMyNotifications(Long userId) { + return notificationRepository.findByUser_EmpIdOrderByCreatedAtDesc(userId); + } + + public long getUnreadCount(Long userId) { + return notificationRepository.countByUser_EmpIdAndIsRead(userId, 'N'); + } + + @Transactional + public void markAsRead(Long notificationId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> new BusinessException(ErrorCode.REPORT_NOT_FOUND)); + notification.setIsRead('Y'); + notificationRepository.save(notification); + } + + @Transactional + public Notification createNotification(Long userId, String type, String title, String message, String linkUrl) { + Employee user = employeeRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); + + Notification notification = Notification.builder() + .user(user) + .type(type) + .title(title) + .message(message) + .linkUrl(linkUrl) + .isRead('N') + .build(); + return notificationRepository.save(notification); + } +} diff --git a/backend/src/main/java/com/srsystem/service/ParkingService.java b/backend/src/main/java/com/srsystem/service/ParkingService.java new file mode 100644 index 0000000..3981f7d --- /dev/null +++ b/backend/src/main/java/com/srsystem/service/ParkingService.java @@ -0,0 +1,92 @@ +package com.srsystem.service; + +import com.srsystem.dto.HealthResponse; +import com.srsystem.dto.WakeRequest; +import com.srsystem.dto.WakeResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; + +@Service +public class ParkingService { + + private static final Logger log = LoggerFactory.getLogger(ParkingService.class); + + @Value("${parking.enabled:true}") + private boolean parkingEnabled; + + @Value("${parking.schedule.weekday-start:20:00}") + private String weekdayStart; + + @Value("${parking.schedule.weekday-end:08:00}") + private String weekdayEnd; + + @Value("${parking.schedule.weekends:true}") + private boolean parkingOnWeekends; + + public HealthResponse checkStatus() { + if (!parkingEnabled) { + return new HealthResponse(false, "Server is running (parking disabled)", null, "Parking disabled"); + } + + LocalDate today = LocalDate.now(); + LocalTime now = LocalTime.now(); + DayOfWeek dayOfWeek = today.getDayOfWeek(); + + boolean isWeekend = dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY; + LocalTime startTime = LocalTime.parse(weekdayStart, DateTimeFormatter.ofPattern("HH:mm")); + LocalTime endTime = LocalTime.parse(weekdayEnd, DateTimeFormatter.ofPattern("HH:mm")); + + boolean isParkingTime; + if (isWeekend) { + isParkingTime = parkingOnWeekends; + } else { + if (startTime.isBefore(endTime)) { + isParkingTime = !now.isBefore(startTime) && now.isBefore(endTime); + } else { + isParkingTime = !now.isBefore(startTime) || now.isBefore(endTime); + } + } + + String message = isParkingTime ? "Server is currently parked" : "Server is running"; + String resumeTime = isParkingTime ? calculateResumeTime(today, now) : null; + String schedule = String.format("Weekdays: %s-%s, Weekends: %s", weekdayStart, weekdayEnd, parkingOnWeekends ? "Parked" : "Running"); + + return new HealthResponse(isParkingTime, message, resumeTime, schedule); + } + + private String calculateResumeTime(LocalDate today, LocalTime now) { + DayOfWeek dayOfWeek = today.getDayOfWeek(); + LocalTime endTime = LocalTime.parse(weekdayEnd, DateTimeFormatter.ofPattern("HH:mm")); + + if (dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY) { + if (!parkingOnWeekends) { + return today.atTime(endTime).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); + } + LocalDate nextWeekday = today.with(DayOfWeek.MONDAY); + return nextWeekday.atTime(endTime).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); + } else { + return today.atTime(endTime).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); + } + } + + public WakeResponse requestWake(WakeRequest request) { + log.info("Wake request received - requestedBy: {}, reason: {}, requestedAt: {}", + request.requestedBy(), request.reason(), request.requestedAt()); + + String estimatedTime = LocalTime.now().plusMinutes(5) + .format(DateTimeFormatter.ofPattern("HH:mm")); + + return new WakeResponse( + true, + "iLO4 remote boot initiated. Server will be available shortly.", + estimatedTime + ); + } +} diff --git a/backend/src/main/java/com/srsystem/service/PortfolioService.java b/backend/src/main/java/com/srsystem/service/PortfolioService.java new file mode 100644 index 0000000..13516ad --- /dev/null +++ b/backend/src/main/java/com/srsystem/service/PortfolioService.java @@ -0,0 +1,94 @@ +package com.srsystem.service; + +import com.srsystem.dto.PortfolioResponse; +import com.srsystem.entity.*; +import com.srsystem.entity.enums.TicketStatus; +import com.srsystem.exception.BusinessException; +import com.srsystem.exception.ErrorCode; +import com.srsystem.repository.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PortfolioService { + + private final DispatchHistoryRepository dispatchHistoryRepository; + private final ReportRepository reportRepository; + private final TicketRepository ticketRepository; + + public List getPortfolio(Long empId) { + List histories = dispatchHistoryRepository.findByEmployee_EmpId(empId); + + return histories.stream().map(dh -> { + PortfolioResponse resp = new PortfolioResponse(); + resp.setDispatchId(dh.getDispatchId()); + resp.setProjectName(dh.getProject().getProjectName()); + resp.setRoleTitle(dh.getRoleTitle()); + resp.setContributionRate(dh.getContributionRate()); + resp.setIndividualTech(dh.getIndividualTech()); + resp.setPerformedTasks(dh.getPerformedTasks()); + resp.setAchievements(dh.getAchievements()); + resp.setStartDate(dh.getStartDate()); + resp.setEndDate(dh.getEndDate()); + resp.setInternalRating(dh.getInternalRating()); + + // Bind reports for this dispatch + List reports = reportRepository + .findByDispatch_DispatchId(dh.getDispatchId()) + .stream().map(r -> { + PortfolioResponse.BoundReport br = new PortfolioResponse.BoundReport(); + br.targetDate = r.getTargetDate() != null ? r.getTargetDate().toString() : ""; + br.reportType = r.getReportType().name(); + br.completedTasks = r.getCompletedTasks(); + return br; + }).collect(Collectors.toList()); + resp.setReports(reports); + + // Bind resolved + closed tickets for this project, by this employee + List tickets = new ArrayList<>(); + + ticketRepository + .findByProject_ProjectIdAndRequester_EmpIdAndStatus( + dh.getProject().getProjectId(), empId, TicketStatus.RESOLVED) + .forEach(t -> { + PortfolioResponse.BoundTicket bt = new PortfolioResponse.BoundTicket(); + bt.ticketId = t.getTicketId(); + bt.title = t.getTitle(); + bt.category = t.getCategory().name(); + bt.resolutionNote = t.getResolutionNote(); + tickets.add(bt); + }); + + ticketRepository + .findByProject_ProjectIdAndRequester_EmpIdAndStatus( + dh.getProject().getProjectId(), empId, TicketStatus.CLOSED) + .forEach(t -> { + PortfolioResponse.BoundTicket bt = new PortfolioResponse.BoundTicket(); + bt.ticketId = t.getTicketId(); + bt.title = t.getTitle(); + bt.category = t.getCategory().name(); + bt.resolutionNote = t.getResolutionNote(); + tickets.add(bt); + }); + + resp.setResolvedTickets(tickets); + + return resp; + }).collect(Collectors.toList()); + } + + @Transactional + public void updateAchievements(Long dispatchId, String achievements) { + DispatchHistory dh = dispatchHistoryRepository.findById(dispatchId) + .orElseThrow(() -> new BusinessException(ErrorCode.DISPATCH_NOT_FOUND)); + dh.setAchievements(achievements); + dispatchHistoryRepository.save(dh); + } +} diff --git a/backend/src/main/java/com/srsystem/service/ProjectService.java b/backend/src/main/java/com/srsystem/service/ProjectService.java new file mode 100644 index 0000000..811dfb5 --- /dev/null +++ b/backend/src/main/java/com/srsystem/service/ProjectService.java @@ -0,0 +1,112 @@ +package com.srsystem.service; + +import com.srsystem.dto.CreateProjectRequest; +import com.srsystem.entity.DispatchHistory; +import com.srsystem.entity.Employee; +import com.srsystem.entity.Project; +import com.srsystem.entity.enums.ProjectStatus; +import com.srsystem.exception.BusinessException; +import com.srsystem.exception.ErrorCode; +import com.srsystem.repository.DispatchHistoryRepository; +import com.srsystem.repository.EmployeeRepository; +import com.srsystem.repository.ProjectRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ProjectService { + + private final ProjectRepository projectRepository; + private final DispatchHistoryRepository dispatchHistoryRepository; + private final EmployeeRepository employeeRepository; + + public List getMyProjects(Long empId) { + List histories = dispatchHistoryRepository.findByEmployee_EmpId(empId); + Set projectIds = histories.stream() + .map(h -> h.getProject().getProjectId()) + .collect(Collectors.toSet()); + + return projectRepository.findAllById(projectIds); + } + + public Project getProject(Long projectId) { + return projectRepository.findById(projectId) + .orElseThrow(() -> new BusinessException(ErrorCode.PROJECT_NOT_FOUND)); + } + + @Transactional(readOnly = true) + public List findAll(ProjectStatus status) { + if (status != null) { + return projectRepository.findByStatus(status); + } + return projectRepository.findAll(); + } + + @Transactional + public Project create(CreateProjectRequest req) { + Employee internalManager = null; + if (req.internalManagerId() != null) { + internalManager = employeeRepository.findById(req.internalManagerId()) + .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); + } + + Project project = Project.builder() + .projectName(req.projectName()) + .projectType(req.projectType()) + .contractCompany(req.contractCompany()) + .endUserCompany(req.endUserCompany()) + .contractType(req.contractType()) + .status(ProjectStatus.PREPARING) + .startDate(req.startDate()) + .endDate(req.endDate()) + .workLocationType(req.workLocationType()) + .workAddress(req.workAddress()) + .techEnvironment(req.techEnvironment()) + .mainTaskDesc(req.mainTaskDesc()) + .internalManager(internalManager) + .build(); + + return projectRepository.save(project); + } + + @Transactional + public Project update(Long projectId, CreateProjectRequest req) { + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new BusinessException(ErrorCode.PROJECT_NOT_FOUND)); + + Employee internalManager = null; + if (req.internalManagerId() != null) { + internalManager = employeeRepository.findById(req.internalManagerId()) + .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); + } + + project.setProjectName(req.projectName()); + project.setProjectType(req.projectType()); + project.setContractCompany(req.contractCompany()); + project.setEndUserCompany(req.endUserCompany()); + project.setContractType(req.contractType()); + project.setStartDate(req.startDate()); + project.setEndDate(req.endDate()); + project.setWorkLocationType(req.workLocationType()); + project.setWorkAddress(req.workAddress()); + project.setTechEnvironment(req.techEnvironment()); + project.setMainTaskDesc(req.mainTaskDesc()); + project.setInternalManager(internalManager); + + return projectRepository.save(project); + } + + @Transactional + public void delete(Long projectId) { + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new BusinessException(ErrorCode.PROJECT_NOT_FOUND)); + projectRepository.delete(project); + } +} diff --git a/backend/src/main/java/com/srsystem/service/ReportService.java b/backend/src/main/java/com/srsystem/service/ReportService.java new file mode 100644 index 0000000..280074e --- /dev/null +++ b/backend/src/main/java/com/srsystem/service/ReportService.java @@ -0,0 +1,246 @@ +package com.srsystem.service; + +import com.srsystem.dto.CreateReportRequest; +import com.srsystem.dto.ReportSummary; +import com.srsystem.dto.ReviewReportRequest; +import com.srsystem.dto.TicketSummary; +import com.srsystem.dto.WorkReportResponse; +import com.srsystem.entity.DispatchHistory; +import com.srsystem.entity.Employee; +import com.srsystem.entity.Notification; +import com.srsystem.entity.Report; +import com.srsystem.entity.Ticket; +import com.srsystem.entity.WeeklyPlan; +import com.srsystem.entity.enums.ReportStatus; +import com.srsystem.entity.enums.ReportType; +import com.srsystem.entity.enums.TicketStatus; +import com.srsystem.exception.BusinessException; +import com.srsystem.exception.ErrorCode; +import com.srsystem.repository.DispatchHistoryRepository; +import com.srsystem.repository.EmployeeRepository; +import com.srsystem.repository.NotificationRepository; +import com.srsystem.repository.ReportRepository; +import com.srsystem.repository.TicketRepository; +import com.srsystem.repository.WeeklyPlanRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReportService { + + private final ReportRepository reportRepository; + private final EmployeeRepository employeeRepository; + private final DispatchHistoryRepository dispatchHistoryRepository; + private final TicketRepository ticketRepository; + private final NotificationRepository notificationRepository; + private final WeeklyPlanRepository weeklyPlanRepository; + + public Page getReports(ReportStatus status, String type, LocalDate targetDate, Long userId, Pageable pageable) { + Page reports; + + if (userId != null) { + reports = reportRepository.findByUser_EmpId(userId, pageable); + } else if (status != null) { + reports = reportRepository.findByStatus(status, pageable); + } else { + reports = reportRepository.findAll(pageable); + } + + return reports.map(this::toWorkReportResponse); + } + + public Page findByUserId(Long userId, ReportStatus status, String type, LocalDate targetDate, Pageable pageable) { + ReportType reportType = type != null ? ReportType.valueOf(type) : null; + Page reports = reportRepository.findByUser_EmpIdAndStatusAndReportType(userId, status, reportType, targetDate, pageable); + return reports.map(this::toWorkReportResponse); + } + + public WorkReportResponse getReport(Long reportId) { + Report report = reportRepository.findById(reportId) + .orElseThrow(() -> new BusinessException(ErrorCode.REPORT_NOT_FOUND)); + return toWorkReportResponse(report); + } + + @Transactional + public WorkReportResponse createReport(Long userId, CreateReportRequest request) { + if (request.targetDate().isAfter(LocalDate.now())) { + throw new BusinessException(ErrorCode.FUTURE_REPORT_DATE); + } + + Employee user = employeeRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); + + DispatchHistory dispatch = null; + if (request.dispatchId() != null) { + dispatch = dispatchHistoryRepository.findById(request.dispatchId()) + .orElseThrow(() -> new BusinessException(ErrorCode.PROJECT_NOT_FOUND)); + } + + Report report = Report.builder() + .dispatch(dispatch) + .user(user) + .reportType(request.reportType()) + .targetDate(request.targetDate()) + .completedTasks(request.completedTasks()) + .plannedTasks(request.plannedTasks()) + .issuesAndRisks(request.issuesAndRisks()) + .supportRequested(request.supportRequested()) + .status(ReportStatus.DRAFT) + .build(); + + if (request.workHours() != null) report.setWorkHours(request.workHours()); + if (request.blockerFlag() != null) report.setBlockerFlag(request.blockerFlag().charAt(0)); + if (request.artifacts() != null) report.setArtifacts(request.artifacts()); + if (request.weeklyPlanId() != null) { + weeklyPlanRepository.findById(request.weeklyPlanId()) + .ifPresent(report::setWeeklyPlan); + } + + Report saved = reportRepository.save(report); + return toWorkReportResponse(saved); + } + + @Transactional + public WorkReportResponse updateStatus(Long reportId, Long userId, ReportStatus newStatus) { + Report report = reportRepository.findById(reportId) + .orElseThrow(() -> new BusinessException(ErrorCode.REPORT_NOT_FOUND)); + + if (report.getStatus() == ReportStatus.APPROVED || report.getStatus() == ReportStatus.REJECTED) { + throw new BusinessException(ErrorCode.LOCKED_REPORT); + } + + if (newStatus == ReportStatus.SUBMITTED) { + report.setStatus(newStatus); + } + + Report updated = reportRepository.save(report); + return toWorkReportResponse(updated); + } + + @Transactional + public WorkReportResponse reviewReport(Long reportId, Long reviewerId, ReviewReportRequest request) { + Report report = reportRepository.findById(reportId) + .orElseThrow(() -> new BusinessException(ErrorCode.REPORT_NOT_FOUND)); + + if (report.getStatus() == ReportStatus.APPROVED || report.getStatus() == ReportStatus.REJECTED) { + throw new BusinessException(ErrorCode.LOCKED_REPORT); + } + + if (request.status() == ReportStatus.REJECTED && + (request.reviewComment() == null || request.reviewComment().isBlank())) { + throw new BusinessException(ErrorCode.LOCKED_REPORT); + } + + Employee reviewer = employeeRepository.findById(reviewerId) + .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); + + report.setStatus(request.status()); + report.setReviewer(reviewer); + report.setReviewComment(request.reviewComment()); + report.setReviewedAt(LocalDateTime.now()); + + Report updated = reportRepository.save(report); + + // Send notification if rejected + if (request.status() == ReportStatus.REJECTED) { + Notification notification = Notification.builder() + .user(report.getUser()) + .type("REPORT_REJECTED") + .title("Daily report rejected") + .message(String.format("Your report for %s was rejected. Reason: %s", report.getTargetDate(), request.reviewComment())) + .isRead('N') + .build(); + notificationRepository.save(notification); + } + + return toWorkReportResponse(updated); + } + + @Transactional + public void deleteReport(Long reportId) { + Report report = reportRepository.findById(reportId) + .orElseThrow(() -> new BusinessException(ErrorCode.REPORT_NOT_FOUND)); + + if (report.getStatus() == ReportStatus.APPROVED || report.getStatus() == ReportStatus.REJECTED) { + throw new BusinessException(ErrorCode.LOCKED_REPORT); + } + + reportRepository.delete(report); + } + + private WorkReportResponse toWorkReportResponse(Report report) { + String projectName = report.getDispatch() != null + ? report.getDispatch().getProject().getProjectName() + : null; + + String reviewerName = report.getReviewer() != null + ? report.getReviewer().getName() + : null; + + return new WorkReportResponse( + report.getReportId(), + report.getUser().getName(), + projectName, + report.getReportType(), + report.getTargetDate(), + report.getCompletedTasks(), + report.getPlannedTasks(), + report.getIssuesAndRisks(), + report.getSupportRequested(), + report.getStatus(), + reviewerName, + report.getReviewComment(), + report.getReviewedAt(), + report.getCreatedAt(), + report.getWorkHours(), + String.valueOf(report.getBlockerFlag()), + report.getArtifacts() + ); + } + + public ReportSummary getSummary() { + return new ReportSummary( + reportRepository.countByStatus(ReportStatus.SUBMITTED), + reportRepository.countByStatus(ReportStatus.APPROVED), + reportRepository.countByStatus(ReportStatus.REJECTED), + reportRepository.countByStatus(ReportStatus.DRAFT) + ); + } + + public List getResolvedTicketsForDate(Long userId, String date) { + LocalDate targetDate = LocalDate.parse(date); + LocalDateTime startOfDay = targetDate.atStartOfDay(); + LocalDateTime endOfDay = targetDate.atTime(LocalTime.MAX); + + List resolvedTickets = ticketRepository + .findByRequester_EmpIdAndStatusAndResolvedAtBetween(userId, TicketStatus.RESOLVED, startOfDay, endOfDay); + List closedTickets = ticketRepository + .findByRequester_EmpIdAndStatusAndResolvedAtBetween(userId, TicketStatus.CLOSED, startOfDay, endOfDay); + + List all = new ArrayList<>(); + all.addAll(resolvedTickets); + all.addAll(closedTickets); + + return all.stream().map(t -> new TicketSummary( + t.getTicketId(), + t.getTitle(), + t.getCategory().name(), + t.getPriority().name(), + t.getResolutionNote(), + t.getResolvedAt() != null ? t.getResolvedAt().toString() : null + )).collect(Collectors.toList()); + } +} diff --git a/backend/src/main/java/com/srsystem/service/TicketService.java b/backend/src/main/java/com/srsystem/service/TicketService.java new file mode 100644 index 0000000..973e517 --- /dev/null +++ b/backend/src/main/java/com/srsystem/service/TicketService.java @@ -0,0 +1,203 @@ +package com.srsystem.service; + +import com.srsystem.dto.CreateCommentRequest; +import com.srsystem.dto.CreateTicketRequest; +import com.srsystem.dto.IssueTicket; +import com.srsystem.dto.TicketCommentResponse; +import com.srsystem.entity.Employee; +import com.srsystem.entity.Project; +import com.srsystem.entity.Ticket; +import com.srsystem.entity.TicketComment; +import com.srsystem.entity.enums.TicketStatus; +import com.srsystem.exception.BusinessException; +import com.srsystem.exception.ErrorCode; +import com.srsystem.repository.EmployeeRepository; +import com.srsystem.repository.ProjectRepository; +import com.srsystem.repository.TicketCommentRepository; +import com.srsystem.repository.TicketRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TicketService { + + private final TicketRepository ticketRepository; + private final TicketCommentRepository ticketCommentRepository; + private final EmployeeRepository employeeRepository; + private final ProjectRepository projectRepository; + + public Page getTickets(TicketStatus status, String category, String priority, String keyword, Pageable pageable, Long requesterId) { + Page tickets; + + if (requesterId != null) { + tickets = ticketRepository.findByRequester_EmpId(requesterId, pageable); + } else if (status != null) { + tickets = ticketRepository.findByStatus(status, pageable); + } else { + tickets = ticketRepository.findAll(pageable); + } + + return tickets.map(this::toIssueTicket); + } + + public IssueTicket getTicket(Long ticketId) { + Ticket ticket = ticketRepository.findById(ticketId) + .orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND)); + return toIssueTicket(ticket); + } + + @Transactional + public IssueTicket createTicket(Long requesterId, CreateTicketRequest request) { + Employee requester = employeeRepository.findById(requesterId) + .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); + + Project project = projectRepository.findById(request.projectId()) + .orElseThrow(() -> new BusinessException(ErrorCode.PROJECT_NOT_FOUND)); + + Ticket ticket = Ticket.builder() + .project(project) + .requester(requester) + .category(request.category()) + .priority(request.priority()) + .title(request.title()) + .description(request.description()) + .status(TicketStatus.OPEN) + .slaWarnFlag('N') + .build(); + + Ticket saved = ticketRepository.save(ticket); + return toIssueTicket(saved); + } + + @Transactional + public IssueTicket updateStatus(Long ticketId, Long userId, TicketStatus newStatus) { + Ticket ticket = ticketRepository.findById(ticketId) + .orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND)); + + if (ticket.getStatus() == TicketStatus.CLOSED) { + throw new BusinessException(ErrorCode.CLOSED_TICKET_MOD); + } + + if (newStatus == TicketStatus.IN_PROGRESS && ticket.getAssignee() == null) { + throw new BusinessException(ErrorCode.ASSIGNEE_REQUIRED); + } + + if (newStatus == TicketStatus.RESOLVED && ticket.getResolvedAt() == null) { + ticket.setResolvedAt(LocalDateTime.now()); + } + + ticket.setStatus(newStatus); + Ticket updated = ticketRepository.save(ticket); + return toIssueTicket(updated); + } + + @Transactional + public IssueTicket assignTicket(Long ticketId, Long assigneeId) { + Ticket ticket = ticketRepository.findById(ticketId) + .orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND)); + + Employee assignee = employeeRepository.findById(assigneeId) + .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); + + ticket.setAssignee(assignee); + if (ticket.getStatus() == TicketStatus.OPEN) { + ticket.setStatus(TicketStatus.ASSIGNED); + } + + Ticket updated = ticketRepository.save(ticket); + return toIssueTicket(updated); + } + + @Transactional + public IssueTicket resolveTicket(Long ticketId, String resolutionNote) { + Ticket ticket = ticketRepository.findById(ticketId) + .orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND)); + + if (ticket.getStatus() == TicketStatus.CLOSED) { + throw new BusinessException(ErrorCode.CLOSED_TICKET_MOD); + } + + ticket.setStatus(TicketStatus.RESOLVED); + ticket.setResolvedAt(LocalDateTime.now()); + ticket.setResolutionNote(resolutionNote); + + Ticket updated = ticketRepository.save(ticket); + return toIssueTicket(updated); + } + + @Transactional + public TicketCommentResponse addComment(Long ticketId, Long userId, CreateCommentRequest request) { + Ticket ticket = ticketRepository.findById(ticketId) + .orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND)); + + if (ticket.getStatus() == TicketStatus.CLOSED) { + throw new BusinessException(ErrorCode.CLOSED_TICKET_MOD); + } + + Employee user = employeeRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); + + TicketComment comment = TicketComment.builder() + .ticket(ticket) + .user(user) + .commentText(request.commentText()) + .attachedFileUrl(request.attachedFileUrl()) + .build(); + + TicketComment saved = ticketCommentRepository.save(comment); + return new TicketCommentResponse( + saved.getCommentId(), + saved.getUser().getName(), + saved.getCommentText(), + saved.getAttachedFileUrl(), + saved.getCreatedAt() + ); + } + + public List getComments(Long ticketId) { + List comments = ticketCommentRepository.findByTicket_TicketIdOrderByCreatedAtAsc(ticketId); + return comments.stream() + .map(c -> new TicketCommentResponse( + c.getCommentId(), + c.getUser().getName(), + c.getCommentText(), + c.getAttachedFileUrl(), + c.getCreatedAt() + )) + .toList(); + } + + @Transactional + public void deleteTicket(Long ticketId) { + Ticket ticket = ticketRepository.findById(ticketId) + .orElseThrow(() -> new BusinessException(ErrorCode.TICKET_NOT_FOUND)); + ticketRepository.delete(ticket); + } + + private IssueTicket toIssueTicket(Ticket ticket) { + return new IssueTicket( + ticket.getTicketId(), + ticket.getProject().getProjectName(), + ticket.getRequester().getName(), + ticket.getAssignee() != null ? ticket.getAssignee().getName() : null, + ticket.getCategory(), + ticket.getPriority(), + ticket.getTitle(), + ticket.getDescription(), + ticket.getStatus(), + ticket.getDueDate(), + ticket.getResolvedAt(), + ticket.getResolutionNote(), + ticket.getSlaWarnFlag() == 'Y', + ticket.getCreatedAt() + ); + } +} diff --git a/backend/src/main/java/com/srsystem/service/TrainingService.java b/backend/src/main/java/com/srsystem/service/TrainingService.java new file mode 100644 index 0000000..588e51e --- /dev/null +++ b/backend/src/main/java/com/srsystem/service/TrainingService.java @@ -0,0 +1,76 @@ +package com.srsystem.service; + +import com.srsystem.dto.TrainingSummary; +import com.srsystem.dto.UpdateProgressRequest; +import com.srsystem.entity.Employee; +import com.srsystem.entity.TrainingProgress; +import com.srsystem.entity.enums.TrainingStatus; +import com.srsystem.exception.BusinessException; +import com.srsystem.exception.ErrorCode; +import com.srsystem.repository.EmployeeRepository; +import com.srsystem.repository.TrainingProgressRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TrainingService { + + private final TrainingProgressRepository trainingProgressRepository; + private final EmployeeRepository employeeRepository; + + public List getMyProgress(Long userId) { + return trainingProgressRepository.findByUser_EmpId(userId); + } + + @Transactional + public TrainingProgress updateProgress(Long progressId, UpdateProgressRequest request) { + TrainingProgress progress = trainingProgressRepository.findById(progressId) + .orElseThrow(() -> new BusinessException(ErrorCode.REPORT_NOT_FOUND)); + + if (request.progressRate() != null) { + progress.setProgressRate(request.progressRate()); + } + if (request.status() != null) { + progress.setStatus(request.status()); + } + + if (progress.getProgressRate() == 100) { + progress.setStatus(TrainingStatus.COMPLETED); + } + + return trainingProgressRepository.save(progress); + } + + public List getSummary() { + List employees = employeeRepository.findAll(); + + return employees.stream() + .map(emp -> { + List progresses = trainingProgressRepository.findByUser_EmpId(emp.getEmpId()); + long totalCourses = progresses.size(); + long completedCount = progresses.stream() + .filter(p -> p.getStatus() == TrainingStatus.COMPLETED) + .count(); + double avgProgressRate = progresses.stream() + .mapToInt(TrainingProgress::getProgressRate) + .average() + .orElse(0.0); + + return new TrainingSummary( + emp.getEmpId(), + emp.getName(), + totalCourses, + completedCount, + avgProgressRate + ); + }) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/main/java/com/srsystem/service/WeeklyPlanService.java b/backend/src/main/java/com/srsystem/service/WeeklyPlanService.java new file mode 100644 index 0000000..cfd022b --- /dev/null +++ b/backend/src/main/java/com/srsystem/service/WeeklyPlanService.java @@ -0,0 +1,61 @@ +package com.srsystem.service; + +import com.srsystem.entity.Employee; +import com.srsystem.entity.WeeklyPlan; +import com.srsystem.entity.enums.WeeklyPlanStatus; +import com.srsystem.exception.BusinessException; +import com.srsystem.exception.ErrorCode; +import com.srsystem.repository.EmployeeRepository; +import com.srsystem.repository.WeeklyPlanRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class WeeklyPlanService { + + private final WeeklyPlanRepository weeklyPlanRepository; + private final EmployeeRepository employeeRepository; + + public List getMyPlans(Long userId) { + return weeklyPlanRepository.findByUser_EmpIdOrderByWeekStartDesc(userId); + } + + public WeeklyPlan getPlan(Long planId) { + return weeklyPlanRepository.findById(planId) + .orElseThrow(() -> new BusinessException(ErrorCode.PLAN_NOT_FOUND)); + } + + @Transactional + public WeeklyPlan createPlan(Long userId, String tasksJson) { + Employee user = employeeRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.EMPLOYEE_NOT_FOUND)); + LocalDate weekStart = LocalDate.now().with(java.time.DayOfWeek.MONDAY); + + WeeklyPlan plan = WeeklyPlan.builder() + .user(user) + .weekStart(weekStart) + .status(WeeklyPlanStatus.DRAFT) + .tasks(tasksJson) + .build(); + return weeklyPlanRepository.save(plan); + } + + @Transactional + public WeeklyPlan updatePlan(Long planId, String tasksJson, WeeklyPlanStatus status) { + WeeklyPlan plan = getPlan(planId); + if (tasksJson != null) plan.setTasks(tasksJson); + if (status != null) plan.setStatus(status); + return weeklyPlanRepository.save(plan); + } + + @Transactional + public void deletePlan(Long planId) { + weeklyPlanRepository.deleteById(planId); + } +} diff --git a/backend/src/main/java/com/srsystem/service/notification/EmailSender.java b/backend/src/main/java/com/srsystem/service/notification/EmailSender.java new file mode 100644 index 0000000..c5b170c --- /dev/null +++ b/backend/src/main/java/com/srsystem/service/notification/EmailSender.java @@ -0,0 +1,22 @@ +package com.srsystem.service.notification; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class EmailSender { + + @Value("${notification.email.enabled:false}") + private boolean emailEnabled; + + public void send(String to, String subject, String body) { + if (!emailEnabled) { + log.debug("Email notification disabled, skipping"); + return; + } + // TODO: Implement JavaMailSender or external email API + log.info("Email notification sent: to={}, subject={}", to, subject); + } +} diff --git a/backend/src/main/java/com/srsystem/service/notification/SlackSender.java b/backend/src/main/java/com/srsystem/service/notification/SlackSender.java new file mode 100644 index 0000000..1658a60 --- /dev/null +++ b/backend/src/main/java/com/srsystem/service/notification/SlackSender.java @@ -0,0 +1,22 @@ +package com.srsystem.service.notification; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class SlackSender { + + @Value("${notification.slack.webhook-url:}") + private String webhookUrl; + + public void send(String title, String message) { + if (webhookUrl.isBlank()) { + log.debug("Slack webhook URL not configured, skipping Slack notification"); + return; + } + // TODO: Implement Slack Webhook API call + log.info("Slack notification sent: title={}", title); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..7d28beb --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,51 @@ +server: + port: 8080 + +spring: + datasource: + url: jdbc:postgresql://localhost:5432/sr_system + username: sr_admin + password: ${DB_PASSWORD:'!Tp@rtner$'} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + jackson: + serialization: + fail-on-empty-beans: false + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true + validate-on-migrate: false + batch: + jdbc: + initialize-schema: always + job: + enabled: false + +jwt: + secret: ${JWT_SECRET:sr-system-jwt-secret-key-change-in-production-at-least-256-bits-long} + access-token-expiration: 3600000 + refresh-token-expiration: 604800000 + +parking: + enabled: false + schedule: + weekday-start: "20:00" + weekday-end: "08:00" + weekends: true + +file: + upload-dir: ./uploads + +notification: + slack: + webhook-url: ${SLACK_WEBHOOK_URL:} + email: + enabled: false diff --git a/backend/src/main/resources/db/migration/V1__init_schema.sql b/backend/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 0000000..c330fb9 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,443 @@ +-- ============================================================ +-- SR System (HQ Helpdesk) - Initial Schema +-- PostgreSQL 15 +-- ============================================================ + +-- ============================================================ +-- SEQUENCES (INCREMENT BY 50 for JPA allocationSize) +-- ============================================================ + +CREATE SEQUENCE sq_employees START WITH 1 INCREMENT BY 50; +CREATE SEQUENCE sq_projects START WITH 1 INCREMENT BY 50; +CREATE SEQUENCE sq_dispatch_histories START WITH 1 INCREMENT BY 50; +CREATE SEQUENCE sq_tickets START WITH 1 INCREMENT BY 50; +CREATE SEQUENCE sq_ticket_comments START WITH 1 INCREMENT BY 50; +CREATE SEQUENCE sq_equipment START WITH 1 INCREMENT BY 50; +CREATE SEQUENCE sq_equipment_assignments START WITH 1 INCREMENT BY 50; +CREATE SEQUENCE sq_equipment_maintenance START WITH 1 INCREMENT BY 50; +CREATE SEQUENCE sq_reports START WITH 1 INCREMENT BY 50; +CREATE SEQUENCE sq_training_progress START WITH 1 INCREMENT BY 50; +CREATE SEQUENCE sq_notifications START WITH 1 INCREMENT BY 50; +CREATE SEQUENCE sq_common_codes START WITH 1 INCREMENT BY 50; + +-- ============================================================ +-- TB_EMPLOYEE +-- ============================================================ + +CREATE TABLE tb_employee ( + id BIGINT NOT NULL PRIMARY KEY, + login_id VARCHAR(50) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + name VARCHAR(100) NOT NULL, + email VARCHAR(255), + phone VARCHAR(20), + role_code VARCHAR(20) NOT NULL CHECK (role_code IN ('ADMIN', 'MANAGER', 'EMPLOYEE')), + emp_status VARCHAR(20) NOT NULL CHECK (emp_status IN ('ACTIVE', 'ON_LEAVE', 'RESIGNED')), + dispatch_status VARCHAR(20) NOT NULL CHECK (dispatch_status IN ('WAITING', 'DISPATCHED')), + joined_date DATE, + left_date DATE, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + is_deleted CHAR(1) NOT NULL DEFAULT 'N' +); + +CREATE INDEX idx_employee_login_id ON tb_employee(login_id); +CREATE INDEX idx_employee_role_code ON tb_employee(role_code); +CREATE INDEX idx_employee_emp_status ON tb_employee(emp_status); +CREATE INDEX idx_employee_dispatch_status ON tb_employee(dispatch_status); + +-- ============================================================ +-- TB_PROJECT +-- ============================================================ + +CREATE TABLE tb_project ( + id BIGINT NOT NULL PRIMARY KEY, + project_type VARCHAR(20) NOT NULL CHECK (project_type IN ('SI', 'SM', 'MIGRATION')), + project_name VARCHAR(255) NOT NULL, + contract_company VARCHAR(255) NOT NULL, + end_user_company VARCHAR(255), + contract_type VARCHAR(20) NOT NULL CHECK (contract_type IN ('TURN_KEY', 'MM')), + status VARCHAR(20) NOT NULL CHECK (status IN ('PREPARING', 'ONGOING', 'COMPLETED', 'HOLD')), + start_date DATE NOT NULL, + end_date DATE, + work_location_type VARCHAR(20) NOT NULL CHECK (work_location_type IN ('ON_SITE', 'REMOTE', 'HYBRID')), + work_address VARCHAR(500), + tech_environment TEXT, + main_task_desc TEXT, + internal_manager_id BIGINT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + is_deleted CHAR(1) NOT NULL DEFAULT 'N', + CONSTRAINT fk_project_internal_manager + FOREIGN KEY (internal_manager_id) + REFERENCES tb_employee(id) + ON DELETE RESTRICT +); + +CREATE INDEX idx_project_project_type ON tb_project(project_type); +CREATE INDEX idx_project_status ON tb_project(status); +CREATE INDEX idx_project_contract_company ON tb_project(contract_company); +CREATE INDEX idx_project_internal_manager_id ON tb_project(internal_manager_id); + +-- ============================================================ +-- TB_DISPATCH_HISTORY +-- ============================================================ + +CREATE TABLE tb_dispatch_history ( + id BIGINT NOT NULL PRIMARY KEY, + employee_id BIGINT NOT NULL, + project_id BIGINT NOT NULL, + start_date DATE NOT NULL, + end_date DATE, + role_title VARCHAR(255) NOT NULL, + contribution_rate INTEGER NOT NULL CHECK (contribution_rate BETWEEN 0 AND 100), + individual_tech TEXT NOT NULL, + performed_tasks TEXT NOT NULL, + achievements TEXT, + client_feedback TEXT, + internal_rating INTEGER CHECK (internal_rating BETWEEN 0 AND 100), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + is_deleted CHAR(1) NOT NULL DEFAULT 'N', + CONSTRAINT fk_dispatch_employee + FOREIGN KEY (employee_id) + REFERENCES tb_employee(id) + ON DELETE RESTRICT, + CONSTRAINT fk_dispatch_project + FOREIGN KEY (project_id) + REFERENCES tb_project(id) + ON DELETE RESTRICT +); + +CREATE INDEX idx_dispatch_employee_id ON tb_dispatch_history(employee_id); +CREATE INDEX idx_dispatch_project_id ON tb_dispatch_history(project_id); +CREATE INDEX idx_dispatch_start_date ON tb_dispatch_history(start_date); + +-- ============================================================ +-- TB_TICKET +-- ============================================================ + +CREATE TABLE tb_ticket ( + id BIGINT NOT NULL PRIMARY KEY, + project_id BIGINT NOT NULL, + requester_id BIGINT NOT NULL, + assignee_id BIGINT, + category VARCHAR(20) NOT NULL CHECK (category IN ('BUG', 'FEATURE', 'SUPPORT', 'QA')), + priority VARCHAR(20) NOT NULL CHECK (priority IN ('LOW', 'NORMAL', 'HIGH', 'CRITICAL')), + title VARCHAR(500) NOT NULL, + description TEXT NOT NULL, + status VARCHAR(20) NOT NULL CHECK (status IN ('OPEN', 'ASSIGNED', 'IN_PROGRESS', 'RESOLVED', 'CLOSED')), + due_date TIMESTAMP, + resolved_at TIMESTAMP, + resolution_note TEXT, + sla_warn_flag CHAR(1) NOT NULL DEFAULT 'N', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + is_deleted CHAR(1) NOT NULL DEFAULT 'N', + CONSTRAINT fk_ticket_project + FOREIGN KEY (project_id) + REFERENCES tb_project(id) + ON DELETE RESTRICT, + CONSTRAINT fk_ticket_requester + FOREIGN KEY (requester_id) + REFERENCES tb_employee(id) + ON DELETE RESTRICT, + CONSTRAINT fk_ticket_assignee + FOREIGN KEY (assignee_id) + REFERENCES tb_employee(id) + ON DELETE RESTRICT +); + +CREATE INDEX idx_ticket_project_id ON tb_ticket(project_id); +CREATE INDEX idx_ticket_requester_id ON tb_ticket(requester_id); +CREATE INDEX idx_ticket_assignee_id ON tb_ticket(assignee_id); +CREATE INDEX idx_ticket_status ON tb_ticket(status); +CREATE INDEX idx_ticket_priority ON tb_ticket(priority); +CREATE INDEX idx_ticket_category ON tb_ticket(category); +CREATE INDEX idx_ticket_due_date ON tb_ticket(due_date); + +-- ============================================================ +-- TB_TICKET_COMMENT +-- ============================================================ + +CREATE TABLE tb_ticket_comment ( + id BIGINT NOT NULL PRIMARY KEY, + ticket_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + comment_text TEXT NOT NULL, + attached_file_url VARCHAR(1000), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + is_deleted CHAR(1) NOT NULL DEFAULT 'N', + CONSTRAINT fk_comment_ticket + FOREIGN KEY (ticket_id) + REFERENCES tb_ticket(id) + ON DELETE RESTRICT, + CONSTRAINT fk_comment_user + FOREIGN KEY (user_id) + REFERENCES tb_employee(id) + ON DELETE RESTRICT +); + +CREATE INDEX idx_comment_ticket_id ON tb_ticket_comment(ticket_id); +CREATE INDEX idx_comment_user_id ON tb_ticket_comment(user_id); + +-- ============================================================ +-- TB_EQUIPMENT +-- ============================================================ + +CREATE TABLE tb_equipment ( + id BIGINT NOT NULL PRIMARY KEY, + equipment_type VARCHAR(20) NOT NULL CHECK (equipment_type IN ('LAPTOP', 'MONITOR', 'TEST_DEVICE', 'ACCESSORY')), + manufacturer VARCHAR(255) NOT NULL, + model_name VARCHAR(255) NOT NULL, + serial_number VARCHAR(100) NOT NULL UNIQUE, + asset_code VARCHAR(100) NOT NULL UNIQUE, + specifications JSONB, + status VARCHAR(20) NOT NULL CHECK (status IN ('AVAILABLE', 'IN_USE', 'REPAIRING', 'RETIRED', 'LOST')), + purchase_date DATE NOT NULL, + warranty_exp_date DATE, + version BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + is_deleted CHAR(1) NOT NULL DEFAULT 'N' +); + +CREATE INDEX idx_equipment_equipment_type ON tb_equipment(equipment_type); +CREATE INDEX idx_equipment_status ON tb_equipment(status); +CREATE INDEX idx_equipment_asset_code ON tb_equipment(asset_code); +CREATE INDEX idx_equipment_serial_number ON tb_equipment(serial_number); + +-- ============================================================ +-- TB_EQUIPMENT_ASSIGNMENT +-- ============================================================ + +CREATE TABLE tb_equipment_assignment ( + id BIGINT NOT NULL PRIMARY KEY, + equipment_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + assigned_date TIMESTAMP NOT NULL DEFAULT NOW(), + returned_date TIMESTAMP, + cond_on_assign TEXT NOT NULL, + cond_on_return TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + is_deleted CHAR(1) NOT NULL DEFAULT 'N', + CONSTRAINT fk_assignment_equipment + FOREIGN KEY (equipment_id) + REFERENCES tb_equipment(id) + ON DELETE RESTRICT, + CONSTRAINT fk_assignment_user + FOREIGN KEY (user_id) + REFERENCES tb_employee(id) + ON DELETE RESTRICT +); + +CREATE INDEX idx_assignment_equipment_id ON tb_equipment_assignment(equipment_id); +CREATE INDEX idx_assignment_user_id ON tb_equipment_assignment(user_id); +CREATE INDEX idx_assignment_assigned_date ON tb_equipment_assignment(assigned_date); + +-- ============================================================ +-- TB_EQUIPMENT_MAINTENANCE +-- ============================================================ + +CREATE TABLE tb_equipment_maintenance ( + id BIGINT NOT NULL PRIMARY KEY, + equipment_id BIGINT NOT NULL, + reported_by BIGINT NOT NULL, + maintenance_type VARCHAR(20) NOT NULL CHECK (maintenance_type IN ('REPAIR', 'UPGRADE', 'INSPECTION', 'DISPOSAL')), + issue_description TEXT NOT NULL, + action_taken TEXT, + cost NUMERIC(12, 2) NOT NULL DEFAULT 0, + reported_date TIMESTAMP NOT NULL DEFAULT NOW(), + resolved_date TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + is_deleted CHAR(1) NOT NULL DEFAULT 'N', + CONSTRAINT fk_maintenance_equipment + FOREIGN KEY (equipment_id) + REFERENCES tb_equipment(id) + ON DELETE RESTRICT, + CONSTRAINT fk_maintenance_reported_by + FOREIGN KEY (reported_by) + REFERENCES tb_employee(id) + ON DELETE RESTRICT +); + +CREATE INDEX idx_maintenance_equipment_id ON tb_equipment_maintenance(equipment_id); +CREATE INDEX idx_maintenance_reported_by ON tb_equipment_maintenance(reported_by); +CREATE INDEX idx_maintenance_type ON tb_equipment_maintenance(maintenance_type); + +-- ============================================================ +-- TB_REPORT +-- ============================================================ + +CREATE TABLE tb_report ( + id BIGINT NOT NULL PRIMARY KEY, + dispatch_id BIGINT, + user_id BIGINT NOT NULL, + report_type VARCHAR(20) NOT NULL CHECK (report_type IN ('DAILY', 'WEEKLY', 'MONTHLY', 'RETURN')), + target_date DATE NOT NULL, + completed_tasks TEXT NOT NULL, + planned_tasks TEXT NOT NULL, + issues_and_risks TEXT, + support_requested TEXT, + status VARCHAR(20) NOT NULL CHECK (status IN ('DRAFT', 'SUBMITTED', 'APPROVED', 'REJECTED')), + reviewer_id BIGINT, + review_comment TEXT, + reviewed_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + is_deleted CHAR(1) NOT NULL DEFAULT 'N', + CONSTRAINT fk_report_dispatch + FOREIGN KEY (dispatch_id) + REFERENCES tb_dispatch_history(id) + ON DELETE RESTRICT, + CONSTRAINT fk_report_user + FOREIGN KEY (user_id) + REFERENCES tb_employee(id) + ON DELETE RESTRICT, + CONSTRAINT fk_report_reviewer + FOREIGN KEY (reviewer_id) + REFERENCES tb_employee(id) + ON DELETE RESTRICT +); + +CREATE INDEX idx_report_user_id ON tb_report(user_id); +CREATE INDEX idx_report_dispatch_id ON tb_report(dispatch_id); +CREATE INDEX idx_report_report_type ON tb_report(report_type); +CREATE INDEX idx_report_status ON tb_report(status); +CREATE INDEX idx_report_target_date ON tb_report(target_date); +CREATE INDEX idx_report_reviewer_id ON tb_report(reviewer_id); + +-- ============================================================ +-- TB_TRAINING_PROGRESS +-- ============================================================ + +CREATE TABLE tb_training_progress ( + id BIGINT NOT NULL PRIMARY KEY, + user_id BIGINT NOT NULL, + course_name VARCHAR(255) NOT NULL, + progress_rate INTEGER NOT NULL CHECK (progress_rate BETWEEN 0 AND 100), + status VARCHAR(20) NOT NULL CHECK (status IN ('NOT_STARTED', 'ONGOING', 'COMPLETED')), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + is_deleted CHAR(1) NOT NULL DEFAULT 'N', + CONSTRAINT fk_training_user + FOREIGN KEY (user_id) + REFERENCES tb_employee(id) + ON DELETE RESTRICT +); + +CREATE INDEX idx_training_user_id ON tb_training_progress(user_id); +CREATE INDEX idx_training_status ON tb_training_progress(status); +CREATE INDEX idx_training_course_name ON tb_training_progress(course_name); + +-- ============================================================ +-- TB_NOTIFICATION +-- ============================================================ + +CREATE TABLE tb_notification ( + id BIGINT NOT NULL PRIMARY KEY, + user_id BIGINT NOT NULL, + type VARCHAR(50) NOT NULL, + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + is_read CHAR(1) NOT NULL DEFAULT 'N', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + is_deleted CHAR(1) NOT NULL DEFAULT 'N', + CONSTRAINT fk_notification_user + FOREIGN KEY (user_id) + REFERENCES tb_employee(id) + ON DELETE RESTRICT +); + +CREATE INDEX idx_notification_user_id ON tb_notification(user_id); +CREATE INDEX idx_notification_type ON tb_notification(type); +CREATE INDEX idx_notification_is_read ON tb_notification(is_read); + +-- ============================================================ +-- TB_COMMON_CODE +-- ============================================================ + +CREATE TABLE tb_common_code ( + id BIGINT NOT NULL PRIMARY KEY, + group_code VARCHAR(50) NOT NULL, + code_value VARCHAR(100) NOT NULL, + code_name VARCHAR(255) NOT NULL, + option_val1 VARCHAR(255), + option_val2 VARCHAR(255), + sort_order INTEGER NOT NULL DEFAULT 0, + is_active CHAR(1) NOT NULL DEFAULT 'Y', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + is_deleted CHAR(1) NOT NULL DEFAULT 'N' +); + +CREATE INDEX idx_common_code_group_code ON tb_common_code(group_code); +CREATE INDEX idx_common_code_is_active ON tb_common_code(is_active); +CREATE UNIQUE INDEX idx_common_code_group_value ON tb_common_code(group_code, code_value); + +-- ============================================================ +-- INITIAL DATA - COMMON CODES +-- ============================================================ + +INSERT INTO tb_common_code (id, group_code, code_value, code_name, sort_order, is_active) VALUES +(nextval('sq_common_codes'), 'EMP_ROLE', 'ADMIN', '최고 관리자', 1, 'Y'), +(nextval('sq_common_codes'), 'EMP_ROLE', 'MANAGER', '본사 관리자', 2, 'Y'), +(nextval('sq_common_codes'), 'EMP_ROLE', 'EMPLOYEE', '일반 사용자', 3, 'Y'), +(nextval('sq_common_codes'), 'EMP_STATUS', 'ACTIVE', '재직', 1, 'Y'), +(nextval('sq_common_codes'), 'EMP_STATUS', 'ON_LEAVE', '휴직', 2, 'Y'), +(nextval('sq_common_codes'), 'EMP_STATUS', 'RESIGNED', '퇴사', 3, 'Y'), +(nextval('sq_common_codes'), 'DISPATCH_STATUS', 'WAITING', '대기', 1, 'Y'), +(nextval('sq_common_codes'), 'DISPATCH_STATUS', 'DISPATCHED', '파견중', 2, 'Y'), +(nextval('sq_common_codes'), 'PROJECT_TYPE', 'SI', '시스템 통합', 1, 'Y'), +(nextval('sq_common_codes'), 'PROJECT_TYPE', 'SM', '시스템 유지보수', 2, 'Y'), +(nextval('sq_common_codes'), 'PROJECT_TYPE', 'MIGRATION', '마이그레이션', 3, 'Y'), +(nextval('sq_common_codes'), 'CONTRACT_TYPE', 'TURN_KEY', '턴키', 1, 'Y'), +(nextval('sq_common_codes'), 'CONTRACT_TYPE', 'MM', '인월계약', 2, 'Y'), +(nextval('sq_common_codes'), 'PROJECT_STATUS', 'PREPARING', '준비중', 1, 'Y'), +(nextval('sq_common_codes'), 'PROJECT_STATUS', 'ONGOING', '진행중', 2, 'Y'), +(nextval('sq_common_codes'), 'PROJECT_STATUS', 'COMPLETED', '완료', 3, 'Y'), +(nextval('sq_common_codes'), 'PROJECT_STATUS', 'HOLD', '보류', 4, 'Y'), +(nextval('sq_common_codes'), 'WORK_LOCATION', 'ON_SITE', '현상근무', 1, 'Y'), +(nextval('sq_common_codes'), 'WORK_LOCATION', 'REMOTE', '재택', 2, 'Y'), +(nextval('sq_common_codes'), 'WORK_LOCATION', 'HYBRID', '하이브리드', 3, 'Y'), +(nextval('sq_common_codes'), 'TICKET_CATEGORY', 'BUG', '버그', 1, 'Y'), +(nextval('sq_common_codes'), 'TICKET_CATEGORY', 'FEATURE', '기능요청', 2, 'Y'), +(nextval('sq_common_codes'), 'TICKET_CATEGORY', 'SUPPORT', '기술지원', 3, 'Y'), +(nextval('sq_common_codes'), 'TICKET_CATEGORY', 'QA', '품질문의', 4, 'Y'), +(nextval('sq_common_codes'), 'TICKET_PRIORITY', 'LOW', '낮음', 1, 'Y'), +(nextval('sq_common_codes'), 'TICKET_PRIORITY', 'NORMAL', '보통', 2, 'Y'), +(nextval('sq_common_codes'), 'TICKET_PRIORITY', 'HIGH', '높음', 3, 'Y'), +(nextval('sq_common_codes'), 'TICKET_PRIORITY', 'CRITICAL', '긴급', 4, 'Y'), +(nextval('sq_common_codes'), 'TICKET_STATUS', 'OPEN', '접수', 1, 'Y'), +(nextval('sq_common_codes'), 'TICKET_STATUS', 'ASSIGNED', '배정', 2, 'Y'), +(nextval('sq_common_codes'), 'TICKET_STATUS', 'IN_PROGRESS', '처리중', 3, 'Y'), +(nextval('sq_common_codes'), 'TICKET_STATUS', 'RESOLVED', '해결', 4, 'Y'), +(nextval('sq_common_codes'), 'TICKET_STATUS', 'CLOSED', '종료', 5, 'Y'), +(nextval('sq_common_codes'), 'EQUIPMENT_TYPE', 'LAPTOP', '노트북', 1, 'Y'), +(nextval('sq_common_codes'), 'EQUIPMENT_TYPE', 'MONITOR', '모니터', 2, 'Y'), +(nextval('sq_common_codes'), 'EQUIPMENT_TYPE', 'TEST_DEVICE', '테스트장비', 3, 'Y'), +(nextval('sq_common_codes'), 'EQUIPMENT_TYPE', 'ACCESSORY', '액세서리', 4, 'Y'), +(nextval('sq_common_codes'), 'EQUIPMENT_STATUS', 'AVAILABLE', '사용가능', 1, 'Y'), +(nextval('sq_common_codes'), 'EQUIPMENT_STATUS', 'IN_USE', '사용중', 2, 'Y'), +(nextval('sq_common_codes'), 'EQUIPMENT_STATUS', 'REPAIRING', '수리중', 3, 'Y'), +(nextval('sq_common_codes'), 'EQUIPMENT_STATUS', 'RETIRED', '폐기', 4, 'Y'), +(nextval('sq_common_codes'), 'EQUIPMENT_STATUS', 'LOST', '분실', 5, 'Y'), +(nextval('sq_common_codes'), 'MAINTENANCE_TYPE', 'REPAIR', '수리', 1, 'Y'), +(nextval('sq_common_codes'), 'MAINTENANCE_TYPE', 'UPGRADE', '업그레이드', 2, 'Y'), +(nextval('sq_common_codes'), 'MAINTENANCE_TYPE', 'INSPECTION', '점검', 3, 'Y'), +(nextval('sq_common_codes'), 'MAINTENANCE_TYPE', 'DISPOSAL', '폐기', 4, 'Y'), +(nextval('sq_common_codes'), 'REPORT_TYPE', 'DAILY', '일일보고', 1, 'Y'), +(nextval('sq_common_codes'), 'REPORT_TYPE', 'WEEKLY', '주간보고', 2, 'Y'), +(nextval('sq_common_codes'), 'REPORT_TYPE', 'MONTHLY', '월간보고', 3, 'Y'), +(nextval('sq_common_codes'), 'REPORT_TYPE', 'RETURN', '복귀보고', 4, 'Y'), +(nextval('sq_common_codes'), 'REPORT_STATUS', 'DRAFT', '임시저장', 1, 'Y'), +(nextval('sq_common_codes'), 'REPORT_STATUS', 'SUBMITTED', '제출', 2, 'Y'), +(nextval('sq_common_codes'), 'REPORT_STATUS', 'APPROVED', '승인', 3, 'Y'), +(nextval('sq_common_codes'), 'REPORT_STATUS', 'REJECTED', '반려', 4, 'Y'), +(nextval('sq_common_codes'), 'TRAINING_STATUS', 'NOT_STARTED', '수강전', 1, 'Y'), +(nextval('sq_common_codes'), 'TRAINING_STATUS', 'ONGOING', '수강중', 2, 'Y'), +(nextval('sq_common_codes'), 'TRAINING_STATUS', 'COMPLETED', '완료', 3, 'Y'); diff --git a/backend/src/main/resources/db/migration/V2__seed_admin.sql b/backend/src/main/resources/db/migration/V2__seed_admin.sql new file mode 100644 index 0000000..645538a --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__seed_admin.sql @@ -0,0 +1,11 @@ +INSERT INTO tb_employee (id, login_id, password, name, email, role_code, emp_status, dispatch_status, joined_date) +SELECT nextval('sq_employees'), 'admin', '$2a$10$esMvhqy5..BznXdoGHNGiej3zIx8UV9x2x5NM6rJayIChMFV0sV76', 'Admin', 'admin@sr-system.com', 'ADMIN', 'ACTIVE', 'WAITING', CURRENT_DATE +WHERE NOT EXISTS (SELECT 1 FROM tb_employee WHERE login_id = 'admin'); + +INSERT INTO tb_employee (id, login_id, password, name, email, role_code, emp_status, dispatch_status, joined_date) +SELECT nextval('sq_employees'), 'manager', '$2a$10$yLm4w9OMemIrhMtPuNL5seoxWDZJ5R38yNB2yTOaQ0BhoLOgZehfm', 'Manager Kim', 'manager@sr-system.com', 'MANAGER', 'ACTIVE', 'WAITING', CURRENT_DATE +WHERE NOT EXISTS (SELECT 1 FROM tb_employee WHERE login_id = 'manager'); + +INSERT INTO tb_employee (id, login_id, password, name, email, role_code, emp_status, dispatch_status, joined_date) +SELECT nextval('sq_employees'), 'employee', '$2a$10$FNveJr.DdyTjtRl0eOYVfOvEEmAWSrip3tUdeo2PMsNznlLxmcCly', 'Employee Lee', 'employee@sr-system.com', 'EMPLOYEE', 'ACTIVE', 'WAITING', CURRENT_DATE +WHERE NOT EXISTS (SELECT 1 FROM tb_employee WHERE login_id = 'employee'); diff --git a/backend/src/main/resources/db/migration/V5__seed_data.sql b/backend/src/main/resources/db/migration/V5__seed_data.sql new file mode 100644 index 0000000..ae81ffa --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__seed_data.sql @@ -0,0 +1,160 @@ +-- ============================================================ +-- SR System - Complete Seed Data (clean insert) +-- V5 — self-contained, no BEGIN/COMMIT (Flyway handles TX) +-- ============================================================ + +-- Remove previous seed data (FK-safe order) +DELETE FROM tb_notification; +DELETE FROM tb_training_progress; +DELETE FROM tb_report; +DELETE FROM tb_equipment_maintenance; +DELETE FROM tb_equipment_assignment; +DELETE FROM tb_equipment; +DELETE FROM tb_ticket_comment; +DELETE FROM tb_ticket; +DELETE FROM tb_dispatch_history; +DELETE FROM tb_project; +DELETE FROM tb_employee WHERE login_id IN ('parkdispatch', 'kimdev', 'leespecialist', 'choiexpert'); + +-- Reset sequences (not sq_employees — V2 keeps IDs 1/51/101) +ALTER SEQUENCE sq_projects RESTART WITH 1; +ALTER SEQUENCE sq_dispatch_histories RESTART WITH 1; +ALTER SEQUENCE sq_tickets RESTART WITH 1; +ALTER SEQUENCE sq_ticket_comments RESTART WITH 1; +ALTER SEQUENCE sq_equipment RESTART WITH 1; +ALTER SEQUENCE sq_equipment_assignments RESTART WITH 1; +ALTER SEQUENCE sq_equipment_maintenance RESTART WITH 1; +ALTER SEQUENCE sq_reports RESTART WITH 1; +ALTER SEQUENCE sq_training_progress RESTART WITH 1; +ALTER SEQUENCE sq_notifications RESTART WITH 1; + +-- Update employee(101) → DISPATCHED +UPDATE tb_employee SET dispatch_status = 'DISPATCHED', updated_at = NOW() WHERE login_id = 'employee' AND dispatch_status = 'WAITING'; + +-- ============================================================ +-- 1. EMPLOYEES (4명, explicit IDs) +-- ============================================================ +INSERT INTO tb_employee (id, login_id, password, name, email, role_code, emp_status, dispatch_status, joined_date) VALUES +(151, 'parkdispatch', '$2a$10$FNveJr.DdyTjtRl0eOYVfOvEEmAWSrip3tUdeo2PMsNznlLxmcCly', '박파견', 'park@dispatch.com', 'EMPLOYEE', 'ACTIVE', 'DISPATCHED', '2024-03-01'), +(201, 'kimdev', '$2a$10$FNveJr.DdyTjtRl0eOYVfOvEEmAWSrip3tUdeo2PMsNznlLxmcCly', '김개발', 'kim@dispatch.com', 'EMPLOYEE', 'ACTIVE', 'DISPATCHED', '2024-06-01'), +(251, 'leespecialist','$2a$10$FNveJr.DdyTjtRl0eOYVfOvEEmAWSrip3tUdeo2PMsNznlLxmcCly', '이전문', 'lee@sr-system.com', 'MANAGER', 'ACTIVE', 'WAITING', '2023-01-01'), +(301, 'choiexpert', '$2a$10$FNveJr.DdyTjtRl0eOYVfOvEEmAWSrip3tUdeo2PMsNznlLxmcCly', '최전문', 'choi@sr-system.com', 'MANAGER', 'ACTIVE', 'WAITING', '2022-07-01'); + +-- ============================================================ +-- 2. PROJECTS (4개, nextval → 1, 51, 101, 151) +-- ============================================================ +INSERT INTO tb_project (id, project_type, project_name, contract_company, end_user_company, contract_type, status, start_date, end_date, work_location_type, work_address, tech_environment, main_task_desc, internal_manager_id) VALUES +(nextval('sq_projects'), 'SI', '삼성페이 고도화 (SI)', '삼성전자', '삼성전자', 'TURN_KEY', 'ONGOING', '2025-01-01', '2026-12-31', 'ON_SITE', '서울시 서초구', 'Java 17, Spring Boot 3.x, Oracle 19c, Kubernetes', '삼성페이 차세대 시스템 구축', 1), +(nextval('sq_projects'), 'SM', 'LG CNS SM 운영', 'LG CNS', 'LG CNS', 'MM', 'ONGOING', '2024-01-01', '2026-06-30', 'HYBRID', '서울시 강남구', 'Java 11, Spring Framework, Oracle 11g, Jenkins', 'LG CNS 시스템 유지보수', 51), +(nextval('sq_projects'), 'SM', '카카오 서비스 운영', '카카오', '카카오', 'MM', 'ONGOING', '2024-06-01', '2025-12-31', 'REMOTE', '경기도 판교', 'Kotlin, Spring Cloud, MySQL, Kafka', '카카오 서비스 운영', 51), +(nextval('sq_projects'), 'SI', 'SK하이닉스 차세대', 'SK하이닉스','SK하이닉스','TURN_KEY','PREPARING', '2026-07-01', '2027-12-31', 'ON_SITE', '충청북도 청주시','Java 17, Spring Boot 3.x, PostgreSQL, React', 'SK하이닉스 MES 시스템 구축', 1); + +-- ============================================================ +-- 3. DISPATCH HISTORIES (3건, nextval → 1, 51, 101) +-- ============================================================ +INSERT INTO tb_dispatch_history (id, employee_id, project_id, start_date, role_title, contribution_rate, individual_tech, performed_tasks, achievements, client_feedback, internal_rating) VALUES +(nextval('sq_dispatch_histories'), (SELECT id FROM tb_employee WHERE login_id='parkdispatch'), (SELECT id FROM tb_project WHERE project_name LIKE '삼성페이%'), '2025-01-01', '프론트엔드 개발자', 80, 'React, TypeScript, Next.js, Tailwind CSS', '프론트엔드 아키텍처 설계 및 공통 컴포넌트 개발, 성능 최적화', '프론트엔드 로딩 속도 40% 개선, 공통 UI 라이브러리 구축', '협업 능력이 뛰어나고 기술적으로 신뢰할 수 있음', 85), +(nextval('sq_dispatch_histories'), (SELECT id FROM tb_employee WHERE login_id='kimdev'), (SELECT id FROM tb_project WHERE project_name LIKE '카카오%'), '2024-06-01', '백엔드 엔지니어', 60, 'Java, Kotlin, Spring Cloud, MySQL, Kafka', 'MSA 기반 백엔드 개발 및 배포, Kafka 이벤트 처리', '메시지 처리량 2배 향상, 장애 대응 시간 50% 단축', '적극적인 문제 해결 능력 보유', 78), +(nextval('sq_dispatch_histories'), (SELECT id FROM tb_employee WHERE login_id='employee'), (SELECT id FROM tb_project WHERE project_name LIKE 'LG CNS%'), '2024-01-01', '유지보수 엔지니어', 70, 'Java 11, Spring Framework, Oracle 11g, Jenkins', '장애 대응, 정기 패치, 성능 모니터링, 배치 자동화', '월간 장애 건수 30% 감소, 배포 자동화 구축', '꼼꼼하고 안정적인 운영 능력', 82); + +-- ============================================================ +-- 4. TICKETS (8건, nextval → 1, 51, 101, 151, 201, 251, 301, 351) +-- ============================================================ +INSERT INTO tb_ticket (id, project_id, requester_id, assignee_id, category, priority, title, description, status, due_date) VALUES +(nextval('sq_tickets'), (SELECT id FROM tb_project WHERE project_name LIKE '삼성페이%'), (SELECT id FROM tb_employee WHERE login_id='parkdispatch'), NULL, 'BUG', 'CRITICAL', 'JVM 메모리 릭 및 Heap Dump 분석 요청', 'Prod JVM 메모리 사용량 지속 증가, Full GC 빈도 높음', 'OPEN', '2026-05-25 18:00:00'), +(nextval('sq_tickets'), (SELECT id FROM tb_project WHERE project_name LIKE 'LG CNS%'), (SELECT id FROM tb_employee WHERE login_id='employee'), NULL, 'SUPPORT', 'HIGH', '사내망 방화벽 정책 가이드 필요', '파견지 사내망 연동 방화벽 정책 가이드 요청', 'OPEN', '2026-05-28 18:00:00'); + +INSERT INTO tb_ticket (id, project_id, requester_id, assignee_id, category, priority, title, description, status, due_date, sla_warn_flag) +VALUES (nextval('sq_tickets'), (SELECT id FROM tb_project WHERE project_name LIKE '삼성페이%'), (SELECT id FROM tb_employee WHERE login_id='kimdev'), (SELECT id FROM tb_employee WHERE login_id='leespecialist'), 'BUG', 'CRITICAL', 'CI/CD 파이프라인 staging 배포 실패', 'Staging 배포 시 Docker build 실패, 의존성 문제', 'ASSIGNED', '2026-05-24 18:00:00', 'Y'); + +INSERT INTO tb_ticket (id, project_id, requester_id, assignee_id, category, priority, title, description, status, due_date) VALUES +(nextval('sq_tickets'), (SELECT id FROM tb_project WHERE project_name LIKE '카카오%'), (SELECT id FROM tb_employee WHERE login_id='kimdev'), (SELECT id FROM tb_employee WHERE login_id='choiexpert'), 'SUPPORT', 'NORMAL', 'MacBook Pro 배터리 교체 요청', 'MacBook Pro 14 M1 배터리 성능 78%, 교체 주기 도래', 'IN_PROGRESS', '2026-06-05 18:00:00'), +(nextval('sq_tickets'), (SELECT id FROM tb_project WHERE project_name LIKE 'LG CNS%'), (SELECT id FROM tb_employee WHERE login_id='parkdispatch'), (SELECT id FROM tb_employee WHERE login_id='leespecialist'), 'SUPPORT', 'HIGH', 'VPN 접속 연장 요청', '원격 접속 VPN 인증서 만료 예정', 'RESOLVED', '2026-05-20 18:00:00'), +(nextval('sq_tickets'), (SELECT id FROM tb_project WHERE project_name LIKE '삼성페이%'), (SELECT id FROM tb_employee WHERE login_id='parkdispatch'), (SELECT id FROM tb_employee WHERE login_id='leespecialist'), 'BUG', 'HIGH', 'DB 커넥션 풀 고갈 문제 분석', '오전 10-12시 DB 커넥션 풀 고갈 현상', 'CLOSED', '2026-05-18 18:00:00'), +(nextval('sq_tickets'), (SELECT id FROM tb_project WHERE project_name LIKE '삼성페이%'), (SELECT id FROM tb_employee WHERE login_id='parkdispatch'), NULL, 'QA', 'HIGH', '오라클 쿼리 튜닝 필요 (Full Scan)', '실행 계획 Full Scan 발생, 5000만건 테이블 인덱스 튜닝 필요', 'OPEN', '2026-06-01 18:00:00'), +(nextval('sq_tickets'), (SELECT id FROM tb_project WHERE project_name LIKE '카카오%'), (SELECT id FROM tb_employee WHERE login_id='kimdev'), NULL, 'FEATURE', 'NORMAL', 'API 응답 캐싱 도입 제안', '반복 API 응답 속도 개선을 위한 Redis 캐싱 도입', 'OPEN', '2026-06-10 18:00:00'); + +-- Add resolved/closed timestamps +UPDATE tb_ticket SET resolved_at = '2026-05-19 17:00:00', resolution_note = 'VPN 인증서 재발급 완료' WHERE id = (SELECT id FROM tb_ticket WHERE title LIKE 'VPN 접속%'); +UPDATE tb_ticket SET resolved_at = '2026-05-17 12:00:00', resolution_note = 'Connection leak 수정 완료' WHERE id = (SELECT id FROM tb_ticket WHERE title LIKE 'DB 커넥션 풀%'); + +-- ============================================================ +-- 5. TICKET COMMENTS (6건, nextval → 1, 51, 101, 151, 201, 251) +-- ============================================================ +INSERT INTO tb_ticket_comment (id, ticket_id, user_id, comment_text, attached_file_url) +VALUES (nextval('sq_ticket_comments'), (SELECT id FROM tb_ticket WHERE title LIKE 'JVM 메모리 릭%'), (SELECT id FROM tb_employee WHERE login_id='leespecialist'), 'Heap Dump 분석 결과: G1OldGen 누수 패턴 확인됨', '/uploads/heap-dump-analysis-20260520.pdf'); + +INSERT INTO tb_ticket_comment (id, ticket_id, user_id, comment_text) +VALUES (nextval('sq_ticket_comments'), (SELECT id FROM tb_ticket WHERE title LIKE 'JVM 메모리 릭%'), (SELECT id FROM tb_employee WHERE login_id='parkdispatch'), 'Reference Chain 추적 중, 곧 수정안 올리겠습니다.'); + +INSERT INTO tb_ticket_comment (id, ticket_id, user_id, comment_text) +VALUES (nextval('sq_ticket_comments'), (SELECT id FROM tb_ticket WHERE title LIKE 'CI/CD%'), (SELECT id FROM tb_employee WHERE login_id='leespecialist'), 'Dockerfile base image 태그가 latest로 지정됨. 특정 버전으로 고정 필요.'); + +INSERT INTO tb_ticket_comment (id, ticket_id, user_id, comment_text) +VALUES (nextval('sq_ticket_comments'), (SELECT id FROM tb_ticket WHERE title LIKE 'CI/CD%'), (SELECT id FROM tb_employee WHERE login_id='kimdev'), '17-jdk-slim으로 변경 후 재빌드하겠습니다.'); + +INSERT INTO tb_ticket_comment (id, ticket_id, user_id, comment_text) +VALUES (nextval('sq_ticket_comments'), (SELECT id FROM tb_ticket WHERE title LIKE 'MacBook Pro 배터리%'), (SELECT id FROM tb_employee WHERE login_id='choiexpert'), '배터리 재고 확인 후 회신드리겠습니다.'); + +INSERT INTO tb_ticket_comment (id, ticket_id, user_id, comment_text, attached_file_url) +VALUES (nextval('sq_ticket_comments'), (SELECT id FROM tb_ticket WHERE title LIKE '오라클 쿼리 튜닝%'), (SELECT id FROM tb_employee WHERE login_id='leespecialist'), '결합 인덱스 생성으로 튜닝 가능. 스크립트 첨부.', '/uploads/tuning_script_20260521.sql'); + +-- ============================================================ +-- 6. EQUIPMENT (8건, nextval → 1, 51, 101, 151, 201, 251, 301, 351) +-- ============================================================ +INSERT INTO tb_equipment (id, equipment_type, manufacturer, model_name, serial_number, asset_code, specifications, status, purchase_date, warranty_exp_date, version) VALUES +(nextval('sq_equipment'), 'LAPTOP', 'Apple', 'MacBook Pro 16 M3', 'F2G8H3J9K1', 'HQ-LP-2026-0001', '{"cpu":"M3 Pro","ram":"36GB","ssd":"512GB"}', 'IN_USE', '2026-01-15', '2028-01-15', 0), +(nextval('sq_equipment'), 'LAPTOP', 'Apple', 'MacBook Pro 14 M3', 'A1B2C3D4E5', 'HQ-LP-2026-0002', '{"cpu":"M3","ram":"18GB","ssd":"512GB"}', 'AVAILABLE', '2026-02-01', '2028-02-01', 0), +(nextval('sq_equipment'), 'MONITOR', 'LG', '27UN880 Ergo', 'LG-27U-001', 'HQ-MO-2026-0001', '{"size":"27\"","resolution":"4K UHD","panel":"IPS"}', 'IN_USE', '2026-01-20', '2028-01-20', 0), +(nextval('sq_equipment'), 'LAPTOP', 'Samsung', 'Galaxy Book4 Pro', 'SAMSUNG-001', 'HQ-LP-2026-0003', '{"cpu":"Ultra 7","ram":"16GB","ssd":"256GB"}', 'REPAIRING', '2026-03-01', '2028-03-01', 0), +(nextval('sq_equipment'), 'MONITOR', 'Dell', 'U2723QE', 'DELL-U27-002', 'HQ-MO-2026-0002', '{"size":"27\"","resolution":"4K","panel":"IPS Black"}', 'AVAILABLE', '2026-04-01', '2028-04-01', 0), +(nextval('sq_equipment'), 'ACCESSORY', 'Logitech', 'MX Master 3S', 'LOG-MX-001', 'HQ-AC-2026-0001', '{"type":"Mouse","connection":"Bluetooth"}', 'IN_USE', '2026-01-10', '2027-01-10', 0), +(nextval('sq_equipment'), 'TEST_DEVICE', 'Samsung', 'Galaxy S24 Ultra', 'SAMSUNG-PH-001','HQ-TD-2026-0001', '{"storage":"512GB","color":"Titanium Gray"}', 'AVAILABLE', '2026-02-15', '2027-02-15', 0), +(nextval('sq_equipment'), 'ACCESSORY', 'Apple', 'Magic Keyboard (Kor)', 'APPLE-KB-001', 'HQ-AC-2026-0002', '{"type":"Keyboard","layout":"Korean","connection":"Bluetooth"}','IN_USE', '2026-01-10', '2028-01-10', 0); + +-- ============================================================ +-- 7. EQUIPMENT ASSIGNMENTS (3건, nextval → 1, 51, 101) +-- ============================================================ +INSERT INTO tb_equipment_assignment (id, equipment_id, user_id, assigned_date, cond_on_assign) VALUES +(nextval('sq_equipment_assignments'), (SELECT id FROM tb_equipment WHERE asset_code='HQ-LP-2026-0001'), (SELECT id FROM tb_employee WHERE login_id='parkdispatch'), '2026-01-20 09:00:00', '초기 지급: 케이스/충전기 포함, 외관 이상 없음'), +(nextval('sq_equipment_assignments'), (SELECT id FROM tb_equipment WHERE asset_code='HQ-MO-2026-0001'), (SELECT id FROM tb_employee WHERE login_id='employee'), '2026-02-01 10:00:00', '모니터 암 포함, 불량 화소 없음'), +(nextval('sq_equipment_assignments'), (SELECT id FROM tb_equipment WHERE asset_code='HQ-AC-2026-0001'), (SELECT id FROM tb_employee WHERE login_id='kimdev'), '2026-01-15 14:00:00', '수신기 포함'); + +-- ============================================================ +-- 8. EQUIPMENT MAINTENANCE (2건, nextval → 1, 51) +-- ============================================================ +INSERT INTO tb_equipment_maintenance (id, equipment_id, reported_by, maintenance_type, issue_description, action_taken, cost, reported_date, resolved_date) VALUES +(nextval('sq_equipment_maintenance'), (SELECT id FROM tb_equipment WHERE asset_code='HQ-LP-2026-0003'), (SELECT id FROM tb_employee WHERE login_id='parkdispatch'), 'REPAIR', '액정 파손, 터치패널 일부 미작동', '액정 교체 및 전체 기능 테스트 완료', 450000.00, '2026-04-10 09:00:00', '2026-04-15 18:00:00'), +(nextval('sq_equipment_maintenance'), (SELECT id FROM tb_equipment WHERE asset_code='HQ-LP-2026-0001'), (SELECT id FROM tb_employee WHERE login_id='parkdispatch'), 'UPGRADE', 'SSD 512GB→1TB 업그레이드 요청', 'Samsung 990 Pro 1TB 교체, 데이터 마이그레이션 완료', 180000.00, '2026-03-20 10:00:00', '2026-03-21 16:00:00'); + +-- ============================================================ +-- 9. REPORTS (5건, nextval → 1, 51, 101, 151, 201) +-- ============================================================ +INSERT INTO tb_report (id, dispatch_id, user_id, report_type, target_date, completed_tasks, planned_tasks, issues_and_risks, support_requested, status) VALUES +(nextval('sq_reports'), (SELECT id FROM tb_dispatch_history WHERE employee_id=(SELECT id FROM tb_employee WHERE login_id='employee') LIMIT 1), (SELECT id FROM tb_employee WHERE login_id='employee'), 'WEEKLY', '2026-05-11', '정기 패치 적용 완료\n배치 오류 수정 3건\n보안 취약점 점검', 'DB 정기 점검\n월간 리포팅\n매뉴얼 업데이트', 'DB 디스크 사용률 85%, 증설 검토', '디스크 증설 요청', 'DRAFT'), +(nextval('sq_reports'), (SELECT id FROM tb_dispatch_history WHERE employee_id=(SELECT id FROM tb_employee WHERE login_id='employee') LIMIT 1), (SELECT id FROM tb_employee WHERE login_id='employee'), 'WEEKLY', '2026-05-18', 'DB 정기 점검 완료\n보안 패치 CVE-2026-1234 적용\n장애 대응 매뉴얼 v2.1', '성능 테스트 준비\n배치 자동화 고도화\n문서 정리', '없음', '없음', 'SUBMITTED'), +(nextval('sq_reports'), (SELECT id FROM tb_dispatch_history WHERE employee_id=(SELECT id FROM tb_employee WHERE login_id='employee') LIMIT 1), (SELECT id FROM tb_employee WHERE login_id='employee'), 'WEEKLY', '2026-05-04', '긴급 패치 2건\n모니터링 대시보드 고도화\n배치 자동화 테스트', '배치 자동화 본배포\n정기 보고서', '긴급 패치로 일정 지연', '없음', 'APPROVED'), +(nextval('sq_reports'), (SELECT id FROM tb_dispatch_history WHERE employee_id=(SELECT id FROM tb_employee WHERE login_id='parkdispatch') LIMIT 1), (SELECT id FROM tb_employee WHERE login_id='parkdispatch'), 'WEEKLY', '2026-05-18', '공통 컴포넌트 리팩토링\nLCP 2.5s→1.2s 최적화\nStorybook 문서화', '신규 페이지 5건\nE2E 테스트\n코드 리뷰', '디자인팀 일정 조율', '없음', 'SUBMITTED'), +(nextval('sq_reports'), (SELECT id FROM tb_dispatch_history WHERE employee_id=(SELECT id FROM tb_employee WHERE login_id='kimdev') LIMIT 1), (SELECT id FROM tb_employee WHERE login_id='kimdev'), 'WEEKLY', '2026-05-18', 'Kafka Lag 모니터링 개선\nAPI Gateway 인증 업데이트\nCI/CD 파이프라인 개선', '부하 테스트\nKafka 업그레이드 준비', 'Kafka 3.5→3.7 호환성', '업그레이드 가이드 요청', 'SUBMITTED'); + +-- ============================================================ +-- 10. TRAINING PROGRESS (6건, nextval → 1, 51, 101, 151, 201, 251) +-- ============================================================ +INSERT INTO tb_training_progress (id, user_id, course_name, progress_rate, status) VALUES +(nextval('sq_training_progress'), (SELECT id FROM tb_employee WHERE login_id='employee'), 'Spring Boot 심화', 100, 'COMPLETED'), +(nextval('sq_training_progress'), (SELECT id FROM tb_employee WHERE login_id='employee'), 'Kubernetes 실무', 65, 'ONGOING'), +(nextval('sq_training_progress'), (SELECT id FROM tb_employee WHERE login_id='employee'), 'Docker & 컨테이너', 100, 'COMPLETED'), +(nextval('sq_training_progress'), (SELECT id FROM tb_employee WHERE login_id='employee'), 'DevOps 파이프라인', 0, 'NOT_STARTED'), +(nextval('sq_training_progress'), (SELECT id FROM tb_employee WHERE login_id='parkdispatch'), 'Spring Boot 심화', 45, 'ONGOING'), +(nextval('sq_training_progress'), (SELECT id FROM tb_employee WHERE login_id='kimdev'), 'Apache Kafka 심화', 80, 'ONGOING'); + +-- ============================================================ +-- 11. NOTIFICATIONS (6건, nextval → 1, 51, 101, 151, 201, 251) +-- ============================================================ +INSERT INTO tb_notification (id, user_id, type, title, message, is_read) VALUES +(nextval('sq_notifications'), (SELECT id FROM tb_employee WHERE login_id='parkdispatch'), 'TICKET_ASSIGNED', 'SR 티켓 배정', 'CI/CD 배포 실패 건이 이전문 님께 배정됨', 'N'), +(nextval('sq_notifications'), (SELECT id FROM tb_employee WHERE login_id='leespecialist'), 'TICKET_ASSIGNED', 'SR 티켓 배정', 'CI/CD 배포 실패 건 담당자 지정됨', 'Y'), +(nextval('sq_notifications'), 51, 'REPORT_SUBMITTED', '보고서 제출', 'Employee Lee 님 주간 보고서 제출', 'N'), +(nextval('sq_notifications'), (SELECT id FROM tb_employee WHERE login_id='employee'), 'REPORT_APPROVED', '보고서 승인', '5/4 주간 보고서 승인됨', 'Y'), +(nextval('sq_notifications'), (SELECT id FROM tb_employee WHERE login_id='parkdispatch'), 'EQUIPMENT_MAINTENANCE','장비 수리 완료', 'Galaxy Book4 Pro 액정 수리 완료', 'N'), +(nextval('sq_notifications'), 1, 'SLA_WARNING', 'SLA 임박', 'CI/CD 배포 실패 건 처리 기한 24시간 이내', 'N'); diff --git a/backend/src/main/resources/db/migration/V6__add_equipment_images.sql b/backend/src/main/resources/db/migration/V6__add_equipment_images.sql new file mode 100644 index 0000000..f7de580 --- /dev/null +++ b/backend/src/main/resources/db/migration/V6__add_equipment_images.sql @@ -0,0 +1,5 @@ +-- ============================================================ +-- V6: Add multi-image support to equipment (JSONB array) +-- ============================================================ + +ALTER TABLE tb_equipment ADD COLUMN IF NOT EXISTS images JSONB DEFAULT '[]'::jsonb; diff --git a/backend/src/main/resources/db/migration/V7__add_report_enhancements.sql b/backend/src/main/resources/db/migration/V7__add_report_enhancements.sql new file mode 100644 index 0000000..5e70e82 --- /dev/null +++ b/backend/src/main/resources/db/migration/V7__add_report_enhancements.sql @@ -0,0 +1,29 @@ +-- V7__add_report_enhancements.sql +-- 업무 보고 고도화: 근무 시간, blocker 플래그, 산출물, 주간 계획 연동 + +ALTER TABLE tb_report + ADD COLUMN IF NOT EXISTS work_hours NUMERIC(5,2), + ADD COLUMN IF NOT EXISTS blocker_flag CHAR(1) DEFAULT 'N' NOT NULL, + ADD COLUMN IF NOT EXISTS artifacts JSONB, + ADD COLUMN IF NOT EXISTS weekly_plan_id BIGINT; + +-- 주간 계획 테이블 +CREATE TABLE IF NOT EXISTS tb_weekly_plan ( + id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + week_start DATE NOT NULL, + status VARCHAR(20) NOT NULL, + tasks JSONB, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + is_deleted CHAR(1) DEFAULT 'N' NOT NULL, + CONSTRAINT pk_weekly_plan PRIMARY KEY (id), + CONSTRAINT fk_weekly_plan_user FOREIGN KEY (user_id) REFERENCES tb_employee (id) +); + +CREATE SEQUENCE IF NOT EXISTS sq_weekly_plans START WITH 1 INCREMENT BY 50; + +-- 인덱스 추가 +CREATE INDEX IF NOT EXISTS idx_weekly_plan_user ON tb_weekly_plan (user_id); +CREATE INDEX IF NOT EXISTS idx_weekly_plan_week_start ON tb_weekly_plan (week_start); +CREATE INDEX IF NOT EXISTS idx_report_weekly_plan ON tb_report (weekly_plan_id); diff --git a/backend/src/main/resources/db/migration/V8__add_daily_task_progress.sql b/backend/src/main/resources/db/migration/V8__add_daily_task_progress.sql new file mode 100644 index 0000000..7184b19 --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__add_daily_task_progress.sql @@ -0,0 +1,23 @@ +-- V8__add_daily_task_progress.sql +-- 일일 태스크 진도 관리 테이블 + +CREATE TABLE IF NOT EXISTS tb_daily_task_progress ( + id BIGINT NOT NULL, + report_id BIGINT NOT NULL, + weekly_plan_id BIGINT NOT NULL, + task_name VARCHAR(500) NOT NULL, + progress_rate INTEGER NOT NULL, + work_hours NUMERIC(5,2) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + is_deleted CHAR(1) DEFAULT 'N' NOT NULL, + CONSTRAINT pk_daily_task_progress PRIMARY KEY (id), + CONSTRAINT fk_daily_task_progress_report FOREIGN KEY (report_id) REFERENCES tb_report (id), + CONSTRAINT fk_daily_task_progress_weekly_plan FOREIGN KEY (weekly_plan_id) REFERENCES tb_weekly_plan (id) +); + +CREATE SEQUENCE IF NOT EXISTS sq_daily_task_progress START WITH 1 INCREMENT BY 50; + +-- 인덱스 추가 +CREATE INDEX IF NOT EXISTS idx_daily_task_progress_report ON tb_daily_task_progress (report_id); +CREATE INDEX IF NOT EXISTS idx_daily_task_progress_weekly_plan ON tb_daily_task_progress (weekly_plan_id); diff --git a/backend/src/main/resources/db/migration/V9__add_notification_link_url.sql b/backend/src/main/resources/db/migration/V9__add_notification_link_url.sql new file mode 100644 index 0000000..d2508ca --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__add_notification_link_url.sql @@ -0,0 +1 @@ +ALTER TABLE tb_notification ADD COLUMN link_url VARCHAR(500); diff --git a/backend/uploads/9c0d9ba0-919b-4579-a7c3-0bbe083a7fd3.jpg b/backend/uploads/9c0d9ba0-919b-4579-a7c3-0bbe083a7fd3.jpg new file mode 100644 index 0000000..5f33bc5 --- /dev/null +++ b/backend/uploads/9c0d9ba0-919b-4579-a7c3-0bbe083a7fd3.jpg @@ -0,0 +1 @@ +test image diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..247ff59 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + backend: + build: ./backend + ports: + - "8080:8080" + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://192.168.31.2:5432/sr_service_db + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-sr_admin} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} + JWT_SECRET: ${JWT_SECRET} + volumes: + - uploads:/app/uploads + restart: unless-stopped + + frontend: + build: + context: . + args: + API_BACKEND_URL: http://backend:8080/api/v1 + ports: + - "3000:3000" + environment: + NODE_ENV: production + API_BACKEND_URL: http://backend:8080/api/v1 + depends_on: + - backend + restart: unless-stopped + +volumes: + uploads: diff --git a/docs/01-requirements.md b/docs/01-requirements.md new file mode 100644 index 0000000..84cc5ba --- /dev/null +++ b/docs/01-requirements.md @@ -0,0 +1,76 @@ +# 시스템 요구 기능 정의서 (Functional Requirements) + +## 1. 시스템 개요 + +- **시스템명:** SM/SI 파견 직원 통합 지원 및 IT 자산 관리 포털 (HQ 헬프데스크) +- **목적:** 파견 직원 라이프사이클/커리어 관리, 현장 기술 문제 본사(SR) 지원, IT 자산 생명주기 추적 통합 +- **기대 효과:** 본사-파견지 간 정보 간극 해소, 운영 효율성 극대화 + +## 2. 역할 및 권한 체계 (RBAC) + +| 역할 | 코드 | 설명 | +|------|------|------| +| 일반 사용자 | `EMPLOYEE` | 파견지 현장 직원. 포트폴리오 관리, 업무 보고서 작성, SR 티켓 신청, 장비/교육 조회 | +| 본사 관리자 | `MANAGER` | 영업/인사/자산 담당. 직원 현황 관리, 보고 결재, SR 티켓 처리, 장비 인벤토리 제어 | +| 최고 관리자 | `ADMIN` | 전역 계정 관리, 접근 권한 부여, 공통 코드 테이블 동적 제어 | + +## 3. 도메인별 기능 요구사항 + +### 3.1 인사 및 포트폴리오 관리 + +| 기능 | 설명 | API | 권한 | +|------|------|-----|------| +| 직원 정보 관리 | 기본 인적사항, 재직 상태(재직/휴직/퇴사), 파견 상태(대기/파견중) CRUD | `/api/v1/employees` | ADMIN | +| 포트폴리오 누적 | 투입 프로젝트, 수행 역할, 기간, 개인 기여 기술, 성과 기록 (1:N) | `/api/v1/dispatch-history` | EMPLOYEE | +| 경력 기술서 조회 | 본인 포트폴리오 데이터 통합 조회 | `/api/v1/employees/{id}/portfolio` | EMPLOYEE | + +### 3.2 HQ 헬프데스크 (SR 지원) + +| 기능 | 설명 | API | 권한 | +|------|------|-----|------| +| SR 티켓 발행 | 현장 기술 난제 발생 시 SOS 요청 (에러 로그, 캡처 이미지 첨부) | `POST /api/v1/tickets` | EMPLOYEE | +| 티켓 목록/칸반 | 상태별(OPEN/IN_PROGRESS/RESOLVED/CLOSED) 칸반 보드 조회 | `GET /api/v1/tickets` | ALL | +| 티켓 상세/타임라인 | 티켓 상세 정보 + 시간순 댓글(파일 포함) 내역 | `GET /api/v1/tickets/{id}` | ALL | +| 댓글 등록 | 본사-현장 간 분석 의견, 스크립트 소통 | `POST /api/v1/tickets/{id}/comments` | ASSIGNED | +| 상태 전환 | OPEN→ASSIGNED→IN_PROGRESS→RESOLVED→CLOSED | `PUT /api/v1/tickets/{id}/status` | MANAGER | +| 필터/검색 | 카테고리, 우선순위, 키워드 기반 검색 | `GET /api/v1/tickets?category=&priority=&keyword=` | ALL | +| SLA 만료 알림 | 목표 처리 기한 초과 시 Slack/Email 알림 자동 발송 | Batch Scheduler | SYSTEM | + +### 3.3 IT 자산 관리 (ITAM) + +| 기능 | 설명 | API | 권한 | +|------|------|-----|------| +| 장비 마스터 관리 | 자산 코드, 제조사, 모델명 기반 장비 CRUD | `GET/POST /api/v1/equipment` | MANAGER | +| 장비 지급 | AVAILABLE 장비 → 직원 할당 (낙관적 락 동시성 제어) | `POST /api/v1/equipment/{id}/assign` | MANAGER | +| 장비 반납 | IN_USE 장비 회수 + 상태 코멘트 기록 | `PUT /api/v1/equipment/{id}/return` | MANAGER | +| 사용 이력 추적 | 장비별 과거 지급/반납 내역 (1:N) | `GET /api/v1/equipment/{id}/assignments` | MANAGER | +| 유지보수 이력 | 수리, 업그레이드(RAM/SSD), 폐기 이력 및 비용 관리 | `POST /api/v1/equipment/{id}/maintenance` | MANAGER | +| 직원 검색 자동완성 | 장비 지급 대상 사원 검색 (디바운싱 300ms) | `GET /api/v1/employees/search?keyword=` | MANAGER | + +### 3.4 업무 보고 및 결재 워크플로우 + +| 기능 | 설명 | API | 권한 | +|------|------|-----|------| +| 보고서 작성 | 일일/주간/월간 단위 실적, 계획, 애로사항 기재 | `POST /api/v1/reports` | EMPLOYEE | +| 보고서 상태 관리 | DRAFT(임시저장) → SUBMITTED(제출) → APPROVED/REJECTED | `PUT /api/v1/reports/{id}/status` | EMPLOYEE | +| 결재 처리 | 관리자 승인/반려 + 반려 시 피드백 필수 | `PUT /api/v1/reports/{id}/review` | MANAGER | +| SR 연동 자동화 | 보고서 작성 시 해당 주차 해결 완료 SR 내역 자동 바인딩 | Internal (Backend) | SYSTEM | + +### 3.5 내부 교육 관리 + +| 기능 | 설명 | API | 권한 | +|------|------|-----|------| +| 교육 진도 조회 | 사내 커리큘럼별 진도율(%), 수강 상태(NOT_STARTED/ONGOING/COMPLETED) 조회 | `GET /api/v1/training` | EMPLOYEE | +| 진도율 업데이트 | 교육 수강 완료 시 진도율 자동/수동 갱신 | `PUT /api/v1/training/{id}` | SYSTEM | +| 실시간 모니터링 | 관리자 대시보드에서 직원별 교육 이수 현황 파악 | `GET /api/v1/training/summary` | MANAGER | + +## 4. 비기능 요구사항 + +| 항목 | 요구사항 | +|------|---------| +| 보안 | BCrypt 비밀번호 암호화, JWT 기반 인증, RBAC 권한 제어 | +| 데이터 무결성 | Soft Delete 전역 적용, 낙관적 락(Optimistic Lock)으로 동시성 제어 | +| 응답 시간 | 페이지 응답 3초 이내, 검색 자동완성 500ms 이내 | +| 알림 | 이벤트 기반 비동기 알림 (Slack/Email), 트랜잭션 분리 | +| SLA 관리 | Spring Batch 청크 처리로 시간당 대량 티켓 데드라인 스캔 | +| 감사(Audit) | 모든 테이블에 created_at, updated_at 타임스탬프强制 적용 | diff --git a/docs/02-architecture.md b/docs/02-architecture.md new file mode 100644 index 0000000..5c4436e --- /dev/null +++ b/docs/02-architecture.md @@ -0,0 +1,150 @@ +# 시스템 아키텍처 정의서 (System Architecture) + +## 1. 기술 스택 + +| 계층 | 기술 | 버전 | +|------|------|------| +| Backend | Spring Boot | 3.x | +| | Java | 17 | +| | Spring Data JPA | - | +| | Spring Security | - | +| | Spring Batch | - | +| Frontend | Next.js | 14+ (App Router) | +| | TypeScript | - | +| | Tailwind CSS | - | +| | Pinia (State Management) | - | +| Database | PostgreSQL | 15 | +| Infra | Nginx Proxy Manager | - | +| CI/CD | GitHub Actions | Release 트리거 | +| Notification | Slack Webhook / Email | - | + +## 2. 전역 시스템 레이어 구조 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Frontend (Next.js 14+) │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │ +│ │ Pinia │ │ Axios │ │ Tailwind │ │ Dynamic LNB │ │ +│ │ Storage │ │ Intercep.│ │ CSS │ │ (RBAC) │ │ +│ └──────────┘ └──────────┘ └──────────┘ └───────────────┘ │ +└──────────────────────┬──────────────────────────────────────┘ + │ HTTPS / REST API + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Cloudflare Tunnel (SSL / Zero-Trust Proxy) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Backend (Spring Boot 3.x / Java 17) │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Security Layer │ │ +│ │ ┌─────────────────┐ ┌──────────────────────────┐ │ │ +│ │ │ JWT Token │ │ Security Filter Chain │ │ │ +│ │ │ Provider │ │ (RBAC Authorization) │ │ │ +│ │ └─────────────────┘ └──────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ API Layer (REST Controllers) │ │ +│ │ EmployeeController / TicketController / ... │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Service Layer (Business Logic + Transaction) │ │ +│ │ ├─ @Transactional 선언 │ │ +│ │ └─ ApplicationEventPublisher (EDA 발행) │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Persistence Layer (Spring Data JPA) │ │ +│ │ ├─ Entity / Repository / Specification │ │ +│ │ ├─ @SQLDelete + @Where (Soft Delete 전역 필터) │ │ +│ │ └─ @SequenceGenerator(allocationSize=50) │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ Infrastructure Layer │ │ +│ │ ├─ @TransactionalEventListener(AFTER_COMMIT) │ │ +│ │ ├─ @Async Notification Listener │ │ +│ │ └─ Spring Batch SLA Scheduler (Chunk Size=100) │ │ +│ └──────────────────────────────────────────────────────┘ │ +└──────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ PostgreSQL 15 Database │ +│ ├─ Sequence (INCREMENT BY 50) │ +│ ├─ Common Code Table (동적 드롭다운) │ +│ └─ Batch Metadata Schema │ +└─────────────────────────────────────────────────────────────┘ +``` + +## 3. ApiResponse 공통 규격 + +모든 API 응답은 아래 포맷을 강제합니다. + +**성공:** +```json +{ + "success": true, + "message": "요청이 성공적으로 처리되었습니다.", + "data": { ... } +} +``` + +**실패 (비즈니스 예외):** +```json +{ + "success": false, + "message": "이미 결재가 완료된 보고서는 수정할 수 없습니다.", + "errorCode": "RPT_ERR_001", + "data": null +} +``` + +## 4. 글로벌 예외 처리 (GlobalExceptionHandler) + +- 모든 도메인 예외는 `BusinessException` (RuntimeException 상속) 통일 +- `@RestControllerAdvice`가 감지 → `ErrorCode` Enum 매핑 → `ApiResponse.error()` 변환 + +### ErrorCode 매트릭스 + +| Enum | HTTP Status | ErrorCode | 메시지 | +|------|------------|-----------|--------| +| `RESIGNED_EMPLOYEE` | 400 | EMP_ERR_001 | 퇴사 처리된 직원 정보는 수정할 수 없습니다 | +| `LOCKED_REPORT` | 400 | RPT_ERR_001 | 결재 완료 보고서는 수정/삭제할 수 없습니다 | +| `FUTURE_REPORT_DATE` | 400 | RPT_ERR_002 | 미래 날짜의 업무 보고는 작성할 수 없습니다 | +| `CLOSED_TICKET_MOD` | 400 | TCK_ERR_001 | 종료된 티켓은 댓글/수정이 불가능합니다 | +| `ASSIGNEE_REQUIRED` | 400 | TCK_ERR_002 | 담당자 미배정 티켓은 IN_PROGRESS 전환 불가 | +| `EQUIP_NOT_AVAILABLE` | 400 | EQP_ERR_001 | 지급 가능한 재고 상태가 아닙니다 | +| `INVALID_ASSIGNEE` | 400 | EQP_ERR_002 | 재직 중인 직원에게만 장비 지급 가능 | +| `SYSTEM_CONCURRENCY` | 409 | SYS_ERR_001 | 동시성 충돌 - 다시 시도해 주세요 | + +## 5. 이벤트 기반 비동기 알림 (EDA) + +```mermaid +sequenceDiagram + Controller->>Service: 요청 처리 + Service->>DB: 트랜잭션 커밋 + Service->>EventPublisher: 이벤트 발행 + EventPublisher->>Listener: @TransactionalEventListener(AFTER_COMMIT) + Listener->>Slack/Email: @Async 비동기 발송 + Note over Listener: 메인 트랜잭션과 완전 격리 +``` + +## 6. SLA 만료 배치 아키텍처 (Spring Batch) + +- **트리거:** Spring Scheduler (매시간) +- **Chunk Size:** 100건 단위 청크 분할 +- **필터 조건:** `SLA_WARN_FLAG = 'N'` AND `status NOT IN (RESOLVED, CLOSED)` AND `due_date < NOW()` +- **처리:** 대상 티켓 탐색 → 알림 이벤트 발행 → `SLA_WARN_FLAG = 'Y'` 갱신 +- **PostgreSQL 메타데이터:** 배치 전용 메타 테이블 스키마 별도 구축 + +## 7. Soft Delete 전역 아키텍처 + +```java +// BaseEntity 패턴 +@SQLDelete(sql = "UPDATE tb_xxx SET is_deleted = 'Y' WHERE id = ?") +@Where(clause = "is_deleted = 'N'") +``` + +- 모든 엔티티가 `BaseEntity` 상속 (created_at, updated_at, is_deleted) +- 조회 시 `@Where` 조건절 자동 적용 → 조건절 누수 방지 +- 물리 삭제 `DELETE` → `UPDATE is_deleted='Y'` 자변환 diff --git a/docs/03-database-schema.md b/docs/03-database-schema.md new file mode 100644 index 0000000..29abb27 --- /dev/null +++ b/docs/03-database-schema.md @@ -0,0 +1,220 @@ +# 데이터베이스 설계서 (Database Schema) + +## 1. 공통 원칙 + +- **Soft Delete:** 모든 테이블 `is_deleted CHAR(1) DEFAULT 'N'` — 물리 삭제 금지 +- **Audit:** 모든 테이블 `created_at TIMESTAMP`, `updated_at TIMESTAMP` — 감사 로그 +- **ID 생성:** `Sequence` + `allocationSize=50` — Oracle 11g 최적화 +- **공통 코드:** 드롭다운/상태값은 Common Code 테이블에서 동적 관리 + +## 2. 공통 코드 인프라 + +### TB_COMMON_CODE_GROUP (대분류) + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| GROUP_CODE | VARCHAR2(50) | PK | 'PROJECT_TYPE', 'ISSUE_STATUS' | +| GROUP_NAME | VARCHAR2(100) | NOT NULL | '프로젝트 유형', '이슈 상태' | +| DESCRIPTION | CLOB | NULLABLE | | + +### TB_COMMON_CODE (소분류) + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| CODE_ID | NUMBER | PK (Seq) | 시퀀스 자동 증가 | +| GROUP_CODE | VARCHAR2(50) | FK | TB_COMMON_CODE_GROUP 참조 | +| CODE_VALUE | VARCHAR2(50) | UNIQUE, NOT NULL | 내부 로직 코드 ('BUG', 'SI') | +| CODE_NAME | VARCHAR2(100) | NOT NULL | 화면 표시용 ('결함 및 장애') | +| OPTION_VAL_1 | VARCHAR2(100) | NULLABLE | UI 뱃지 색상 | +| OPTION_VAL_2 | VARCHAR2(100) | NULLABLE | SLA 가중치 | +| OPTION_VAL_3 | VARCHAR2(100) | NULLABLE | 연동 키 | +| SORT_ORDER | NUMBER | DEFAULT 1 | 노출 순서 | +| IS_ACTIVE | CHAR(1) | DEFAULT 'Y' | 'Y'/'N' | +| DESCRIPTION | CLOB | NULLABLE | | + +**공통 코드 예시 데이터:** + +| GROUP_CODE | CODE_VALUE | CODE_NAME | OPTION_VAL_1 (색상) | OPTION_VAL_2 (SLA) | +|------------|-----------|-----------|---------------------|--------------------| +| ISSUE_STATUS | OPEN | 접수 | blue | 48h | +| ISSUE_STATUS | IN_PROGRESS | 처리중 | orange | - | +| ISSUE_STATUS | RESOLVED | 해결 | green | - | +| ISSUE_STATUS | CLOSED | 종료 | gray | - | +| PRIORITY | CRITICAL | 긴급 | red | 4h | +| PRIORITY | HIGH | 높음 | orange | 24h | +| PRIORITY | NORMAL | 보통 | blue | 72h | +| PRIORITY | LOW | 낮음 | gray | 120h | + +## 3. 도메인 테이블 명세 + +### TB_EMPLOYEE (직원 마스터) + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| EMP_ID | NUMBER | PK (Seq) | 시스템 고유 사번 | +| LOGIN_ID | VARCHAR2(50) | UNIQUE, NOT NULL | 인증용 ID | +| PASSWORD | VARCHAR2(255) | NOT NULL | BCrypt 암호화 | +| NAME | VARCHAR2(50) | NOT NULL | 실명 | +| EMAIL | VARCHAR2(100) | UNIQUE, NOT NULL | 회사 이메일 | +| ROLE_CODE | VARCHAR2(20) | NOT NULL | ADMIN / MANAGER / EMPLOYEE | +| EMP_STATUS | VARCHAR2(20) | NOT NULL | ACTIVE / ON_LEAVE / RESIGNED | +| DISPATCH_STATUS | VARCHAR2(20) | NOT NULL | WAITING / DISPATCHED | +| JOINED_DATE | DATE | NOT NULL | 입사일 | +| LEFT_DATE | DATE | NULLABLE | 퇴사일 | +| IS_DELETED | CHAR(1) | DEFAULT 'N' | Soft Delete | + +### TB_PROJECT (프로젝트 마스터) + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| PROJECT_ID | NUMBER | PK (Seq) | | +| PROJECT_TYPE | VARCHAR2(20) | NOT NULL | SI / SM / MIGRATION | +| PROJECT_NAME | VARCHAR2(150) | NOT NULL | 공식 프로젝트명 | +| CONTRACT_COMPANY | VARCHAR2(100) | NOT NULL | 원청 계약사 | +| END_USER_COMPANY | VARCHAR2(100) | NOT NULL | 최종 고객사 | +| CONTRACT_TYPE | VARCHAR2(20) | NOT NULL | TURN_KEY / MM | +| STATUS | VARCHAR2(20) | NOT NULL | PREPARING / ONGOING / COMPLETED / HOLD | +| START_DATE | DATE | NOT NULL | | +| END_DATE | DATE | NOT NULL | | +| WORK_LOCATION_TYPE | VARCHAR2(20) | NOT NULL | ON_SITE / REMOTE / HYBRID | +| WORK_ADDRESS | VARCHAR2(255) | NOT NULL | 파견지 물리 주소 | +| TECH_ENVIRONMENT | CLOB | NULLABLE | 기술 스택 묶음 | +| MAIN_TASK_DESC | CLOB | NOT NULL | 구축 목적 요약 | +| CLIENT_PM_NAME | VARCHAR2(50) | NULLABLE | | +| CLIENT_PM_CONTACT | VARCHAR2(100) | NULLABLE | | +| INTERNAL_MANAGER_ID | NUMBER | FK → TB_EMPLOYEE | 내부 영업 담당자 | + +### TB_DISPATCH_HISTORY (파견 투입 이력 = 포트폴리오) + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| DISPATCH_ID | NUMBER | PK (Seq) | 포트폴리오 라인 단건 | +| EMP_ID | NUMBER | FK → TB_EMPLOYEE | | +| PROJECT_ID | NUMBER | FK → TB_PROJECT | | +| START_DATE | DATE | NOT NULL | 실제 투입일 | +| END_DATE | DATE | NULLABLE | NULL=현재 투입중 | +| ROLE_TITLE | VARCHAR2(50) | NOT NULL | PM / PL / BACKEND / FRONTEND / DBA | +| CONTRIBUTION_RATE | NUMBER | DEFAULT 100 | 투입률(%) | +| INDIVIDUAL_TECH | VARCHAR2(255) | NOT NULL | 개인이 직접 사용한 기술 | +| PERFORMED_TASKS | CLOB | NOT NULL | 상세 수행 업무 | +| ACHIEVEMENTS | CLOB | NULLABLE | 트러블슈팅 사례 | +| CLIENT_FEEDBACK | CLOB | NULLABLE | 고객사 PM 평가 (본사 은닉) | +| INTERNAL_RATING | NUMBER | NULLABLE | 1~5 | + +### TB_ISSUE_TICKET (SR 티켓 마스터) + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| TICKET_ID | NUMBER | PK (Seq) | 고유 식별 번호 | +| PROJECT_ID | NUMBER | FK → TB_PROJECT | 발생 현장 | +| REQUESTER_ID | NUMBER | FK → TB_EMPLOYEE | SOS 발행 직원 | +| ASSIGNEE_ID | NUMBER | FK → TB_EMPLOYEE | 본사 해결사 | +| CATEGORY | VARCHAR2(30) | NOT NULL | BUG / FEATURE / SUPPORT / QA | +| PRIORITY | VARCHAR2(20) | NOT NULL | LOW / NORMAL / HIGH / CRITICAL | +| TITLE | VARCHAR2(255) | NOT NULL | | +| DESCRIPTION | CLOB | NOT NULL | 에러 재현 경로 | +| STATUS | VARCHAR2(20) | NOT NULL | OPEN / ASSIGNED / IN_PROGRESS / RESOLVED / CLOSED | +| DUE_DATE | DATE | NULLABLE | SLA 데드라인 | +| RESOLVED_AT | TIMESTAMP | NULLABLE | 해결 일시 | +| RESOLUTION_NOTE | CLOB | NULLABLE | 조치 내역 | +| SLA_WARN_FLAG | CHAR(1) | DEFAULT 'N' | 배치 알림 중복 방지 | + +### TB_TICKET_COMMENT (티켓 타임라인 댓글) + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| COMMENT_ID | NUMBER | PK (Seq) | | +| TICKET_ID | NUMBER | FK → TB_ISSUE_TICKET | | +| USER_ID | NUMBER | FK → TB_EMPLOYEE | 작성자 | +| COMMENT_TEXT | CLOB | NOT NULL | 분석 의견 | +| ATTACHED_FILE_URL | VARCHAR2(255) | NULLABLE | 첨부파일 경로 | + +### TB_WORK_REPORT (업무 보고서) + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| REPORT_ID | NUMBER | PK (Seq) | | +| DISPATCH_ID | NUMBER | FK → TB_DISPATCH_HISTORY | | +| USER_ID | NUMBER | FK → TB_EMPLOYEE | 작성자 | +| REPORT_TYPE | VARCHAR2(20) | NOT NULL | DAILY / WEEKLY / MONTHLY / RETURN | +| TARGET_DATE | DATE | NOT NULL | 기준일 | +| COMPLETED_TASKS | CLOB | NOT NULL | SR 연동 자동 취합 영역 | +| PLANNED_TASKS | CLOB | NOT NULL | 차기 계획 | +| ISSUES_AND_RISKS | CLOB | NULLABLE | 특이사항 | +| SUPPORT_REQUESTED | CLOB | NULLABLE | 본사 지원 요청 | +| STATUS | VARCHAR2(20) | NOT NULL | DRAFT / SUBMITTED / APPROVED / REJECTED | +| REVIEWER_ID | NUMBER | FK → TB_EMPLOYEE | 결재자 | +| REVIEW_COMMENT | CLOB | NULLABLE | 반려 사유 | + +### TB_EQUIPMENT (IT 자산 마스터) + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| EQUIPMENT_ID | NUMBER | PK (Seq) | | +| EQUIPMENT_TYPE | VARCHAR2(50) | NOT NULL | LAPTOP / MONITOR / TEST_DEVICE / ACCESSORY | +| MANUFACTURER | VARCHAR2(50) | NOT NULL | Apple / LG / Samsung | +| MODEL_NAME | VARCHAR2(100) | NOT NULL | MacBook Pro 16 M3 | +| SERIAL_NUMBER | VARCHAR2(100) | UNIQUE, NOT NULL | 기기 고유 바코드 | +| ASSET_CODE | VARCHAR2(50) | UNIQUE, NOT NULL | HQ-LP-2026-0001 | +| SPECIFICATIONS | CLOB (JSON) | NOT NULL | JSON 다품종 사양 | +| STATUS | VARCHAR2(20) | NOT NULL | AVAILABLE / IN_USE / REPAIRING / RETIRED / LOST | +| PURCHASE_DATE | DATE | NOT NULL | | +| WARRANTY_EXP_DATE | DATE | NOT NULL | | +| VERSION | NUMBER | DEFAULT 0 | **낙관적 락** | + +### TB_EQUIPMENT_ASSIGNMENT (장비 지급/반납 이력) + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| ASSIGNMENT_ID | NUMBER | PK (Seq) | | +| EQUIPMENT_ID | NUMBER | FK → TB_EQUIPMENT | | +| USER_ID | NUMBER | FK → TB_EMPLOYEE | 지급 대상 | +| ASSIGNED_DATE | DATE | NOT NULL | 출고일 | +| RETURNED_DATE | DATE | NULLABLE | NULL=사용중 | +| COND_ON_ASSIGN | VARCHAR2(255) | NOT NULL | 지급시 상태 | +| COND_ON_RETURN | VARCHAR2(255) | NULLABLE | 반납시 상태 | + +### TB_EQUIPMENT_MAINTENANCE (장비 유지보수 이력) + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| MAINTENANCE_ID | NUMBER | PK (Seq) | | +| EQUIPMENT_ID | NUMBER | FK → TB_EQUIPMENT | | +| REPORTED_BY | NUMBER | FK → TB_EMPLOYEE | 고장 접수자 | +| MAINTENANCE_TYPE | VARCHAR2(30) | NOT NULL | REPAIR / UPGRADE / INSPECTION / DISPOSAL | +| ISSUE_DESCRIPTION | CLOB | NOT NULL | 고장 증상 | +| ACTION_TAKEN | CLOB | NULLABLE | 조치 내역 | +| COST | NUMBER | DEFAULT 0 | 발생 비용 | +| REPORTED_DATE | DATE | NOT NULL | | +| RESOLVED_DATE | DATE | NULLABLE | | + +### TB_TRAINING_PROGRESS (교육 진도) + +| 컬럼 | 타입 | 제약 | 설명 | +|------|------|------|------| +| PROGRESS_ID | NUMBER | PK (Seq) | | +| USER_ID | NUMBER | FK → TB_EMPLOYEE | | +| COURSE_NAME | VARCHAR2(100) | NOT NULL | 과정명 | +| PROGRESS_RATE | NUMBER | DEFAULT 0 | 0~100 | +| STATUS | VARCHAR2(20) | NOT NULL | NOT_STARTED / ONGOING / COMPLETED | + +## 4. ER 관계 요약 + +``` +TB_EMPLOYEE ──1:N── TB_DISPATCH_HISTORY ──N:1── TB_PROJECT + └──1:N── TB_WORK_REPORT + │ + └──1:N── TB_ISSUE_TICKET ──1:N── TB_TICKET_COMMENT + │ + └──1:N── TB_EQUIPMENT_ASSIGNMENT ──N:1── TB_EQUIPMENT + │ └──1:N── TB_EQUIPMENT_MAINTENANCE + └──1:N── TB_TRAINING_PROGRESS +``` + +## 5. DDL 생성 참고 사항 + +- 모든 `SEQUENCE`: `INCREMENT BY 50` (Hibernate `allocationSize=50`과 정합) +- 모든 시퀀스명 컨벤션: `SEQ_TB_XXX` +- 모든 PK 컬럼: `NUMBER` 타입, 시퀀스 자동 할당 +- `SPECIFICATIONS` (TB_EQUIPMENT): `CLOB`에 JSON 문자열 저장, 애플리케이션 레벨 직렬화/역직렬화 +- 물리 스키마 DDL 파일: `src/main/resources/ddl/oracle_11g_schema.sql` 별도 관리 diff --git a/docs/04-api-spec.md b/docs/04-api-spec.md new file mode 100644 index 0000000..b0f1c7e --- /dev/null +++ b/docs/04-api-spec.md @@ -0,0 +1,412 @@ +# API 명세서 (REST API Specification) + +## 1. 공통 규격 + +### Base URL +``` +http://{host}:{port}/api/v1 +``` + +### 공통 응답 포맷 +```json +// 성공 +{ "success": true, "message": "...", "data": { ... } } + +// 실패 (비즈니스 예외) +{ "success": false, "message": "...", "errorCode": "XXX_ERR_000", "data": null } +``` + +### 인증 헤더 +``` +Authorization: Bearer {jwt_access_token} +``` + +### 페이지네이션 (목록 조회) +``` +Query Params: ?page=0&size=20&sort=createdAt,desc +Response: { + "content": [...], + "totalElements": 100, + "totalPages": 5, + "number": 0, + "size": 20 +} +``` + +--- + +## 2. 직원 (Employee) + +### 직원 목록 조회 +``` +GET /employees +Query: ?roleCode=MANAGER&empStatus=ACTIVE&dispatchStatus=DISPATCHED&page=0&size=20 +Response: Page +``` + +### 직원 단건 조회 +``` +GET /employees/{empId} +Response: EmployeeDto +``` + +### 직원 검색 (자동완성) +``` +GET /employees/search?keyword=김코딩 +Response: [{ empId: 1, name: "김코딩", ... }] +``` + +### 내 정보 조회 +``` +GET /employees/me +Response: EmployeeDto (현재 로그인 사용자) +``` + +### 직원 생성 +``` +POST /employees +Body: { + "loginId": "string", + "password": "string", + "name": "string", + "email": "string", + "roleCode": "EMPLOYEE", + "joinedDate": "2026-01-01" +} +``` + +### 직원 수정 +``` +PUT /employees/{empId} +Body: { "email": "new@company.com", ... } +``` + +### 직원 삭제 (Soft Delete) +``` +DELETE /employees/{empId} +``` + +--- + +## 3. 프로젝트 (Project) + +### 프로젝트 목록 조회 +``` +GET /projects +Query: ?type=SI&status=ONGOING&page=0&size=20 +``` + +### 프로젝트 단건 조회 +``` +GET /projects/{projectId} +``` + +### 프로젝트 생성 +``` +POST /projects +Body: { + "projectType": "SI", + "projectName": "차세대 시스템 구축", + "contractCompany": "원청사", + "endUserCompany": "고객사", + "contractType": "TURN_KEY", + "startDate": "2026-01-01", + "endDate": "2026-12-31", + "workLocationType": "ON_SITE", + "workAddress": "서울시 강남구", + "mainTaskDesc": "...", + "internalManagerId": 1 +} +``` + +--- + +## 4. 파견 이력 / 포트폴리오 (Dispatch) + +### 파견 이력 목록 (직원별) +``` +GET /employees/{empId}/dispatch-histories +``` + +### 파견 이력 등록 +``` +POST /dispatch-histories +Body: { + "empId": 1, + "projectId": 1, + "startDate": "2026-01-01", + "roleTitle": "BACKEND", + "individualTech": "Java, Spring, Oracle", + "performedTasks": "..." +} +``` + +### 포트폴리오 단건 조회 +``` +GET /dispatch-histories/{dispatchId} +``` + +--- + +## 5. SR 티켓 (Ticket) + +### 티켓 목록 조회 (칸반) +``` +GET /tickets +Query: ?category=BUG&priority=HIGH&status=OPEN&searchKeyword=오라클&page=0&size=50 +Response: Page<{ + ticketId, title, priority, status, category, + requesterName, assigneeName, + dueDate, createdAt, + isImminent: boolean // dueDate - now < 24h +}> +``` + +### 티켓 단건 상세 조회 +``` +GET /tickets/{ticketId} +Response: { + ...ticketInfo, + comments: [{ + commentId, userId, userName, + commentText, attachedFileUrl, createdAt + }] +} +``` + +### 티켓 생성 +``` +POST /tickets +Body: { + "projectId": 1, + "category": "BUG", + "priority": "HIGH", + "title": "오라클 쿼리 튜닝 필요", + "description": "실행 계획 상 Full Scan 발생..." +} +``` + +### 티켓 상태 전환 +``` +PUT /tickets/{ticketId}/status?status=IN_PROGRESS +Body (optional): { + "assigneeId": 5 // ASSIGNED 또는 IN_PROGRESS 전환 시 필요 +} + +Status Flow: OPEN → ASSIGNED → IN_PROGRESS → RESOLVED → CLOSED +``` + +### 티켓 해결 처리 +``` +PUT /tickets/{ticketId}/resolve +Body: { + "resolutionNote": "인덱스 재생성 및 통계 갱신 완료" +} +``` + +### 티켓 삭제 (Soft Delete) +``` +DELETE /tickets/{ticketId} +``` + +--- + +## 6. 티켓 댓글 (Ticket Comment) + +### 댓글 목록 조회 +``` +GET /tickets/{ticketId}/comments +``` + +### 댓글 등록 +``` +POST /tickets/{ticketId}/comments +Body: { + "commentText": "분석 스크립트 첨부합니다.", + "attachedFileUrl": "https://..." +} +``` + +### 댓글 삭제 +``` +DELETE /tickets/{ticketId}/comments/{commentId} +``` + +--- + +## 7. 업무 보고서 (Report) + +### 보고서 목록 조회 +``` +GET /reports +Query: ?reportType=WEEKLY&status=SUBMITTED&userId=1&page=0&size=20 +``` + +### 보고서 단건 조회 +``` +GET /reports/{reportId} +``` + +### 보고서 생성 +``` +POST /reports +Body: { + "dispatchId": 1, + "reportType": "WEEKLY", + "targetDate": "2026-05-21", + "completedTasks": "본사 시스템 SR 3건 해결 완료", + "plannedTasks": "차주 성능 테스트 진행", + "issuesAndRisks": "납기 일정 협의 필요", + "status": "DRAFT" +} +``` + +### 보고서 상태 변경 (제출) +``` +PUT /reports/{reportId}/status?status=SUBMITTED +``` + +### 보고서 결재 처리 (관리자 전용) +``` +PUT /reports/{reportId}/review +Body: { + "status": "APPROVED", + "reviewComment": "수고하셨습니다." +} +// 또는 +Body: { + "status": "REJECTED", + "reviewComment": "완료 실적 내역 SR 상세 분석 내용 보강 요망" +} +``` + +### 보고서 삭제 (DRAFT 상태만) +``` +DELETE /reports/{reportId} +``` + +--- + +## 8. IT 자산 (Equipment) + +### 장비 목록 조회 +``` +GET /equipment +Query: ?type=LAPTOP&status=AVAILABLE&manufacturer=Apple&page=0&size=20 +``` + +### 장비 단건 조회 +``` +GET /equipment/{equipId} +``` + +### 장비 등록 +``` +POST /equipment +Body: { + "equipmentType": "LAPTOP", + "manufacturer": "Apple", + "modelName": "MacBook Pro 16 M3", + "serialNumber": "F2G8H3J9K1", + "assetCode": "HQ-LP-2026-0001", + "specifications": { "cpu": "M3 Pro", "ram": "36GB", "ssd": "512GB" }, + "purchaseDate": "2026-01-15", + "warrantyExpDate": "2028-01-15" +} +``` + +### 장비 정보 수정 +``` +PUT /equipment/{equipId} +``` + +### 장비 삭제 (Soft Delete) +``` +DELETE /equipment/{equipId} +``` + +### 장비 지급 +``` +POST /equipment/{equipId}/assign +Body: { + "assigneeId": 2, + "conditionNote": "미개봉 신품" +} +- 낙관적 락(version) 충돌 시 409 Conflict +``` + +### 장비 반납 +``` +PUT /equipment/{equipId}/return +Body: { + "conditionNote": "액정 파손 - 수리 필요" +} +``` + +### 장비 지급 이력 조회 +``` +GET /equipment/{equipId}/assignments +``` + +### 장비 유지보수 등록 +``` +POST /equipment/{equipId}/maintenance +Body: { + "maintenanceType": "REPAIR", + "issueDescription": "키보드 침수 작동 불가", + "actionTaken": "키보드 상판 교체 완료", + "cost": 150000 +} +``` + +### 장비 유지보수 이력 조회 +``` +GET /equipment/{equipId}/maintenances +``` + +--- + +## 9. 교육 (Training) + +### 교육 진도 조회 (내 것) +``` +GET /training/me +Response: [{ courseName, progressRate, status, updatedAt }] +``` + +### 직원별 교육 진도 조회 (관리자) +``` +GET /training?userId=1 +``` + +### 교육 진도 업데이트 +``` +PUT /training/{progressId} +Body: { "progressRate": 80, "status": "ONGOING" } +``` + +### 교육 요약 (관리자 대시보드) +``` +GET /training/summary +Response: [{ courseName, totalEnrolled, completedCount, avgProgress }] +``` + +--- + +## 10. 알림 (Notification) + +### 읽지 않은 알림 카운트 +``` +GET /notifications/unread +Response: { "count": 3 } +``` + +### 내 알림 목록 +``` +GET /notifications?page=0&size=20 +``` + +### 알림 읽음 처리 +``` +PUT /notifications/{notificationId}/read +``` diff --git a/docs/05-ui-screens.md b/docs/05-ui-screens.md new file mode 100644 index 0000000..2d291e0 --- /dev/null +++ b/docs/05-ui-screens.md @@ -0,0 +1,238 @@ +# 화면 설계 명세서 (UI & Screen Specification) + +**프론트엔드:** Next.js 14+ (App Router), TypeScript, Tailwind CSS, Pinia + +## 1. 전역 레이아웃 (Global Layout) + +``` +┌─────────────────────────────────────────────────┐ +│ GNB (Global Navigation Bar) │ +│ ┌──────┬─────────────────────────────────┬──┐ │ +│ │ Logo │ (검색/제목) │🔔│👤│ │ +│ └──────┴─────────────────────────────────┴──┘ │ +├──────┬──────────────────────────────────────────┤ +│ LNB │ Content Area │ +│ │ │ +│ Menu │ (Page Component Rendering) │ +│ 1 │ │ +│ │ │ +│ Menu │ │ +│ 2 │ │ +│ │ │ +│ Menu │ │ +│ 3 │ │ +│ │ │ +└──────┴──────────────────────────────────────────┘ +``` + +### GNB (Global Navigation Bar) +- **알림 뱃지(🔔):** `GET /api/v1/notifications/unread` 폴링 — 0개면 숨김 +- **프로필:** `GET /api/v1/employees/me` — 사용자명 및 권한 표시 + +### LNB (Left Navigation Bar) — RBAC 기반 동적 메뉴 +| 메뉴 | EMPLOYEE | MANAGER | ADMIN | +|------|----------|---------|-------| +| 내 포트폴리오 | ✅ | ❌ | ❌ | +| SR 티켓 관리 | ✅ | ✅ | ✅ | +| 업무 보고서 | ✅ | ✅ | ✅ | +| 교육 현황 | ✅ | ✅ | ✅ | +| 프로젝트 관리 | ❌ | ✅ | ✅ | +| 장비 자산 관리 | ❌ | ✅ | ✅ | +| 직원 관리 | ❌ | ❌ | ✅ | +| 공통 코드 관리 | ❌ | ❌ | ✅ | + +--- + +## 2. SR 티켓 칸반 보드 (`TicketBoardView.vue`) + +### UI 구조 +``` +┌─────────────────────────────────────────────────────────┐ +│ [필터 영역] │ +│ 카테고리: [BUG ▼] 우선순위: [HIGH ▼] 검색: [________] │ +├─────────┬──────────┬──────────┬──────────┬──────────────┤ +│ OPEN │ ASSIGNED │ IN_PROG │ RESOLVED │ CLOSED │ +│ (3) │ (2) │ (5) │ (8) │ (12) │ +├─────────┼──────────┼──────────┼──────────┼──────────────┤ +│ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ ┌─────┐ │ │ +│ │Bug │ │ │High │ │ │Supp │ │ │Bug │ │ │ +│ │DB │ │ │API │ │ │배포 │ │ │튜닝 │ │ │ +│ │튜닝 │ │ │에러 │ │ │지원 │ │ │완료 │ │ │ +│ │🔴⚠️ │ │ │ │ │ │ │ │ │ │ │ │ +│ └─────┘ │ └─────┘ │ └─────┘ │ └─────┘ │ │ +│ ┌─────┐ │ │ ┌─────┐ │ │ │ +│ │Feat │ │ │ │Bug │ │ │ │ +│ │화면 │ │ │ │SQL │ │ │ │ +│ │개발 │ │ │ │튜닝 │ │ │ │ +│ └─────┘ │ │ └─────┘ │ │ │ +└─────────┴──────────┴──────────┴──────────┴──────────────┘ +``` + +### 핵심 로직 +- **칸반 컬럼:** 상태별 4단 (상단에 컬럼별 카운트) +- **기한 임박:** `dueDate - now < 24h` → 카드 좌상단 🔴 경고 레이블 + 애니메이션 +- **드래그 앤 드롭:** 상태 전환 시 `PUT /api/v1/tickets/{id}/status?status=NEW_STATUS` +- **티켓 카드 클릭:** 라우터 push → `/tickets/{ticketId}` (상세 페이지) + +--- + +## 3. SR 티켓 상세/타임라인 (`TicketDetailView.vue`) + +### UI 구조 +``` +┌─────────────────────────────────────────────────────────┐ +│ [← 뒤로] [삭제] [상태 변경: IN_PROGRESS ▼] │ +├─────────────────────────────────────────────────────────┤ +│ 📋 티켓 정보 │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ 제목: 오라클 쿼리 튜닝 필요 │ │ +│ │ 상태: OPEN | 우선순위: HIGH | 카테고리: BUG │ │ +│ │ 요청자: 김개발 | 담당자: 미배정 | SLA: 2026-05-22 │ │ +│ │ 설명: 실행 계획 상 Full Scan 발생 중... │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ 📎 첨부파일 │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ [execution_plan.png] [error_log.txt] │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ 💬 타임라인 │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ 2026-05-21 14:32 | 김개발 │ │ +│ │ 오라클 실행 계획 첨부합니다. │ │ +│ │ 📎 [execution_plan.png] │ │ +│ ├──────────────────────────────────────────────────┤ │ +│ │ 2026-05-21 15:10 | 이튜너 (본사) │ │ +│ │ 인덱스 힌트 사용해보세요: /*+ INDEX() */ │ │ +│ ├──────────────────────────────────────────────────┤ │ +│ │ 2026-05-21 16:00 | 김개발 │ │ +│ │ 힌트 적용 후 3초→0.5초로 개선되었습니다! ✅ │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ 댓글 입력: [________________________________] 📎 │ │ +│ │ [등록] │ │ +│ └──────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 핵심 로직 +- **댓글 등록:** `POST /api/v1/tickets/{id}/comments` → 타임라인 최하단 prepend +- **파일 업로드:** `` → S3/서버 업로드 → URL 반환 → 댓글 첨부 +- **상태 전환 버튼:** 현재 상태에 따라 다음 가능한 액션 버튼 노출 (OPEN→ASSIGNED→IN_PROGRESS→RESOLVED→CLOSED) +- **종료된 티켓 봉인:** `status == CLOSED` 시 입력폼 비활성화 + 뱃지 표시 + +--- + +## 4. 장비 인벤토리 (`EquipmentInventoryView.vue`) + +### UI 구조 +``` +┌─────────────────────────────────────────────────────────┐ +│ 📊 장비 현황 요약 │ +│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ +│ │전체 │ │가용 │ │사용중 │ │수리중 │ │폐기 │ │ +│ │45 │ │12 │ │28 │ │3 │ │2 │ │ +│ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │ +│ │ +│ [필터: LAPTOP ▼] [제조사: Apple ▼] [검색: ____________] │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ 자산코드 | 모델명 | 시리얼 | 상태 | 액션 │ │ +│ ├──────────────────────────────────────────────────┤ │ +│ │HQ-LP-001|MacBook |F2G8... |IN_USE |[반납] │ │ +│ │HQ-LP-002|MacBook |H3J9... |AVAILABLE|[지급] │ │ +│ │HQ-MO-001|LG 27UN880|L7K2... |IN_USE |[반납] │ │ +│ └──────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + +--- 지급 모달 --- +┌──────────────────────────────────────────┐ +│ 장비 지급: HQ-LP-002 MacBook Pro M3 │ +│ │ +│ 대상자 검색: [김_______] │ +│ ┌──────────────────────────────────┐ │ +│ │ 김코딩 (BACKEND, 파견중) │ │ +│ │ 김개발 (DBA, 파견중) │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ 지급 상태 코멘트: [미개봉 신품________] │ +│ [취소] [지급 확정] │ +└──────────────────────────────────────────┘ +``` + +### 핵심 로직 +- **상태 요약 카드:** 각 상태별 장비 카운트 → `GET /api/v1/equipment/summary` +- **조건부 액션 버튼:** `AVAILABLE` → [지급], `IN_USE` → [반납], `REPAIRING` → [수리 완료] +- **사원 검색 자동완성:** `GET /api/v1/employees/search?keyword=` + **300ms 디바운싱** +- **낙관적 락 충돌:** 지급 시 `version` 불일치 → 409 → 토스트 "다른 사용자가 먼저 처리했습니다" + +--- + +## 5. 업무 보고서 (`WorkReportView.vue`) + +### UI 구조 +``` +┌─────────────────────────────────────────────────────────┐ +│ 📝 업무 보고서 작성 │ +│ │ +│ 보고 유형: [WEEKLY ▼] 기준일자: [2026-05-21] │ +│ │ +│ ✅ 완료 실적 (SR 자동 연동) │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ SR-2026-042: 오라클 인덱스 튜닝 (RESOLVED) │ │ +│ │ SR-2026-043: 배포 파이프라인 오류 수정 (CLOSED) │ │ +│ │ ※ 해당 주차 해결 완료한 SR 내역 자동 바인딩 │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ 완료 실적: [________________________________________] │ +│ 차기 계획: [________________________________________] │ +│ 특이사항: [_________________________________________] │ +│ 본사 지원 요청: [___________________________________] │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ 관리자 피드백 (김매니저): 수고하셨습니다. ✅ │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ [임시저장] [제출하기] │ +└─────────────────────────────────────────────────────────┘ +``` + +### 결재 상태 흐름 +``` +DRAFT ── [제출] ──> SUBMITTED ── [승인] ──> APPROVED + │ + └── [반려] ──> DRAFT (관리자 피드백 필요) +``` + +### 핵심 로직 +- **SR 자동 연동:** 페이지 진입 시 `GET /api/v1/reports/sr-auto-complete?targetDate=...` → 해당 주차 해결 SR 바인딩 +- **임시저장 vs 제출:** DRAFT → SUBMITTED 전환 전 벨리데이션 (필수 항목 체크) +- **결재 피드백:** `REJECTED` 상태에서 관리자 피드백 박스 조건부 렌더링 + +--- + +## 6. 상태 천이 다이어그램 + +### SR 티켓 +``` +OPEN ──[담당자 배정]──> ASSIGNED ──[처리시작]──> IN_PROGRESS ──[해결]──> RESOLVED ──[확인종료]──> CLOSED + │ │ │ + └──[취소/삭제]──────────┴───────────────────────┴── (Soft Delete) +``` + +### 업무 보고서 +``` +DRAFT ──[제출]──> SUBMITTED ──[승인]──> APPROVED + │ + └──[반려]──> DRAFT (+ 피드백) +``` + +### 장비 +``` +AVAILABLE ──[지급]──> IN_USE ──[반납]──> AVAILABLE + │ │ + └──[고장접수]──> REPAIRING ──[수리완료]──> AVAILABLE + │ + └──[폐기]──> RETIRED +``` diff --git a/docs/06-development-guide.md b/docs/06-development-guide.md new file mode 100644 index 0000000..a5cd50d --- /dev/null +++ b/docs/06-development-guide.md @@ -0,0 +1,321 @@ +# 개발 가이드 및 컨벤션 (Development Guide) + +## 1. 프로젝트 디렉토리 구조 + +### Backend (Spring Boot) +``` +src/main/java/com/hq/support/portal/ +├── global/ +│ ├── common/ +│ │ └── ApiResponse.java // 공통 응답 래퍼 +│ ├── config/ +│ │ ├── AsyncConfig.java // @Async 스레드 풀 +│ │ ├── JpaAuditConfig.java // @EnableJpaAuditing +│ │ ├── BatchConfig.java // Spring Batch 메타 설정 +│ │ └── SecurityConfig.java // Security Filter Chain +│ ├── error/ +│ │ ├── ErrorCode.java // 에러코드 Enum 매트릭스 +│ │ ├── GlobalExceptionHandler.java // @RestControllerAdvice +│ │ └── exception/ +│ │ └── BusinessException.java // 최상위 예외 클래스 +│ └── security/ +│ ├── JwtTokenProvider.java // JWT 생성/검증 +│ └── JwtAuthenticationFilter.java // 인증 필터 +│ +├── domain/ +│ ├── base/ +│ │ └── BaseEntity.java // createdAt, updatedAt, isDeleted +│ ├── employee/ +│ │ ├── entity/TbEmployee.java +│ │ ├── repository/EmployeeRepository.java +│ │ └── exception/InvalidEmployeeException.java +│ ├── project/ +│ │ ├── entity/TbProject.java +│ │ └── repository/ProjectRepository.java +│ ├── dispatch/ +│ │ ├── entity/TbDispatchHistory.java +│ │ └── repository/DispatchRepository.java +│ ├── ticket/ +│ │ ├── entity/TbIssueTicket.java +│ │ ├── entity/TbTicketComment.java +│ │ ├── repository/TicketRepository.java +│ │ ├── controller/IssueTicketController.java +│ │ ├── dto/TicketDto.java +│ │ ├── service/IssueTicketService.java +│ │ └── event/TicketCreatedEvent.java +│ ├── report/ +│ │ ├── entity/TbWorkReport.java +│ │ └── repository/ReportRepository.java +│ ├── equipment/ +│ │ ├── entity/TbEquipment.java +│ │ ├── entity/TbEquipmentAssignment.java +│ │ ├── entity/TbEquipmentMaintenance.java +│ │ └── repository/EquipmentRepository.java +│ └── training/ +│ ├── entity/TbTrainingProgress.java +│ └── repository/TrainingRepository.java +│ +└── infra/ + └── notification/ + ├── listener/NotificationEventListener.java + └── scheduler/SlaJobScheduler.java + +src/main/resources/ +├── application.yml +└── ddl/oracle_11g_schema.sql +``` + +### Frontend (Next.js 14+) +``` +src/ +├── app/ +│ ├── (auth)/login/page.tsx +│ ├── tickets/ +│ │ ├── page.tsx // 칸반 보드 +│ │ └── [ticketId]/page.tsx // 상세/타임라인 +│ ├── equipment/page.tsx // 장비 인벤토리 +│ ├── reports/page.tsx // 업무 보고서 +│ ├── training/page.tsx // 교육 진도 +│ ├── employees/page.tsx // 직원 관리 (ADMIN) +│ └── layout.tsx // GNB + LNB 레이아웃 +├── components/ +│ ├── layout/ +│ │ ├── GNB.tsx +│ │ └── LNB.tsx +│ ├── ticket/ +│ │ ├── KanbanBoard.tsx +│ │ ├── TicketCard.tsx +│ │ └── TimelineComment.tsx +│ ├── equipment/ +│ │ ├── EquipmentTable.tsx +│ │ └── AssignModal.tsx +│ └── common/ +│ ├── ApiResponse.ts +│ └── Pagination.tsx +├── stores/ +│ ├── authStore.ts +│ └── notificationStore.ts +├── services/ +│ ├── apiClient.ts // Axios 인스턴스 + 인터셉터 +│ ├── ticketService.ts +│ ├── equipmentService.ts +│ └── reportService.ts +└── types/ + └── index.ts // 전역 TypeScript 타입 +``` + +--- + +## 2. Backend 코딩 컨벤션 + +### 2.1 엔티티 네이밍 +- **테이블명:** `TB_XXX` (Oracle 11g 컨벤션) +- **엔티티 클래스명:** `TbXxx.java` (PascalCase, 테이블명 매핑) +- **PK 컬럼:** `xxx_id` (NUMBER, Sequence 할당) + +### 2.2 ID 생성 전략 (Oracle 11g 최적화) +```java +@Entity +@SequenceGenerator(name = "SEQ_TB_TICKET", sequenceName = "SEQ_TB_TICKET", allocationSize = 50) +public class TbIssueTicket { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "SEQ_TB_TICKET") + private Long ticketId; +} +``` +- Oracle DDL 시퀀스: `CREATE SEQUENCE SEQ_TB_TICKET INCREMENT BY 50;` +- `allocationSize = 50` + `INCREMENT BY 50` 반드시 정합 + +### 2.3 Soft Delete 패턴 +```java +@SQLDelete(sql = "UPDATE tb_issue_ticket SET is_deleted = 'Y' WHERE ticket_id = ?") +@Where(clause = "is_deleted = 'N'") +public class TbIssueTicket { ... } +``` +- 모든 엔티티에 `@SQLDelete` + `@Where` 선언 +- `BaseEntity`에 `isDeleted` 컬럼 공통 상속 + +### 2.4 레이어드 아키텍처 규칙 +| 레이어 | 책임 | 규칙 | +|--------|------|------| +| Controller | HTTP 진입점 | DTO 반환, `@Valid` 검증, 서비스 호출만 | +| Service | 비즈니스 로직 | `@Transactional`, 예외 throw, 이벤트 발행 | +| Repository | DB 접근 | Spring Data JPA, `Specification` 동적 쿼리 | +| DTO | 데이터 전송 | Request/Response 분리, 엔티티 직접 노출 금지 | + +### 2.5 예외 처리 계층 +```java +// 최상위 예외 +public class BusinessException extends RuntimeException { + private final ErrorCode errorCode; +} + +// 도메인별 구체 예외 +public class TicketClosedException extends BusinessException { + public TicketClosedException() { + super(ErrorCode.CLOSED_TICKET_MOD); + } +} + +// ControllerAdvice에서 ErrorCode → ApiResponse 자동 변환 +``` + +### 2.6 EDA (이벤트 기반 알림) +```java +// 발행 +@Service +public class IssueTicketService { + @Autowired + private ApplicationEventPublisher publisher; + + public TicketDto createTicket(TicketCreateReq req) { + TbIssueTicket ticket = repository.save(req.toEntity()); + publisher.publishEvent(new TicketCreatedEvent(ticket)); + return TicketDto.from(ticket); + } +} + +// 구독 (트랜잭션 완료 후 비동기 실행) +@Component +public class NotificationEventListener { + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleTicketCreated(TicketCreatedEvent event) { + // Slack/Email 발송 (메인 트랜잭션과 완전 격리) + } +} +``` + +--- + +## 3. Frontend 코딩 컨벤션 + +### 3.1 API 통신 (Axios) +```typescript +// apiClient.ts - 공통 인터셉터 +const apiClient = axios.create({ + baseURL: '/api/v1', + timeout: 10000, +}); + +apiClient.interceptors.response.use( + (res) => res.data, // ApiResponse.data 자동 언래핑 + (err) => { + const { errorCode, message } = err.response.data; + // errorCode 기반 전역 토스트/알림 + return Promise.reject({ errorCode, message }); + } +); +``` + +### 3.2 Pinia Store 패턴 +```typescript +// stores/authStore.ts +export const useAuthStore = defineStore('auth', () => { + const user = ref(null); + const roleCode = computed(() => user.value?.roleCode); + + async function fetchMe() { + const res = await apiClient.get('/employees/me'); + user.value = res.data; + } + + return { user, roleCode, fetchMe }; +}); +``` + +### 3.3 컴포넌트 네이밍 +- **페이지 컴포넌트:** `page.tsx` (App Router), 도메인 폴더 하위 +- **UI 컴포넌트:** `PascalCase.tsx` (단일 책임) +- **Props:** Interface 선언 후 `defineProps()` +- **Event Emit:** `defineEmits<{ eventName: [payload: type] }>()` + +--- + +## 4. 개발 환경 설정 + +### Backend (application.yml) +```yaml +spring: + datasource: + url: jdbc:oracle:thin:@localhost:1521:XE + username: portal + password: ${DB_PASSWORD} + jpa: + hibernate: + ddl-auto: none # 운영 DDL은 별도 스크립트 관리 + properties: + hibernate: + dialect: org.hibernate.dialect.Oracle10gDialect + batch: + jdbc: + initialize-schema: always # 배치 메타 테이블 자동 생성 + +jwt: + secret: ${JWT_SECRET} + access-token-validity: 3600000 # 1시간 + refresh-token-validity: 604800000 # 7일 +``` + +### Frontend (.env.local) +``` +NEXT_PUBLIC_API_BASE_URL=http://localhost:8080/api/v1 +NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws/notifications +``` + +--- + +## 5. 빌드 및 배포 + +### CI/CD (GitHub Actions) +- **트리거:** `release` 생성 시에만 운영 빌드 +- **Push 이벤트:** Lint + Test만 실행 (배포 없음) +- **파이프라인:** Build → Test → Docker Image → Deploy (Nginx Proxy Manager) + +### 로컬 빌드 검증 +```bash +# Backend +./gradlew clean build + +# Frontend +npm run build +npm run lint +``` + +### Nginx Proxy Manager +- SSL 인증서 관리 +- `/api/*` → Backend Container (Reverse Proxy) +- `/*` → Frontend Static/Node Server +- Rate Limit 설정 (API abuse 방어) + +--- + +## 6. DB DDL 관리 규칙 + +- DDL 파일: `src/main/resources/ddl/oracle_11g_schema.sql` +- 모든 시퀀스: `CREATE SEQUENCE SEQ_TB_XXX INCREMENT BY 50 START WITH 1` +- `ddl-auto: none` — DDL은 스크립트로만 관리 (Hibernate 자동 생성 금지) +- 스키마 변경 시 DDL 파일 버전 관리 (Git) +- 인덱스: 자주 조회하는 컬럼(상태, 날짜, 외래키)에 인덱스 별도 추가 +- 공통 코드 데이터: `INSERT` 문으로 시드 데이터 관리 + +--- + +## 7. 커밋 컨벤션 + +한국어로 명확하게 작성 (팀 협업 가독성 보장) + +``` +feat: SR 티켓 칸반 보드 API 구현 +fix: 장비 지급 시 낙관적 락 충돌 해결 +refactor: BaseEntity 공통 필드 추출 +docs: DB 설계서 최신화 +``` + +### 브랜치 전략 +``` +main ← develop ← feature/{domain}-{description} +- feature/issue-ticket-kanban +- feature/equipment-assign-optimistic-lock +- fix/report-status-transition-bug +``` diff --git a/docs/07-design-system.md b/docs/07-design-system.md new file mode 100644 index 0000000..ccfce80 --- /dev/null +++ b/docs/07-design-system.md @@ -0,0 +1,377 @@ +--- +name: Premium Enterprise — SR System HQ Helpdesk +version: 2.0.0 +merged_from: + - Stitch/Premium Enterprise (v1) + - project_m_dashboard_shell_1 + - project_m_dashboard_shell_2 +colors: + # === Canvas & Surface === + surface: '#f8f9ff' + surface-dim: '#cbdbf5' + surface-bright: '#f8f9ff' + surface-container-lowest: '#ffffff' + surface-container-low: '#eff4ff' + surface-container: '#e5eeff' + surface-container-high: '#dce9ff' + surface-container-highest: '#d3e4fe' + on-surface: '#0b1c30' + on-surface-variant: '#464555' + inverse-surface: '#213145' + inverse-on-surface: '#eaf1ff' + outline: '#777587' + outline-variant: '#c7c4d8' + surface-tint: '#4d44e3' + # === Brand Indigo === + primary: '#3525cd' + on-primary: '#ffffff' + primary-container: '#4f46e5' + on-primary-container: '#dad7ff' + inverse-primary: '#c3c0ff' + # === Accent Purple === + secondary: '#712ae2' + on-secondary: '#ffffff' + secondary-container: '#8a4cfc' + on-secondary-container: '#fffbff' + # === Semantic Amber (tertiary, for ITAM warnings) === + tertiary: '#7e3000' + on-tertiary: '#ffffff' + tertiary-container: '#a44100' + on-tertiary-container: '#ffd2be' + # === Semantic Error === + error: '#ba1a1a' + on-error: '#ffffff' + error-container: '#ffdad6' + on-error-container: '#93000a' + # === Fixed Tints === + primary-fixed: '#e2dfff' + primary-fixed-dim: '#c3c0ff' + on-primary-fixed: '#0f0069' + on-primary-fixed-variant: '#3323cc' + secondary-fixed: '#eaddff' + secondary-fixed-dim: '#d2bbff' + on-secondary-fixed: '#25005a' + on-secondary-fixed-variant: '#5a00c6' + tertiary-fixed: '#ffdbcc' + tertiary-fixed-dim: '#ffb695' + on-tertiary-fixed: '#351000' + on-tertiary-fixed-variant: '#7b2f00' + background: '#f2f3f8' + on-background: '#0b1c30' + surface-variant: '#d3e4fe' + # === NEW: Status / Semantic Colors (from reference) === + status-open: '#3b82f6' # Blue — SR Open + status-open-bg: '#eff6ff' + status-resolved: '#10b981' # Emerald — Resolved + status-resolved-bg: '#ecfdf5' + status-critical: '#ef4444' # Red — Critical Priority + status-critical-bg: '#fef2f2' + status-pending: '#f59e0b' # Amber — Pending / In-progress + status-pending-bg: '#fffbeb' + status-closed: '#6b7280' # Gray — Closed / Archived + status-closed-bg: '#f9fafb' + # === NEW: Project Dot Colors (from shell reference) === + project-dot-green: '#22c55e' + project-dot-orange: '#f97316' + project-dot-blue: '#3b82f6' + project-dot-pink: '#ec4899' + project-dot-purple: '#a855f7' + project-dot-gray: '#d1d5db' +typography: + h1: + fontFamily: Outfit + fontSize: 32px + fontWeight: '600' + lineHeight: '1.2' + h2: + fontFamily: Outfit + fontSize: 24px + fontWeight: '600' + lineHeight: '1.3' + h3: + fontFamily: Outfit + fontSize: 20px + fontWeight: '500' + lineHeight: '1.4' + h4: + fontFamily: Outfit + fontSize: 16px + fontWeight: '600' + lineHeight: '1.4' + body-lg: + fontFamily: Inter + fontSize: 16px + fontWeight: '400' + lineHeight: '1.6' + body-md: + fontFamily: Inter + fontSize: 14px + fontWeight: '400' + lineHeight: '1.5' + body-sm: + fontFamily: Inter + fontSize: 12px + fontWeight: '400' + lineHeight: '1.5' + data-label: + fontFamily: Inter + fontSize: 12px + fontWeight: '500' + lineHeight: '1.2' + letterSpacing: 0.02em + button: + fontFamily: Inter + fontSize: 14px + fontWeight: '600' + lineHeight: '1' + nav-item: + fontFamily: Inter + fontSize: 14px + fontWeight: '500' + lineHeight: '1' + nav-group-label: + fontFamily: Inter + fontSize: 11px + fontWeight: '600' + lineHeight: '1' + letterSpacing: 0.08em + textTransform: uppercase +rounded: + sm: 0.25rem + DEFAULT: 0.5rem + md: 0.75rem + lg: 1rem + xl: 1.5rem + full: 9999px +spacing: + base: 4px + xs: 4px + sm: 8px + md: 16px + lg: 24px + xl: 32px + gutter: 24px + margin-page: 32px +--- + +## Brand & Style + +This design system establishes an **"Approachable Enterprise"** aesthetic for the SR System — a HQ Helpdesk and IT Asset Management Portal for dispatched SI/SM engineers. It balances the rigor of data-dense corporate tools with a modern, human-centric softness. + +The visual style is **Corporate Modern with Tactile Influences**. It utilizes generous whitespace, a high-quality Off-white canvas (`#F2F3F8`), and soft ambient shadows to create clear spatial hierarchy. The intent is to lower cognitive load for power users while maintaining a premium feel through refined typography and intentional roundness. + +**Merged from:** Stitch Premium Enterprise v1 + Project M. Dashboard Shell (expanded & icon-collapsed variants). + +--- + +## Colors + +The palette is anchored by **Deep Indigo** for primary actions and brand presence. The canvas follows a "Softer Background" philosophy: +- **Canvas:** `#F2F3F8` Off-white, slightly cooler than the v1 value, to contrast white cards more clearly. +- **Surfaces:** Pure White `#FFFFFF` for cards and main content containers. +- **Brand Indigo:** `#4F46E5` for primary buttons, active nav states, and key CTAs. +- **Accents:** Soft purples (`#712AE2`) for secondary actions; muted semantic colors for SR status badges. +- **Status Colors (NEW):** A distinct set of semantic tokens for SR ticket states (Open, In-Progress, Resolved, Closed, Critical) and ITAM asset states. These must remain consistent across all badge, chip, and table row usages. +- **Project Dot Colors (NEW):** 5 distinct hues (green, orange, blue, pink, purple) used as visual identifiers for active dispatch projects in the sidebar project list. + +--- + +## Typography + +Dual-font strategy: +- **Outfit** — Headlines, section titles, sidebar brand name. +- **Inter** — All data labels, body text, nav items, badges. + +Hierarchy is achieved through weight and tonal contrast. Data labels use `letter-spacing: 0.02em` and `font-weight: 500`. +Nav group labels (e.g., "MY PROJECTS", "MAIN MENU") use `font-size: 11px`, `font-weight: 600`, `letter-spacing: 0.08em`, uppercase. + +--- + +## Layout & Spacing + +### Sidebar Behavior (UPDATED from reference) +The sidebar has **two responsive states**: + +| State | Width | Content | +|---|---|---| +| **Expanded** (default, ≥1280px) | 250px | Logo + label + full nav text + project list + CTA card | +| **Collapsed** (manual toggle or ≤1024px) | 64px | Icon-only nav + project color dots + icon CTA | + +A `>>` / `<<` toggle chevron is placed in the top bar to switch states. Active state = pill-shaped highlight (`8px–12px` rounded rect) in `primary-fixed` tint. + +### Top Navigation Bar +- Height: 64px, white background, `1px solid outline-variant` bottom border. +- **Left zone:** Sidebar toggle chevron + Search bar (centered, `#F3F4F6` bg, 12px radius). +- **Right zone:** Calendar icon → Chat icon → Bell icon (with unread badge) → `[Avatar] Name / Location ▾`. + +### Main Content Grid +- 12-column fluid grid with 24px gutters, 32px page margins. +- Summary card row at top: 4 equal columns (SR counts, active dispatches, equipment status, pending reports). +- Below: mix of 8+4 or 6+6 column splits for tables + detail panels. + +### Page Container + +All pages follow a standardized container pattern for layout consistency: + +```tsx +className="p-4 sm:p-6 lg:p-8" // Responsive padding +max-w-7xl mx-auto // Content max-width (or max-w-5xl for profile) +``` + +**Padding:** +- Mobile (< 640px): `p-4` (16px) +- Tablet (640–1024px): `p-6` (24px) +- Desktop (≥ 1024px): `p-8` (32px) + +**Max-width:** +- Standard pages: `max-w-7xl` (1280px) +- Profile page: `max-w-5xl` (1024px) — lighter content density + +**Components that must use this pattern:** +- All `src/app/(main)/*/page.tsx` files +- All sub-page files in route groups +- The root div of each page should directly use `p-4 sm:p-6 lg:p-8` + +**Exceptions:** +- Modals and overlays (use their own sizing) +- The sidebar and header (managed by MainShell) + +--- + +## Elevation & Depth + +| Level | Surface | Treatment | +|---|---|---| +| **0 — Canvas** | `#F2F3F8` | Background only | +| **1 — Cards / Sidebar** | White | `1px border (#E5E7EB)` + `box-shadow: 0 1px 3px rgba(0,0,0,0.06)` | +| **2 — Floating / Active** | White | `box-shadow: 0 4px 16px rgba(0,0,0,0.08)` | +| **3 — Modal / Overlay** | White | `box-shadow: 0 20px 48px rgba(0,0,0,0.14)` + backdrop blur | + +Hover states increase shadow spread; border color stays constant. + +--- + +## Shapes + +- **Content Cards:** `border-radius: 16px` (lg) +- **Buttons / Inputs:** `12px–16px` +- **Sidebar Nav Active State:** Pill-shape or `8px–12px` rounded rect background +- **Status Badges / Chips:** Pill (`border-radius: 9999px`), low-opacity bg tint + high-contrast text +- **Avatars:** `border-radius: 9999px` (circular) +- **Project Dots:** `border-radius: 9999px`, `width: 8px`, `height: 8px` + +--- + +## Components + +### Sidebar (UPDATED) + +**Expanded State (250px)** +``` +┌─────────────────────────┐ +│ 🟣 SR System │ ← Logo dot (gradient circle) + Outfit h4 +├─────────────────────────┤ +│ MAIN MENU │ ← nav-group-label style +│ ⊞ Dashboard │ ← Active: pill bg (#EEF2FF), text (#4F46E5) +│ 📋 SR Issues │ +│ 🚀 Dispatch │ +│ 💻 ITAM │ +│ 📊 Work Reports │ +│ 🎓 Training │ +│ ⚙️ Settings │ +├─────────────────────────┤ +│ MY PROJECTS [+] │ ← nav-group-label + icon button +│ 🟢 Samsung Pay SI │ +│ 🟠 LG CNS SM │ +│ 🔵 Kakao Maintenance │ +│ 🔴 SK Hynix Dev │ +├─────────────────────────┤ +│ ╭───────────────────╮ │ +│ │ 💡 Quick Note │ │ ← CTA Card: #FFF9EC bg, amber accent +│ │ Share updates │ │ +│ │ [Write a message] │ │ +│ ╰───────────────────╯ │ +└─────────────────────────┘ +``` + +**Collapsed State (64px)** +Icons only; project list shows 8px color dots; CTA card reduces to single 💡 icon button. + +--- + +### Top Navigation Bar +- **Search:** `placeholder="Search issues, assets, employees..."` — 400px wide at center. +- **Icons (right):** Calendar (`📅`), Chat/Message (`💬`), Bell (`🔔` with red dot if unread). +- **Profile:** Circular avatar (32px) + `Inter Medium` name + subtitle (department/location) + dropdown `▾`. + +--- + +### Summary Cards (Dashboard Header Row) +4 cards with colored left-border accent and inline sparkline or icon: +1. **Open SR Tickets** — Blue left-border, count + delta vs last week. +2. **Active Dispatches** — Indigo left-border, headcount + project list mini chips. +3. **Equipment In-Use** — Amber left-border, asset count + status bar. +4. **Pending Reports** — Purple left-border, overdue count highlighted in red. + +--- + +### Buttons & Controls +- **Primary:** `bg: #4F46E5`, white text, `12px` radius, `px-16 py-8`, Inter SemiBold 14px. +- **Secondary Outlined:** transparent bg, `1px solid #4F46E5`, indigo text. +- **Ghost:** No border, gray text, hover bg `#F3F4F6`. +- **Danger:** `bg: #EF4444` for destructive actions. +- **'New SR' (Primary CTA):** In Top Nav right area — Primary style with `+` icon prefix. + +--- + +### Status Badges / Chips (SR Tickets) +``` +OPEN → bg: #EFF6FF, text: #3B82F6, border: none +IN-PROGRESS→ bg: #FFFBEB, text: #F59E0B +RESOLVED → bg: #ECFDF5, text: #10B981 +CLOSED → bg: #F9FAFB, text: #6B7280 +CRITICAL → bg: #FEF2F2, text: #EF4444, font-weight: 600 +``` +All pill-shaped (`border-radius: 9999px`), `px-8 py-2`, `font-size: 12px`. + +--- + +### Data Tables +- Header: `font-size: 12px`, Inter SemiBold, `letter-spacing: 0.04em`, uppercase, `color: #6B7280`. +- Row hover: `bg: #F8F9FF`. +- Selected row: `bg: #EEF2FF`. +- Pagination: Simple `< 1 2 3 ... 12 >` strip, `border-top: 1px solid #E5E7EB`. +- Column widths: flexible, min-width per type (`ID: 60px, Date: 100px, Status: 120px, Text: auto`). + +--- + +### SR Issue Detail Panel +A right-side slide-over panel (480px) or full-page route: +- **Header:** Title, SR ID badge, Status badge, Priority chip, Assignee avatar. +- **Thread / Comments:** Chat-bubble layout, alternating left (requester) / right (HQ support). +- **Code Block:** Dark bg (`#1E1E2E`), monospace font (JetBrains Mono 13px), copy button. +- **Attachment Area:** File chip list with extension icon + filename + size. + +--- + +### Dispatch Timeline +Vertical timeline component for employee career: +- Left rail: `2px solid #E5E7EB` with colored dot per project. +- Each entry: Project name (Outfit SemiBold), period badge, role chip, tech stack pill list. + +--- + +### ITAM Asset Viewer +- Expandable JSON spec panel using styled key-value grid. +- Asset status pill in header. +- Maintenance history as compact table below specs. + +--- + +## Responsive Breakpoints + +| Breakpoint | Sidebar | Layout | +|---|---|---| +| `≥ 1280px` | 250px expanded | Full dashboard | +| `1024px–1279px` | 64px collapsed (auto) | Icon sidebar | +| `< 1024px` | Hidden (drawer overlay) | Mobile nav bar at bottom | \ No newline at end of file diff --git a/docs/08-deployment-guide.md b/docs/08-deployment-guide.md new file mode 100644 index 0000000..c258cbd --- /dev/null +++ b/docs/08-deployment-guide.md @@ -0,0 +1,643 @@ +# 배포 가이드 (Deployment Guide) + +## 1. 전체 아키텍처 + +### 시스템 구성도 + +``` +[User Browser] + │ + ├── https://sr.example.com ──────────────────────┐ + │ │ + ▼ ▼ +Cloudflare DNS ─── Cloudflare Pages ──── Cloudflare Tunnel +(도메인 관리) (Next.js 정적 호스팅) (outbound 전용) + │ + │ http://localhost:8080 + ▼ + TrueNAS Scale + ├── Docker: Spring Boot (:8080) + ├── Docker: PostgreSQL (:5432) + └── Dataset: 첨부파일 저장소 +``` + +### 데이터 흐름 + +``` +1. User → https://sr.example.com → Cloudflare Pages (Next.js SPA) +2. SPA → GET https://api.sr.example.com/v1/tickets + │ +3. Cloudflare DNS → Cloudflare Tunnel (443) → cloudflared 컨테이너 +4. cloudflared → http://spring-boot:8080/api/v1/tickets +5. Spring Boot → PostgreSQL (:5432) / Dataset (/data/attachments) +``` + +### 포트 오픈 현황 + +| 방향 | 프로토콜 | 포트 | 설명 | +|------|---------|------|------| +| **아웃바운드** | TCP | 443 | cloudflared → Cloudflare Edge | +| 인바운드 | **없음** | - | 포트포워딩 불필요 | + +TrueNAS에 **열린 인바운드 포트가 0개**. cloudflared가 아웃바운드 연결만 생성하여 Cloudflare와 통신. + +--- + +## 2. 사전 준비 + +### 2.1 Cloudflare + +- Cloudflare 계정 (https://dash.cloudflare.com) +- 도메인 Cloudflare DNS 위임 완료 +- Cloudflare Pages 가입 (무료 티어 가능) + +### 2.2 TrueNAS Scale + +- TrueNAS Scale 24.04+ (Linux 기반) +- Apps 기능 활성화: **Apps → Settings → Choose Pool** (앱 전용 Dataset 선택) +- Docker 네트워크 확인: `docker network ls` + +### 2.3 GitHub + +- Frontend Repository (`sr-frontend`) +- Backend Repository (`sr-backend`) +- GitHub Secrets 등록 준비 + +--- + +## 3. 1단계: Backend Docker화 + +### 3.1 Dockerfile + +Spring Boot 프로젝트 루트에 `Dockerfile` 생성: + +```dockerfile +# Build stage +FROM gradle:8.7-jdk17 AS builder +WORKDIR /app +COPY build.gradle settings.gradle ./ +COPY src ./src +RUN gradle build -x test --no-daemon + +# Runtime stage +FROM eclipse-temurin:17-jre +WORKDIR /app +COPY --from=builder /app/build/libs/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] +``` + +### 3.2 application-prod.yml + +`src/main/resources/application-prod.yml` 생성: + +```yaml +spring: + datasource: + url: jdbc:postgresql://postgres:5432/sr_system + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + jpa: + hibernate: + ddl-auto: validate + show-sql: false + servlet: + multipart: + max-file-size: 50MB + max-request-size: 100MB + +app: + file: + upload-dir: /data/attachments + jwt: + secret: ${JWT_SECRET} + +server: + port: 8080 + +logging: + level: + com.hq.support.portal: INFO +``` + +### 3.3 docker-compose.yml + +최상위 `docker/infra/docker-compose.yml`: + +```yaml +version: "3.8" + +services: + postgres: + image: postgres:16 + container_name: sr-postgres + restart: unless-stopped + environment: + POSTGRES_DB: sr_system + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - /mnt/apps/sr-system/postgres:/var/lib/postgresql/data + ports: + - "127.0.0.1:5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d sr_system"] + interval: 10s + timeout: 5s + retries: 5 + + spring-boot: + build: + context: . + dockerfile: Dockerfile + container_name: sr-backend + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + SPRING_PROFILES_ACTIVE: prod + DB_USERNAME: ${DB_USERNAME} + DB_PASSWORD: ${DB_PASSWORD} + JWT_SECRET: ${JWT_SECRET} + volumes: + - /mnt/apps/sr-system/attachments:/data/attachments + ports: + - "127.0.0.1:8080:8080" +``` + +> **중요:** `ports`가 `127.0.0.1:8080:8080`으로 설정됨. 외부 직접 접근 불가, cloudflared를 통해서만 접근 가능. + +--- + +## 4. 2단계: TrueNAS Scale 배포 + +### 4.1 Dataset 생성 + +TrueNAS GUI → Storage → Create Dataset: + +| 항목 | 경로 | 용도 | +|------|------|------| +| App Config | `/mnt/apps/sr-system` | docker-compose, 환경변수 | +| PostgreSQL Data | `/mnt/apps/sr-system/postgres` | DB 데이터 | +| Attachments | `/mnt/apps/sr-system/attachments` | 첨부파일 저장소 | + +### 4.2 환경변수 파일 + +`/mnt/apps/sr-system/.env` 생성: + +```bash +DB_USERNAME=sr_admin +DB_PASSWORD=<복잡한_비밀번호> +JWT_SECRET= +``` + +### 4.3 Custom App 등록 + +TrueNAS GUI → Apps → Launch Docker Image 대신 **Custom App** 사용: + +1. **App Name:** `sr-system` +2. **Image:** (docker-compose 방식 사용) +3. **Add Container → docker-compose:** + + TrueNAS Apps → Discover Apps → Custom App 선택 후 아래 내용 입력: + + ```yaml + services: + postgres: + image: postgres:16 + container_name: sr-postgres + restart: unless-stopped + env_file: /mnt/apps/sr-system/.env + volumes: + - /mnt/apps/sr-system/postgres:/var/lib/postgresql/data + networks: + - sr-network + + spring-boot: + image: sr-backend:latest + container_name: sr-backend + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + env_file: /mnt/apps/sr-system/.env + environment: + SPRING_PROFILES_ACTIVE: prod + volumes: + - /mnt/apps/sr-system/attachments:/data/attachments + networks: + - sr-network + + networks: + sr-network: + driver: bridge + ``` + +> **참고:** docker-compose 방식 대신 TrueNAS Apps GUI의 **Custom App → Container Images**에서 각 이미지를 개별 등록하고 `Network`를 동일한 bridge 네트워크로 연결해도 됨. + +### 4.4 수동 빌드 (초기) + +백엔드 코드를 TrueNAS에 복사 후 빌드: + +```bash +# TrueNAS SSH 접속 +cd /mnt/apps/sr-system +git clone https://github.com/your-org/sr-backend.git +cd sr-backend +docker build -t sr-backend:latest . +docker compose up -d +``` + +--- + +## 5. 3단계: Cloudflare Tunnel 설치 + +### 5.1 Cloudflare Tunnel 생성 (로컬 CLI) + +```bash +# 로컬 PC에서 cloudflared 설치 +brew install cloudflare/cloudflare/cloudflared + +# Cloudflare 로그인 +cloudflared tunnel login + +# Tunnel 생성 +cloudflared tunnel create sr-system-tunnel + +# 출력 예시: Tunnel ID: abc12345-xxxx-xxxx-xxxx-xxxxxxxxxxxx +# 인증 파일: ~/.cloudflared/abc12345-xxxx-xxxx-xxxx-xxxxxxxxxxxx.json +``` + +### 5.2 Config 파일 생성 + +`~/.cloudflared/config.yml`: + +```yaml +tunnel: abc12345-xxxx-xxxx-xxxx-xxxxxxxxxxxx +credentials-file: /home/.cloudflared/abc12345-xxxx-xxxx-xxxx-xxxxxxxxxxxx.json + +ingress: + - hostname: api.sr.example.com + service: http://localhost:8080 + - service: http_status:404 +``` + +### 5.3 DNS 라우팅 설정 + +```bash +cloudflared tunnel route dns sr-system-tunnel api.sr.example.com +``` + +### 5.4 TrueNAS에 cloudflared 컨테이너 배포 + +**방법 A: Docker Compose에 포함** + +`docker/infra/docker-compose.yml`에 cloudflared 추가: + +```yaml +services: + # ... postgres, spring-boot ... + + cloudflared: + image: cloudflare/cloudflared:latest + container_name: sr-tunnel + restart: unless-stopped + command: tunnel --no-autoupdate run + environment: + - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN} + networks: + - sr-network +``` + +**방법 B: TrueNAS Apps에서 개별 배포** + +1. TrueNAS → Apps → Discover Apps → Custom App +2. **App Name:** `sr-tunnel` +3. **Image:** `cloudflare/cloudflared:latest` +4. **Entrypoint:** `tunnel --no-autoupdate run` +5. **Environment Variables:** + - `TUNNEL_TOKEN`: `` + +### 5.5 Tunnel Token 발급 + +```bash +cloudflared tunnel token sr-system-tunnel > tunnel-token.txt +# 출력된 token을 CLOUDFLARE_TUNNEL_TOKEN 환경변수로 등록 +``` + +### 5.6 동작 확인 + +```bash +# Tunnel 로그 확인 +docker logs sr-tunnel + +# 정상 출력 예시: +# 2026/05/21 10:00:00 INF Connection abc12345 registered +# 2026/05/21 10:00:00 INF Route created hostname=api.sr.example.com +# 2026/05/21 10:00:00 INF tunnelId=abc12345 + +# Spring Boot 헬스체크 +curl https://api.sr.example.com/api/v1/actuator/health +# {"status":"UP"} +``` + +--- + +## 6. 4단계: Frontend Cloudflare Pages 배포 + +### 6.1 next.config.ts 설정 + +```typescript +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "export", // 정적 사이트 내보내기 + images: { + unoptimized: true, // 정적 export 시 필수 + }, + trailingSlash: true, + distDir: "out", +}; + +export default nextConfig; +``` + +> **참고:** API Routes나 Server Components가 필요하면 `output: 'export'` 대신 `@cloudflare/next-on-pages` 사용. + +### 6.2 환경변수 + +`.env.production`: + +```bash +NEXT_PUBLIC_API_URL=https://api.sr.example.com/api/v1 +``` + +### 6.3 Cloudflare Pages 연결 + +1. Cloudflare Dashboard → **Workers & Pages → Pages** +2. **Connect to Git** → GitHub 저장소 선택 +3. **Build settings:** + - Framework preset: **Next.js** + - Build command: `npm run build && npm run export` + - Build output: `out` +4. **Environment Variables (Advanced):** + - `NEXT_PUBLIC_API_URL`: `https://api.sr.example.com/api/v1` +5. **Deploy** + +### 6.4 Custom Domain 설정 + +Deploy 완료 후: +1. Pages 프로젝트 → **Custom domains** → **Set up custom domain** +2. `sr.example.com` 입력 +3. Cloudflare가 자동으로 DNS 레코드 생성 (Proxy ON = 주황 구름) + +--- + +## 7. 5단계: CI/CD + +### 7.1 Backend (GitHub Actions) + +`.github/workflows/deploy-backend.yml`: + +```yaml +name: Deploy Backend + +on: + push: + branches: [main] + paths: + - "backend/**" + - "docker/**" + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: "17" + distribution: "temurin" + + - name: Build with Gradle + run: | + cd backend + ./gradlew build -x test + + - name: Build Docker Image + run: | + cd backend + docker build -t ghcr.io/${{ github.repository }}/sr-backend:latest . + docker tag ghcr.io/${{ github.repository }}/sr-backend:latest \ + ghcr.io/${{ github.repository }}/sr-backend:${{ github.sha }} + + - name: Push to GitHub Container Registry + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + docker push ghcr.io/${{ github.repository }}/sr-backend --all-tags + + - name: Deploy to TrueNAS + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.TRUENAS_HOST }} + username: ${{ secrets.TRUENAS_SSH_USER }} + key: ${{ secrets.TRUENAS_SSH_KEY }} + script: | + cd /mnt/apps/sr-system + docker compose pull spring-boot + docker compose up -d spring-boot + docker image prune -f +``` + +### 7.2 Frontend (GitHub Actions) + +`.github/workflows/deploy-frontend.yml`: + +```yaml +name: Deploy Frontend + +on: + push: + branches: [main] + paths: + - "frontend/**" + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install & Build + run: | + cd frontend + npm ci + npm run build + + - name: Deploy to Cloudflare Pages + uses: cloudflare/pages-action@v1 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + projectName: sr-frontend + directory: frontend/out + gitHubToken: ${{ secrets.GITHUB_TOKEN }} +``` + +### 7.3 GitHub Secrets 등록 + +| Secret | 값 | +|--------|-----| +| `TRUENAS_HOST` | TrueNAS IP 주소 (내부망) | +| `TRUENAS_SSH_USER` | SSH 사용자 | +| `TRUENAS_SSH_KEY` | SSH Private Key | +| `CLOUDFLARE_API_TOKEN` | Cloudflare API Token (Pages 권한) | +| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare Account ID | +| `TUNNEL_TOKEN` | Cloudflare Tunnel Token | + +--- + +## 8. 보안 설정 + +### 8.1 Cloudflare WAF 규칙 + +Cloudflare Dashboard → Security → WAF: + +**규칙 1: API 경로만 허용** +``` +Field: Hostname +Operator: equals +Value: api.sr.example.com + +Then: + URI Path starts with /api/v1 OR /actuator/health + → Allow + Else → Block +``` + +**규칙 2: Rate Limiting** +``` +When: requests match api.sr.example.com +Rate: 1000 requests per 5 minutes +Action: Block +``` + +### 8.2 Cloudflare Access (선택 - VPN 대체) + +Zero Trust → Access → Applications: + +- **App Domain:** `api.sr.example.com` +- **Policy:** 특정 이메일/Google/OAuth 로그인 필수 +- **Session Duration:** 24h + +> Cloudflare Access를 활성화하면 API도 인증된 사용자만 접근 가능. 단, 프론트엔드 SPA에서 API 호출 시 Access Token 처리 로직이 추가로 필요. + +### 8.3 SSL 설정 + +Cloudflare Dashboard → SSL/TLS: +- **Full (Strict)** 선택 +- TrueNAS 측에서는 별도 SSL 인증서 불필요 (cloudflared가 종단 암호화) + +### 8.4 DB 접근 제한 + +PostgreSQL이 `127.0.0.1:5432`에서만 리스닝. 외부 접근 차단. +Docker 네트워크 내에서만 `postgres:5432`로 접근 가능. + +--- + +## 9. 운영 + +### 9.1 헬스체크 + +```bash +# Spring Boot Actuator +curl https://api.sr.example.com/api/v1/actuator/health + +# DB 연결 확인 +curl https://api.sr.example.com/api/v1/actuator/health/db +``` + +### 9.2 로그 확인 + +```bash +# TrueNAS SSH 접속 후 +docker logs sr-backend # Spring Boot 로그 +docker logs sr-postgres # PostgreSQL 로그 +docker logs sr-tunnel # Tunnel 로그 +docker compose logs -f # 실시간 로그 스트림 +``` + +### 9.3 DB 백업 + +**ZFS Snapshot (자동):** +TrueNAS GUI → Storage → `/mnt/apps/sr-system/postgres` → Snapshot: +- Schedule: Daily at 03:00 +- Retention: 30일 + +**pg_dump (수동):** +```bash +docker exec sr-postgres pg_dump -U sr_admin sr_system > \ + /mnt/apps/sr-system/backups/sr_system_$(date +%Y%m%d).sql +``` + +### 9.4 Watchtower (자동 업데이트) + +docker-compose에 Watchtower 추가: + +```yaml +services: + watchtower: + image: containrrr/watchtower + container_name: sr-watchtower + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + command: --interval 3600 --cleanup +``` + +매시간 이미지 체크 → 새 이미지 있으면 자동 재배포. + +--- + +## 10. 문제 해결 + +### Tunnel 연결 안 됨 + +```bash +# cloudflared 로그 확인 +docker logs sr-tunnel + +# DNS 확인 +cloudflared tunnel info sr-system-tunnel + +# 재시작 +docker restart sr-tunnel +``` + +### Spring Boot 컨테이너 Crash + +```bash +# 로그 확인 +docker logs sr-backend --tail 50 + +# DB 연결 테스트 +docker exec sr-postgres pg_isready -U sr_admin -d sr_system + +# 환경변수 확인 +docker exec sr-backend env | grep -E "DB_|JWT_|SPRING_" +``` + +### Cloudflare Pages 502 Error + +- Cloudflare Tunnel이 실행 중인지 확인 +- `api.sr.example.com` DNS가 Tunnel UUID를 가리키는지 확인 +- Spring Boot가 정상 기동했는지 확인 (`http://localhost:8080/actuator/health`) +- Cloudflare Dashboard → SSL/TLS → **Full (Strict)** 확인 diff --git a/docs/feature-implementation-plan.md b/docs/feature-implementation-plan.md new file mode 100644 index 0000000..c8b14b8 --- /dev/null +++ b/docs/feature-implementation-plan.md @@ -0,0 +1,739 @@ +# SR System — Feature Implementation Plan + +> Analysis date: 2026-05-26 +> Scope: Calendar, Notifications, User Profile, ADMIN/MANAGER Management + +--- + +## Executive Summary + +| Feature | Existing Backend | New Backend | Frontend | Priority | Effort | +|---------|:---:|:---:|:---:|:---:|:---:| +| Calendar | **None** | Calendar aggregation endpoint | CalendarPage + CalendarWidget | P2 | 5d | +| Notifications | **Full** (read, mark-read, unread count) | Add `link_url` field to Notification entity | NotificationDropdown | P1 | 2d | +| User Profile | **Partial** (getMe, portfolio, dispatch-history) | ChangePassword endpoint + Extended EmployeeSummary | ProfilePage | P1 | 4d | +| ADMIN/MANAGER | **Partial** (employee CRUD, report listing) | Dispatch CRUD, CommonCode CRUD, Report summary aggregations | EmployeeEditModal, DispatchManager, ReportDashboard, SettingsPage | P1 (Reports), P2 (Dispatch), P2 (Settings) | 8d total | + +**Total estimated effort: 19 days** (full-time, single dev). Parallel work possible — Admin features and Profile can be developed concurrently with Calendar and Notifications. + +--- + +## 1. Calendar Feature + +### 1.1 Current State Analysis + +| Aspect | Status | +|--------|--------| +| Header Calendar icon | Route: `/issues` (no calendar relevance) | +| Backend calendar endpoint | **Does not exist** | +| Data with dates | `Ticket.dueDate`, `Report.targetDate`, `Project.startDate/endDate`, `DispatchHistory.startDate/endDate` | +| Frontend calendar page | **Does not exist** | + +The Header component (line 65): +```tsx + +``` + +### 1.2 What's Missing + +1. **Backend: Calendar aggregation endpoint** — No single endpoint to fetch events across domains. Need: + - `GET /api/v1/calendar?start=YYYY-MM-DD&end=YYYY-MM-DD` — returns unified events + - Events aggregated from: Ticket due dates, Report target dates, Dispatch start/end dates + +2. **Backend: Calendar event entity** — No dedicated schedule/schedule_type entity. Two options: + - **Option A (Recommended):** Aggregation endpoint that queries existing entities (Ticket, Report, DispatchHistory) and returns a unified DTO. Zero schema change. + - **Option B:** New `Schedule` entity with schedule_type (MEETING, DEADLINE, MILESTONE) — more flexible but adds complexity. + +3. **Frontend: Calendar page** (`/app/(main)/calendar/page.tsx`) — Full calendar view with month/week/day modes. + +4. **Frontend: Calendar event types** — Ticket due dates, report deadlines, dispatch milestones. + +### 1.3 Design Proposal + +#### 1.3.1 Backend: Calendar Aggregation Endpoint + +**New DTO: `CalendarEventDto`** +```java +public record CalendarEventDto( + String eventType, // TICKET_DUE, REPORT_DUE, DISPATCH_START, DISPATCH_END + Long sourceId, // ticketId / reportId / dispatchId + String title, + String description, + LocalDate eventDate, + String color, // semantic color per type + String linkUrl // navigation URL (/issues/123, /work-reports/456, etc.) +) {} +``` + +**New Controller Method (in a new `CalendarController` or added to an existing controller):** +```java +@GetMapping("/api/v1/calendar") +@PreAuthorize("hasAnyRole('EMPLOYEE','MANAGER','ADMIN')") +ResponseEntity>> getCalendar( + @RequestParam @DateTimeFormat(iso=ISO.DATE) LocalDate start, + @RequestParam @DateTimeFormat(iso=ISO.DATE) LocalDate end, + @CurrentUser Employee currentUser) +``` + +**New Service: `CalendarService`** +- Queries `TicketRepository.findByDueDateBetweenAndRequester_EmpId(start, end, empId)` (for employee) or all tickets (for manager/admin) +- Queries `ReportRepository.findByTargetDateBetweenAndUser_EmpId(start, end, empId)` or all +- Queries `DispatchHistoryRepository.findByStartDateBetween(start, end)` (only active, DISPATCHED) +- Merges results into `List` + +**Event color mapping:** +| Event Type | Color | Icon | +|-----------|-------|------| +| TICKET_DUE | `#3B82F6` (blue) | Ticket | +| REPORT_DUE | `#10B981` (green) | FileText | +| DISPATCH_START | `#F59E0B` (amber) | Play | +| DISPATCH_END | `#EF4444` (red) | Stop | + +#### 1.3.2 Frontend: Calendar Page + +**Route:** `/calendar` + +**Design:** +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Calendar ◀ 2026.05 ▶ Today │ +├──┬────┬────┬────┬────┬────┬────┐ │ +│S │ M │ T │ W │ T │ F │ S │ │ +├──┼────┼────┼────┼────┼────┼────┤ │ +│ │ │ │ │ │ 1 │ 2 │ │ +│ │ │ │ │ │ ● │ │ │ +│ │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │ │ +│ │ ● │ ● │ │ ■ │ │ │ │ +│ │ 11 │ 12 │ 13 │ 14 │ 15 │ 16 │ │ +│ │ │ ▲ │ ● │ │ │ ■ │ │ +│ │ 18 │ 19 │ 20 │ 21 │ 22 │ 23 │ │ +│ │ │ │ ● │ │ │ │ │ +│ │ 25 │ 26 │ 27 │ 28 │ 29 │ 30 │ │ +│ │ │ │ ■ │ │ │ │ │ +├──┴────┴────┴────┴────┴────┴────┤ │ +│ Legend: ● Report Due ■ Dispatch ▲ Ticket Due │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Components to create:** +- `src/app/(main)/calendar/page.tsx` — Main calendar page +- `src/components/calendar/CalendarGrid.tsx` — Month grid view (no external library — custom CSS Grid, 7-column) +- `src/components/calendar/CalendarEvent.tsx` — Event chip with color dot + title (truncated) +- `src/components/calendar/EventDetailPanel.tsx` — Slide-over or modal showing event details +- `src/components/calendar/CalendarWidget.tsx` — Mini calendar widget for dashboard (optional, P3) + +**Added to sidebar:** +```tsx +{ name: 'Calendar', href: '/calendar', icon: Calendar } +``` + +### 1.4 Implementation Plan — Calendar + +| Step | Task | File(s) | Effort | +|------|------|---------|--------| +| 1 | Create `CalendarEventDto` record | `backend/.../dto/CalendarEventDto.java` | 0.3d | +| 2 | Create `CalendarService` with aggregation logic | `backend/.../service/CalendarService.java` | 0.7d | +| 3 | Create `CalendarController` endpoint | `backend/.../controller/CalendarController.java` | 0.3d | +| 4 | Add `calendarService` to frontend services | `src/services/calendarService.ts` | 0.3d | +| 5 | Add `CalendarEvent` type to frontend types | `src/types/index.ts` | 0.2d | +| 6 | Build `CalendarGrid` component | `src/components/calendar/CalendarGrid.tsx` | 1.5d | +| 7 | Build `CalendarEvent` chip component | `src/components/calendar/CalendarEvent.tsx` | 0.5d | +| 8 | Build `EventDetailPanel` slide-over | `src/components/calendar/EventDetailPanel.tsx` | 0.5d | +| 9 | Build `/calendar` page | `src/app/(main)/calendar/page.tsx` | 0.5d | +| 10 | Update Header Calendar icon to `/calendar` | `src/components/layout/Header.tsx` | 0.1d | +| 11 | Add Calendar to Sidebar | `src/components/layout/Sidebar.tsx` | 0.1d | +| **Total** | | | **5 days** | + +--- + +## 2. Notification System + +### 2.1 Current State Analysis + +| Aspect | Status | +|--------|--------| +| Backend API | ✅ Full: `GET /notifications`, `GET /notifications/unread`, `PUT /notifications/{id}/read` | +| Frontend notificationStore | ✅ Polls unread count (30s interval), fetches list, marks as read | +| Notification entity fields | `notificationId, type, title, message, isRead, createdAt` | +| Bell icon behavior | Calls `fetchNotifications()` on click but **no dropdown/modal renders** | +| Missing | `link_url` field — no way to navigate to related entity from notification | + +### 2.2 What's Missing + +1. **Frontend: NotificationDropdown component** — Bell click should show a dropdown listing recent notifications. +2. **Frontend: Click-to-navigate** — Clicking a notification should navigate to the relevant page (ticket detail, report, etc.). +3. **Backend: `linkUrl` field** — Notification entity has `type` but no link to the source entity. + +### 2.3 Design Proposal + +#### 2.3.1 Backend: Add linkUrl to Notification Entity + +**Migration:** +```sql +ALTER TABLE tb_notification ADD COLUMN link_url VARCHAR(500); +``` + +**Entity update (`Notification.java`):** +```java +@Column(name = "link_url", length = 500) +private String linkUrl; +``` + +**Service update** — When creating notifications, set `linkUrl`: +| Type | linkUrl Example | +|------|----------------| +| TICKET_ASSIGNED | `/issues/123` | +| TICKET_RESOLVED | `/issues/123` | +| REPORT_APPROVED | `/work-reports/456` | +| REPORT_REJECTED | `/work-reports/456` | +| SLA_WARNING | `/issues/789` | +| EQUIPMENT_ASSIGNED | `/itam/123` | + +#### 2.3.2 Frontend: Notification Dropdown + +**Component: `NotificationDropdown.tsx`** +``` +┌─────────────────────────────────┐ +│ Notifications [x] │ ← Header: "Notifications" + close button +├─────────────────────────────────┤ +│ 🔵 Ticket #123 assigned to you │ ← Unread: blue left border +│ 2 minutes ago → │ +├─────────────────────────────────┤ +│ ✅ Report #456 approved │ ← Read: no left border +│ 1 hour ago → │ +├─────────────────────────────────┤ +│ ⚠️ SLA Warning: #789 Overdue │ ← Critical: red left border +│ 3 hours ago → │ +├─────────────────────────────────┤ +│ │ +│ View All ↗ │ ← Link to full notification page +└─────────────────────────────────┘ +``` + +**Behavior:** +- Dropdown slides down from bell icon (absolute positioned) +- Shows latest 10 notifications +- Click notification → `markAsRead(id)` + navigate to `linkUrl` +- Click "View All" → `/notifications` page (optional, P3) +- Click outside → close dropdown +- Escape key → close dropdown + +**Layout:** +- Max height: 400px, overflow-y: auto +- Each item: 12px padding, border-bottom, hover bg-gray-50 +- Width: 360px +- Unread items: `bg-indigo-50/30`, blue dot (6px) left +- Categories: Icon + title (bold 13px) + time ago (gray 11px) + +### 2.4 Implementation Plan — Notifications + +| Step | Task | File(s) | Effort | +|------|------|---------|--------| +| 1 | Add `linkUrl` column to Notification entity | `Notification.java` + migration | 0.3d | +| 2 | Update NotificationListener to set linkUrl | `backend/.../listener/NotificationListener.java` | 0.3d | +| 3 | Add `linkUrl` to frontend Notification type | `src/types/index.ts` | 0.1d | +| 4 | Build `NotificationDropdown` component | `src/components/layout/NotificationDropdown.tsx` | 1.0d | +| 5 | Integrate into Header (replace bell onClick) | `src/components/layout/Header.tsx` | 0.2d | +| 6 | Add mark-all-as-read endpoint (optional) | Backend + service | 0.2d | +| **Total** | | | **2 days** | + +--- + +## 3. User Profile Management + +### 3.1 Current State Analysis + +| Aspect | Status | +|--------|--------| +| `GET /api/v1/employees/me` | ✅ Returns `EmployeeSummary` (empId, name, email, roleCode, dispatchStatus) | +| `GET /api/v1/employees/{empId}/dispatch-histories` | ✅ Returns `DispatchHistory[]` | +| `GET /api/v1/employees/{empId}/portfolio` | ✅ Returns `PortfolioResponse[]` (with reports + resolved tickets) | +| `PUT /api/v1/employees/{empId}` | ✅ Admin-only update endpoint | +| Change password endpoint | **Missing** | +| Extended profile info (phone, empStatus, joinedDate) | **Missing** from `/me` (EmployeeSummary is minimal) | +| `/profile` page | **Does not exist** | + +### 3.2 What's Missing + +1. **Backend: `PUT /api/v1/employees/me/password`** — Change password endpoint for current user. +2. **Backend: Extended `/me` response** — `EmployeeSummary` is too minimal. Need phone, empStatus, joinedDate, dispatchStatus label. +3. **Frontend: `/profile` page** — View/edit profile, change password, view dispatch history, view portfolio. +4. **Frontend: Profile editing** — Allow user to edit their own name, email, phone (or restrict to admin-only for certain fields). + +### 3.3 Design Proposal + +#### 3.3.1 Backend: Change Password Endpoint + +**New DTO: `ChangePasswordRequest`** +```java +public record ChangePasswordRequest( + @NotBlank String currentPassword, + @NotBlank @Size(min=8) String newPassword +) {} +``` + +**New Endpoint in AuthController or EmployeeController:** +```java +@PutMapping("/api/v1/employees/me/password") +@PreAuthorize("hasAnyRole('EMPLOYEE','MANAGER','ADMIN')") +ResponseEntity> changePassword( + @Valid @RequestBody ChangePasswordRequest request, + @CurrentUser Employee currentUser) +``` + +**Logic:** +1. Verify `currentPassword` matches stored hash +2. Encode `newPassword` and save +3. Invalidate existing tokens (optional — add `token_version` field) + +#### 3.3.2 Backend: Extended EmployeeSummary + +**Update `EmployeeSummary` to include:** +```java +public record EmployeeSummary( + Long empId, + String loginId, + String name, + String email, + String phone, // NEW + RoleCode roleCode, + EmpStatus empStatus, // NEW + DispatchStatus dispatchStatus, + LocalDate joinedDate, // NEW + LocalDate leftDate // NEW (nullable) +) {} +``` + +#### 3.3.3 Frontend: Profile Page Design + +**URL:** `/profile` + +**For EMPLOYEE (own profile):** +``` +┌─────────────────────────────────────────────────────┐ +│ [Avatar] 김민수 │ +│ 파견직원 · 재직중 · 파견중 │ +│ │ +│ ┌─ Personal Info ─────────────────────────────────┐ │ +│ │ Login ID: minsu.kim │ │ +│ │ Name: 김민수 │ │ +│ │ Email: minsu@example.com │ │ +│ │ Phone: 010-1234-5678 │ │ +│ │ Joined: 2024-03-01 │ │ +│ │ Status: ACTIVE (DISPATCHED) │ │ +│ │ [Edit Info] [Change Password] │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Dispatch History ──────────────────────────────┐ │ +│ │ Timeline with projects, dates, roles │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Portfolio Summary ─────────────────────────────┐ │ +│ │ Aggregated stats: projects, tickets, reports │ │ +│ └──────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +**For MANAGER/ADMIN viewing another employee:** +- URL: `/profile/[empId]` +- Same layout but with employee selector/dropdown at top +- Manager can view but not edit (unless admin) +- Admin can edit fields + +#### 3.3.4 Components to Create + +| Component | Description | +|-----------|-------------| +| `src/app/(main)/profile/page.tsx` | Own profile view | +| `src/app/(main)/profile/[empId]/page.tsx` | View other employee's profile (MANAGER/ADMIN) | +| `src/components/profile/ProfileCard.tsx` | Personal info card with edit/change-password buttons | +| `src/components/profile/EditProfileModal.tsx` | Modal for editing personal info (admin only or self?) | +| `src/components/profile/ChangePasswordModal.tsx` | Modal with current/new/confirm password fields | +| `src/components/profile/DispatchTimeline.tsx` | Reuse timeline from /dispatch page | +| `src/components/profile/PortfolioStats.tsx` | Summary stats cards (total projects, total tickets resolved, etc.) | + +### 3.4 Implementation Plan — User Profile + +| Step | Task | File(s) | Effort | +|------|------|---------|--------| +| 1 | Create `ChangePasswordRequest` DTO | Backend dto | 0.1d | +| 2 | Add `changePassword` to AuthService | `AuthService.java` | 0.3d | +| 3 | Add `PUT /employees/me/password` endpoint | `AuthController.java` or `EmployeeController.java` | 0.2d | +| 4 | Extend `EmployeeSummary` to include phone, empStatus, joinedDate | `EmployeeSummary.java` + `EmployeeService.toSummary()` | 0.2d | +| 5 | Add `getMeFull()` and `changePassword()` to employeeService | `src/services/employeeService.ts` | 0.2d | +| 6 | Build `ProfileCard` component | `src/components/profile/ProfileCard.tsx` | 0.5d | +| 7 | Build `ChangePasswordModal` component | `src/components/profile/ChangePasswordModal.tsx` | 0.5d | +| 8 | Build `EditProfileModal` component (admin-only for role/status changes; self-service for email/phone) | `src/components/profile/EditProfileModal.tsx` | 0.5d | +| 9 | Build `PortfolioStats` component | `src/components/profile/PortfolioStats.tsx` | 0.3d | +| 10 | Build `/profile` page | `src/app/(main)/profile/page.tsx` | 0.8d | +| 11 | Build `/profile/[empId]` page (manager/admin view) | `src/app/(main)/profile/[empId]/page.tsx` | 0.4d | +| 12 | Make user avatar/name in Header clickable → `/profile` | `src/components/layout/Header.tsx` | 0.1d | +| **Total** | | | **4 days** | + +--- + +## 4. ADMIN/MANAGER Management Features + +### 4.1 Current State Analysis + +#### 4.1.1 Employee CRUD + +| Operation | Backend | Frontend | +|-----------|:---:|:---:| +| List/View | ✅ `GET /employees` (MANAGER/ADMIN) | ✅ `/employees` page with table view | +| Create | ✅ `POST /employees` (ADMIN) | ✅ Modal in `/employees` page | +| Update | ✅ `PUT /employees/{id}` (ADMIN) | ❌ Only `MoreHorizontal` button with no handler | +| Delete | ✅ `DELETE /employees/{id}` (ADMIN) | ❌ No delete UI | +| Search | ✅ `GET /employees/search` | ✅ Search bar in `/employees` | + +**Gap:** Edit and delete functionality missing from frontend. The `MoreHorizontal` button (line 454-456 in employees/page.tsx) is a blank ` +``` + +#### 4.2.2 Dispatch Management + +**Backend needs:** + +**New Controller: `DispatchHistoryController.java`** +```java +@PostMapping("/api/v1/dispatch-histories") +@PreAuthorize("hasAnyRole('MANAGER','ADMIN')") +ResponseEntity> create(CreateDispatchRequest request) + +@PutMapping("/api/v1/dispatch-histories/{dispatchId}") +@PreAuthorize("hasAnyRole('MANAGER','ADMIN')") +ResponseEntity> update(Long dispatchId, UpdateDispatchRequest request) + +@GetMapping("/api/v1/dispatch-histories") +@PreAuthorize("hasAnyRole('MANAGER','ADMIN')") +ResponseEntity>> findAll(Pageable pageable, @RequestParam(required=false) Long empId, @RequestParam(required=false) Long projectId) +``` + +**New DTOs:** +```java +public record CreateDispatchRequest( + @NotNull Long empId, + @NotNull Long projectId, + @NotNull LocalDate startDate, + LocalDate endDate, + @NotBlank String roleTitle, + @NotNull Integer contributionRate, + @NotBlank String individualTech, + @NotBlank String performedTasks +) {} +``` + +**Frontend: Dispatch Management Page** + +**URL:** `/dispatch/manage` (MANAGER/ADMIN only) + +``` +┌─────────────────────────────────────────────────────┐ +│ Dispatch Management [+ New Dispatch] │ +├─────────┬──────────┬─────────┬─────────┬────────────┤ +│ Employee│ Project │ Role │ Period │ Actions │ +├─────────┼──────────┼─────────┼─────────┼────────────┤ +│ 김민수 │ Samsung │ Backend │ 24.03~ │ [Edit][Del]│ +│ 이영희 │ LG CNS │ Frontend│ 24.06~ │ [Edit][Del]│ +│ ... │ ... │ ... │ ... │ ... │ +└─────────┴──────────┴─────────┴─────────┴────────────┘ +``` + +**Create Dispatch modal:** +- Employee selector (autocomplete search) +- Project selector (dropdown from `/projects/my` or all projects) +- Date range picker +- Role title, contribution rate, tech stack, tasks + +#### 4.2.3 Report Monitoring Dashboard + +**URL:** `/work-reports/review` (MANAGER/ADMIN only) + +``` +┌─────────────────────────────────────────────────────┐ +│ Report Review Dashboard │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │Submitted │ │Pending │ │Approved │ │Rejected │ │ +│ │ 12 │ │ 5 │ │ 45 │ │ 3 │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +├─────────────────────────────────────────────────────┤ +│ [Filter: SUBMITTED ▼] [Date: 2026-05 ▼] [Search...]│ +├─────────┬─────────┬──────────┬─────────┬────────────┤ +│ Employee│ Type │ Date │ Status │ Action │ +├─────────┼─────────┼──────────┼─────────┼────────────┤ +│ 김민수 │ DAILY │ 05-26 │ SUBMIT │[✅][❌][👁] │ +│ 이영희 │ WEEKLY │ 05-20 │ SUBMIT │[✅][❌][👁] │ +│ 박철수 │ MONTHLY │ 05-01 │ SUBMIT │[✅][❌][👁] │ +└─────────┴─────────┴──────────┴─────────┴────────────┘ +``` + +**Action buttons:** +- ✅ Approve → `PUT /reports/{id}/review { status: "APPROVED" }` +- ❌ Reject → Opens rejection modal with required feedback text +- 👁 View → Navigate to report detail + +**New Backend Endpoint (optional):** +```java +@GetMapping("/api/v1/reports/summary") +@PreAuthorize("hasAnyRole('MANAGER','ADMIN')") +ResponseEntity> getSummary() +// Returns counts by status: { submitted, approved, rejected, draft } +``` + +#### 4.2.4 System Settings (Common Codes) + +**Backend needs:** + +**New endpoints in `CommonCodeController.java`:** +```java +@PostMapping +@PreAuthorize("hasRole('ADMIN')") +ResponseEntity> create(CommonCode code) + +@PutMapping("/{codeId}") +@PreAuthorize("hasRole('ADMIN')") +ResponseEntity> update(Long codeId, CommonCode code) + +@DeleteMapping("/{codeId}") +@PreAuthorize("hasRole('ADMIN')") +ResponseEntity> delete(Long codeId) +``` + +**Frontend: Settings Page** + +**URL:** `/settings` (ADMIN only) + +``` +┌─────────────────────────────────────────────────────┐ +│ System Settings │ +│ ┌──────────────────────┬───────────────────────────┐ │ +│ │ Common Codes │ Code Group: [Role ▼] │ │ +│ │ ├ Group List │ ┌──────┬──────┬────┬────┐ │ │ +│ │ │ • Role │ │ Code │ Name │Sort│ │ │ │ +│ │ │ • Report Type │ ├──────┼──────┼────┼────┤ │ │ +│ │ │ • Ticket Category │ │EMPLOY│파견직원│ 1 │[✏️][🗑]│ │ +│ │ │ • Equipment Type │ │MANAG│본사관리│ 2 │[✏️][🗑]│ │ +│ │ │ • ... │ │ADMIN│최고 │ 3 │[✏️][🗑]│ │ +│ │ │ │ └──────┴──────┴────┴────┘ │ │ +│ │ │ │ [+ Add Code] │ │ +│ └──────────────────────┴───────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Other Settings │ │ +│ │ □ Enable email notifications │ │ +│ │ □ Enable Slack notifications │ │ +│ │ Default SLA hours: [48 ▼] │ │ +│ │ [Save] │ │ +│ └──────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +### 4.3 Implementation Plan — ADMIN/MANAGER Features + +#### Phase A: Report Monitoring Dashboard (P1, ~2 days) + +| Step | Task | File(s) | Effort | +|------|------|---------|--------| +| 1 | Add `ReportSummary` DTO + `GET /reports/summary` endpoint | Backend | 0.3d | +| 2 | Add `reportService.getSummary()` to frontend services | `src/services/reportService.ts` | 0.1d | +| 3 | Build report review dashboard page | `src/app/(main)/work-reports/review/page.tsx` | 1.0d | +| 4 | Add approve/reject inline actions | Same as above + modal | 0.4d | +| 5 | Add navigation from dashboard "Pending Reports" card to review page | `src/app/(main)/page.tsx` | 0.1d | +| 6 | Add sidebar link for MANAGER/ADMIN (conditional render) | `src/components/layout/Sidebar.tsx` | 0.1d | + +#### Phase B: Employee Edit/Delete (P1, ~1.5 days, already mostly built on backend) + +| Step | Task | File(s) | Effort | +|------|------|---------|--------| +| 1 | Build `EmployeeEditModal` component | `src/components/employees/EmployeeEditModal.tsx` | 0.5d | +| 2 | Add delete confirmation dialog | Same component | 0.2d | +| 3 | Wire `MoreHorizontal` button to open edit modal | `src/app/(main)/employees/page.tsx` | 0.3d | +| 4 | Refresh list after edit/delete | Same page | 0.2d | +| 5 | Add employee detail view (linking to `/profile/[empId]`) | Same page | 0.3d | + +#### Phase C: Dispatch Management (P2, ~3 days) + +| Step | Task | File(s) | Effort | +|------|------|---------|--------| +| 1 | Create `CreateDispatchRequest` + `UpdateDispatchRequest` DTOs | Backend | 0.2d | +| 2 | Create `DispatchHistoryController` with CRUD endpoints | Backend | 0.5d | +| 3 | Add `dispatchHistoryService.getAll()`, `.create()`, `.update()`, `.delete()` to frontend | `src/services/dispatchHistoryService.ts` (new) | 0.3d | +| 4 | Build dispatch management page | `src/app/(main)/dispatch/manage/page.tsx` | 1.0d | +| 5 | Build create/edit dispatch modal with employee search + project select | Same + modal component | 0.8d | +| 6 | Add sidebar link (MANAGER/ADMIN only) | `src/components/layout/Sidebar.tsx` | 0.1d | + +#### Phase D: System Settings (P2, ~1.5 days) + +| Step | Task | File(s) | Effort | +|------|------|---------|--------| +| 1 | Add POST/PUT/DELETE to CommonCodeController | Backend | 0.3d | +| 2 | Add create/update/delete to `commonCodeService` | `src/services/commonCodeService.ts` (new) | 0.2d | +| 3 | Build settings page with code group list + editor | `src/app/(main)/settings/page.tsx` | 1.0d | +| 4 | Add sidebar link (ADMIN only) | `src/components/layout/Sidebar.tsx` | 0.1d | + +--- + +## 5. Consolidated File List + +### 5.1 Backend — Files to Create + +| File | Feature | Description | +|------|---------|-------------| +| `dto/CalendarEventDto.java` | Calendar | Calendar event aggregation DTO | +| `service/CalendarService.java` | Calendar | Aggregation logic from Ticket, Report, Dispatch | +| `controller/CalendarController.java` | Calendar | `GET /api/v1/calendar` endpoint | +| `dto/ChangePasswordRequest.java` | Profile | Password change request DTO | +| `dto/CreateDispatchRequest.java` | Dispatch | Dispatch creation request DTO | +| `dto/UpdateDispatchRequest.java` | Dispatch | Dispatch update request DTO | +| `controller/DispatchHistoryController.java` | Dispatch | Full CRUD for dispatch histories | +| `service/DispatchManagementService.java` | Dispatch | Management-level dispatch operations | + +### 5.2 Backend — Files to Modify + +| File | Feature | Change | +|------|---------|--------| +| `entity/Notification.java` | Notifications | Add `linkUrl` column | +| `dto/EmployeeSummary.java` | Profile | Add phone, empStatus, joinedDate, leftDate fields | +| `service/AuthService.java` | Profile | Add `changePassword()` method | +| `controller/AuthController.java` or `EmployeeController.java` | Profile | Add `PUT /employees/me/password` endpoint | +| `service/EmployeeService.java` | Profile | Update `toSummary()` to include new fields | +| `controller/ReportController.java` | Reports | Add `GET /reports/summary` endpoint | +| `controller/CommonCodeController.java` | Settings | Add POST, PUT, DELETE endpoints | +| `service/CommonCodeService.java` | Settings | Add create, update, delete methods | + +### 5.3 Frontend — Files to Create + +| File | Feature | Description | +|------|---------|-------------| +| `src/services/calendarService.ts` | Calendar | `GET /calendar?start=&end=` | +| `src/services/dispatchHistoryService.ts` | Dispatch | Full CRUD for dispatch histories | +| `src/services/commonCodeService.ts` | Settings | CRUD for common codes | +| `src/components/calendar/CalendarGrid.tsx` | Calendar | Monthly calendar grid component | +| `src/components/calendar/CalendarEvent.tsx` | Calendar | Event chip component | +| `src/components/calendar/EventDetailPanel.tsx` | Calendar | Event detail modal | +| `src/components/layout/NotificationDropdown.tsx` | Notifications | Bell dropdown component | +| `src/components/profile/ProfileCard.tsx` | Profile | Personal info display/edit card | +| `src/components/profile/ChangePasswordModal.tsx` | Profile | Password change modal | +| `src/components/profile/EditProfileModal.tsx` | Profile | Profile edit modal | +| `src/components/profile/PortfolioStats.tsx` | Profile | Portfolio summary stats | +| `src/components/employees/EmployeeEditModal.tsx` | Admin | Employee edit + delete modal | +| `src/components/dispatch/DispatchFormModal.tsx` | Admin | Create/edit dispatch form | +| `src/app/(main)/calendar/page.tsx` | Calendar | Calendar page | +| `src/app/(main)/profile/page.tsx` | Profile | Own profile page | +| `src/app/(main)/profile/[empId]/page.tsx` | Profile | Employee profile (manager view) | +| `src/app/(main)/work-reports/review/page.tsx` | Reports | Report review dashboard | +| `src/app/(main)/dispatch/manage/page.tsx` | Dispatch | Dispatch management page | +| `src/app/(main)/settings/page.tsx` | Settings | System settings page | + +### 5.4 Frontend — Files to Modify + +| File | Feature | Change | +|------|---------|--------| +| `src/types/index.ts` | All | Add `Notification.linkUrl`, `CalendarEvent`, `ReportSummary`, `CalendarEvent` types | +| `src/services/index.ts` | All | Export new services | +| `src/services/employeeService.ts` | Profile | Add `getMeFull()`, `changePassword()` | +| `src/services/reportService.ts` | Reports | Add `getSummary()` | +| `src/components/layout/Header.tsx` | Calendar + Profile + Notifications | Calendar → `/calendar`, integrate NotificationDropdown, avatar → `/profile` | +| `src/components/layout/Sidebar.tsx` | All | Add Calendar, Report Review, Settings links (with RBAC) | +| `src/app/(main)/page.tsx` | Reports | Link "Pending Reports" card to `/work-reports/review` | +| `src/app/(main)/employees/page.tsx` | Admin | Wire MoreHorizontal to edit modal | + +--- + +## 6. Recommended Implementation Order + +``` +Week 1: Quick Wins + Day 1-2: Notifications (Dropdown UI + linkUrl) ← P1, ~2d + Day 3-5: User Profile (ProfilePage + ChangePassword) ← P1, ~4d + +Week 2: Calendar + Report Management + Day 1-3: Employee Edit/Delete (EmployeeEditModal) ← P1, ~1.5d + Day 3-5: Report Monitoring Dashboard ← P1, ~2d + +Week 3: Calendar + Dispatch + Day 1-5: Calendar (Backend aggregation + Full Calendar page) ← P2, ~5d + +Week 4: Admin Features + Day 1-3: Dispatch Management (CRUD endpoints + page) ← P2, ~3d + Day 4-5: System Settings (CommonCode CRUD + Settings page) ← P2, ~1.5d +``` + +**Alternative — parallel by developer:** +- Dev A: Calendar (5d) + Settings (1.5d) = 6.5d +- Dev B: Notifications (2d) + Profile (4d) = 6d +- Dev C: Employee Edit (1.5d) + Report Dashboard (2d) + Dispatch (3d) = 6.5d +- **Total calendar time: ~1.5 weeks with 3 developers** + +--- + +## 7. Risk Assessment + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Calendar aggregation performance with large datasets | Medium | Add DB indexes on `due_date`, `target_date`; paginate results | +| Notification polling load | Low | Already 30s interval — acceptable. Could go to 60s after 30min idle | +| Password change breaks existing sessions | Medium | Option A: Invalidate all tokens on password change. Option B: Add `tokenVersion` to user and check on JWT validation | +| DispatchHistory creation without project exists validation | Medium | Backend must validate project exists and is not completed/hold | +| Common code changes break existing dropdowns | High | Never hard-delete common codes — add `is_active` toggle instead. Frontend caches codes on load | +| Concurrent employee edits by multiple admins | Low | Add `@Version` optimistic lock to Employee entity | + +--- + +## 8. Quick Wins (Low Effort, High Impact) + +If you only have 1 day, do these: + +1. **Notification Dropdown** (~2h) — Make the bell icon functional. Just build the dropdown UI, no backend changes needed. Notifications already have type/title/message. +2. **Header Calendar fix** (~5min) — Change `onClick={() => router.push('/issues')}` to `onClick={() => router.push('/calendar')}` even before building the calendar page — at least the button has a logical destination. +3. **Header avatar → /profile** (~5min) — Wrap the avatar/name in a Link to `/profile`. +4. **Employee MoreHorizontal → Edit** (~2h) — Wire the existing `PUT /employees/{id}` backend to a simple edit form. diff --git a/docs/references/skills-catalog.md b/docs/references/skills-catalog.md new file mode 100644 index 0000000..db4e064 --- /dev/null +++ b/docs/references/skills-catalog.md @@ -0,0 +1,676 @@ +# Antigravity Skill Bundles + +> **Curated collections of skills organized by role and expertise level.** Don't know where to start? Pick a bundle below to get a curated set of skills for your role. + +> These packs are curated starter recommendations for humans. Generated bundle ids in `data/bundles.json` are broader catalog/workflow groupings and do not need to map 1:1 to the editorial packs below. + +> **Important:** bundles are installable plugin subsets and activation presets, not invokable mega-skills such as `@web-wizard` or `/essentials-bundle`. Use the individual skills listed in the pack, install the bundle as a dedicated marketplace plugin, or use the activation scripts if you want only that bundle's skills active in your live Antigravity directory. + +> **Plugin compatibility:** root plugins and bundle plugins only publish plugin-safe skills. If a bundle shows `pending hardening`, the skills still exist in the repository, but that bundle is not yet published for that target. `Requires manual setup` means the bundle is installable, but one or more included skills need an explicit setup step before first use. + +## Quick Start + +1. **Install the repository or bundle plugin:** + + ```bash + npx antigravity-awesome-skills + # or clone manually + git clone https://github.com/sickn33/antigravity-awesome-skills.git .agent/skills + ``` + +2. **Choose your bundle** from the list below based on your role or interests. + +3. **Use bundle plugins or individual skills** in your AI assistant: + - Claude Code: install the matching marketplace bundle plugin, or invoke `>> /skill-name help me...` + - Codex CLI / Codex app: install the matching bundle plugin where plugin marketplaces are available, or invoke `Use skill-name...` + - Cursor: `@skill-name` in chat + - Gemini CLI: `Use skill-name...` + +If you want a bundle to behave like a focused active subset instead of a full install, use: + +- macOS/Linux: `./scripts/activate-skills.sh --clear Essentials` +- macOS/Linux: `./scripts/activate-skills.sh --clear "Web Wizard"` +- Windows: `.\scripts\activate-skills.bat --clear Essentials` + +--- + +## Essentials & Core + +### 🚀 The "Essentials" Starter Pack + +_For everyone. Install these first._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`concise-planning`](../../skills/concise-planning/): Always start with a plan. +- [`lint-and-validate`](../../skills/lint-and-validate/): Keep your code clean automatically. +- [`git-pushing`](../../skills/git-pushing/): Save your work safely. +- [`kaizen`](../../skills/kaizen/): Continuous improvement mindset. +- [`systematic-debugging`](../../skills/systematic-debugging/): Debug like a pro. + + +--- + +## Security & Compliance + +### 🛡️ The "Security Engineer" Pack + +_For pentesting, auditing, and hardening._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`ethical-hacking-methodology`](../../skills/ethical-hacking-methodology/): The Bible of ethical hacking. +- [`burp-suite-testing`](../../skills/burp-suite-testing/): Web vulnerability scanning. +- [`top-web-vulnerabilities`](../../skills/top-web-vulnerabilities/): OWASP-aligned vulnerability taxonomy. +- [`linux-privilege-escalation`](../../skills/linux-privilege-escalation/): Advanced Linux security assessment. +- [`cloud-penetration-testing`](../../skills/cloud-penetration-testing/): AWS/Azure/GCP security. +- [`security-auditor`](../../skills/security-auditor/): Comprehensive security audits. +- [`vulnerability-scanner`](../../skills/vulnerability-scanner/): Advanced vulnerability analysis. + +### 🔐 The "Security Developer" Pack + +_For building secure applications._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`api-security-best-practices`](../../skills/api-security-best-practices/): Secure API design patterns. +- [`auth-implementation-patterns`](../../skills/auth-implementation-patterns/): JWT, OAuth2, session management. +- [`backend-security-coder`](../../skills/backend-security-coder/): Secure backend coding practices. +- [`frontend-security-coder`](../../skills/frontend-security-coder/): XSS prevention and client-side security. +- [`cc-skill-security-review`](../../skills/cc-skill-security-review/): Security checklist for features. +- [`pci-compliance`](../../skills/pci-compliance/): Payment card security standards. + + +--- + +## 🌐 Web Development + +### 🌐 The "Web Wizard" Pack + +_For building modern, high-performance web apps._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`frontend-design`](../../skills/frontend-design/): UI guidelines and aesthetics. +- [`react-best-practices`](../../skills/react-best-practices/): React & Next.js performance optimization. +- [`react-patterns`](../../skills/react-patterns/): Modern React patterns and principles. +- [`nextjs-best-practices`](../../skills/nextjs-best-practices/): Next.js App Router patterns. +- [`tailwind-patterns`](../../skills/tailwind-patterns/): Tailwind CSS v4 styling superpowers. +- [`form-cro`](../../skills/form-cro/): Optimize your forms for conversion. +- [`seo-audit`](../../skills/seo-audit/): Get found on Google. + +### 🖌️ The "Web Designer" Pack + +_For pixel-perfect experiences._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`ui-ux-pro-max`](../../skills/ui-ux-pro-max/): Premium design systems and tokens. +- [`frontend-design`](../../skills/frontend-design/): The base layer of aesthetics. +- [`3d-web-experience`](../../skills/3d-web-experience/): Three.js & React Three Fiber magic. +- [`canvas-design`](../../skills/canvas-design/): Static visuals and posters. +- [`mobile-design`](../../skills/mobile-design/): Mobile-first design principles. +- [`scroll-experience`](../../skills/scroll-experience/): Immersive scroll-driven experiences. + +### ⚡ The "Full-Stack Developer" Pack + +_For end-to-end web application development._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`senior-fullstack`](../../skills/senior-fullstack/): Complete fullstack development guide. +- [`frontend-developer`](../../skills/frontend-developer/): React 19+ and Next.js 15+ expertise. +- [`backend-dev-guidelines`](../../skills/backend-dev-guidelines/): Node.js/Express/TypeScript patterns. +- [`api-patterns`](../../skills/api-patterns/): REST vs GraphQL vs tRPC selection. +- [`database-design`](../../skills/database-design/): Schema design and ORM selection. +- [`stripe-integration`](../../skills/stripe-integration/): Payments and subscriptions. + + +--- + +## 🤖 AI & Agents + +### 🤖 The "Agent Architect" Pack + +_For building AI systems and autonomous agents._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`agent-evaluation`](../../skills/agent-evaluation/): Test and benchmark your agents. +- [`langgraph`](../../skills/langgraph/): Build stateful agent workflows. +- [`mcp-builder`](../../skills/mcp-builder/): Create your own MCP tools. +- [`prompt-engineering`](../../skills/prompt-engineering/): Master the art of talking to LLMs. +- [`ai-agents-architect`](../../skills/ai-agents-architect/): Design autonomous AI agents. +- [`rag-engineer`](../../skills/rag-engineer/): Build RAG systems with vector search. + +### 🧠 The "LLM Application Developer" Pack + +_For building production LLM applications._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`llm-app-patterns`](../../skills/llm-app-patterns/): Production-ready LLM patterns. +- [`rag-implementation`](../../skills/rag-implementation/): Retrieval-Augmented Generation. +- [`prompt-caching`](../../skills/prompt-caching/): Cache strategies for LLM prompts. +- [`context-window-management`](../../skills/context-window-management/): Manage LLM context efficiently. +- [`langfuse`](../../skills/langfuse/): LLM observability and tracing. + + +--- + +## 🎮 Game Development + +### 🎮 The "Indie Game Dev" Pack + +_For building games with AI assistants._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`game-development/game-design`](../../skills/game-development/game-design/): Mechanics and loops. +- [`game-development/2d-games`](../../skills/game-development/2d-games/): Sprites and physics. +- [`game-development/3d-games`](../../skills/game-development/3d-games/): Models and shaders. +- [`unity-developer`](../../skills/unity-developer/): Unity 6 LTS development. +- [`godot-gdscript-patterns`](../../skills/godot-gdscript-patterns/): Godot 4 GDScript patterns. +- [`algorithmic-art`](../../skills/algorithmic-art/): Generate assets with code. + + +--- + +## 🐍 Backend & Languages + +### 🐍 The "Python Pro" Pack + +_For backend heavyweights and data scientists._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`python-pro`](../../skills/python-pro/): Master Python 3.12+ with modern features. +- [`python-patterns`](../../skills/python-patterns/): Idiomatic Python code. +- [`fastapi-pro`](../../skills/fastapi-pro/): High-performance async APIs. +- [`fastapi-templates`](../../skills/fastapi-templates/): Production-ready FastAPI projects. +- [`django-pro`](../../skills/django-pro/): The battery-included framework. +- [`python-testing-patterns`](../../skills/python-testing-patterns/): Comprehensive testing with pytest. +- [`async-python-patterns`](../../skills/async-python-patterns/): Python asyncio mastery. + +### 🟦 The "TypeScript & JavaScript" Pack + +_For modern web development._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`typescript-expert`](../../skills/typescript-expert/): TypeScript mastery and advanced types. +- [`javascript-pro`](../../skills/javascript-pro/): Modern JavaScript with ES6+. +- [`react-best-practices`](../../skills/react-best-practices/): React performance optimization. +- [`nodejs-best-practices`](../../skills/nodejs-best-practices/): Node.js development principles. +- [`nextjs-app-router-patterns`](../../skills/nextjs-app-router-patterns/): Next.js 14+ App Router. + +### 🦀 The "Systems Programming" Pack + +_For low-level and performance-critical code._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`rust-pro`](../../skills/rust-pro/): Rust 1.75+ with async patterns. +- [`go-concurrency-patterns`](../../skills/go-concurrency-patterns/): Go concurrency mastery. +- [`golang-pro`](../../skills/golang-pro/): Go development expertise. +- [`memory-safety-patterns`](../../skills/memory-safety-patterns/): Memory-safe programming. +- [`cpp-pro`](../../skills/cpp-pro/): Modern C++ development. + + +--- + +## 🦄 Product & Business + +### 🦄 The "Startup Founder" Pack + +_For building products, not just code._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`product-manager-toolkit`](../../skills/product-manager-toolkit/): RICE prioritization, PRD templates. +- [`competitive-landscape`](../../skills/competitive-landscape/): Competitor analysis. +- [`competitor-alternatives`](../../skills/competitor-alternatives/): Create comparison pages. +- [`launch-strategy`](../../skills/launch-strategy/): Product launch planning. +- [`copywriting`](../../skills/copywriting/): Marketing copy that converts. +- [`stripe-integration`](../../skills/stripe-integration/): Get paid from day one. + +### 📊 The "Business Analyst" Pack + +_For data-driven decision making._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`business-analyst`](../../skills/business-analyst/): AI-powered analytics and KPIs. +- [`startup-metrics-framework`](../../skills/startup-metrics-framework/): SaaS metrics and unit economics. +- [`startup-financial-modeling`](../../skills/startup-financial-modeling/): 3-5 year financial projections. +- [`market-sizing-analysis`](../../skills/market-sizing-analysis/): TAM/SAM/SOM calculations. +- [`kpi-dashboard-design`](../../skills/kpi-dashboard-design/): Effective KPI dashboards. + +### 📈 The "Marketing & Growth" Pack + +_For driving user acquisition and retention._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`content-creator`](../../skills/content-creator/): SEO-optimized marketing content. +- [`seo-audit`](../../skills/seo-audit/): Technical SEO health checks. +- [`programmatic-seo`](../../skills/programmatic-seo/): Create pages at scale. +- [`analytics-tracking`](../../skills/analytics-tracking/): Set up GA4/PostHog correctly. +- [`ab-test-setup`](../../skills/ab-test-setup/): Validated learning experiments. +- [`email-sequence`](../../skills/email-sequence/): Automated email campaigns. + + +--- + +## DevOps & Infrastructure + +### 🌧️ The "DevOps & Cloud" Pack + +_For infrastructure and scaling._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`docker-expert`](../../skills/docker-expert/): Master containers and multi-stage builds. +- [`aws-serverless`](../../skills/aws-serverless/): Serverless on AWS (Lambda, DynamoDB). +- [`kubernetes-architect`](../../skills/kubernetes-architect/): K8s architecture and GitOps. +- [`terraform-specialist`](../../skills/terraform-specialist/): Infrastructure as Code mastery. +- [`environment-setup-guide`](../../skills/environment-setup-guide/): Standardization for teams. +- [`deployment-procedures`](../../skills/deployment-procedures/): Safe rollout strategies. +- [`bash-linux`](../../skills/bash-linux/): Terminal wizardry. + +### 📊 The "Observability & Monitoring" Pack + +_For production reliability._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`observability-engineer`](../../skills/observability-engineer/): Comprehensive monitoring systems. +- [`distributed-tracing`](../../skills/distributed-tracing/): Track requests across microservices. +- [`slo-implementation`](../../skills/slo-implementation/): Service Level Objectives. +- [`incident-responder`](../../skills/incident-responder/): Rapid incident response. +- [`postmortem-writing`](../../skills/postmortem-writing/): Blameless postmortems. +- [`performance-engineer`](../../skills/performance-engineer/): Application performance optimization. + + +--- + +## 📊 Data & Analytics + +### 📊 The "Data & Analytics" Pack + +_For making sense of the numbers._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`analytics-tracking`](../../skills/analytics-tracking/): Set up GA4/PostHog correctly. +- [`claude-d3js-skill`](../../skills/claude-d3js-skill/): Beautiful custom visualizations with D3.js. +- [`sql-pro`](../../skills/sql-pro/): Modern SQL with cloud-native databases. +- [`postgres-best-practices`](../../skills/postgres-best-practices/): Postgres optimization. +- [`ab-test-setup`](../../skills/ab-test-setup/): Validated learning. +- [`database-architect`](../../skills/database-architect/): Database design from scratch. + +### 🔄 The "Data Engineering" Pack + +_For building data pipelines._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`data-engineer`](../../skills/data-engineer/): Data pipeline architecture. +- [`airflow-dag-patterns`](../../skills/airflow-dag-patterns/): Apache Airflow DAGs. +- [`dbt-transformation-patterns`](../../skills/dbt-transformation-patterns/): Analytics engineering. +- [`vector-database-engineer`](../../skills/vector-database-engineer/): Vector databases for RAG. +- [`embedding-strategies`](../../skills/embedding-strategies/): Embedding model selection. + + +--- + +## 🎨 Creative & Content + +### 🎨 The "Creative Director" Pack + +_For visuals, content, and branding._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`canvas-design`](../../skills/canvas-design/): Generate posters and diagrams. +- [`frontend-design`](../../skills/frontend-design/): UI aesthetics. +- [`content-creator`](../../skills/content-creator/): SEO-optimized blog posts. +- [`copy-editing`](../../skills/copy-editing/): Polish your prose. +- [`algorithmic-art`](../../skills/algorithmic-art/): Code-generated masterpieces. +- [`interactive-portfolio`](../../skills/interactive-portfolio/): Portfolios that land jobs. + + +--- + +## 🐞 Quality Assurance + +### 🐞 The "QA & Testing" Pack + +_For breaking things before users do._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`test-driven-development`](../../skills/test-driven-development/): Red, Green, Refactor. +- [`systematic-debugging`](../../skills/systematic-debugging/): Debug like Sherlock Holmes. +- [`browser-automation`](../../skills/browser-automation/): End-to-end testing with Playwright. +- [`e2e-testing-patterns`](../../skills/e2e-testing-patterns/): Reliable E2E test suites. +- [`ab-test-setup`](../../skills/ab-test-setup/): Validated experiments. +- [`code-review-checklist`](../../skills/code-review-checklist/): Catch bugs in PRs. +- [`test-fixing`](../../skills/test-fixing/): Fix failing tests systematically. + + +--- + +## 🔧 Specialized Packs + +### 📱 The "Mobile Developer" Pack + +_For iOS, Android, and cross-platform apps._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`mobile-developer`](../../skills/mobile-developer/): Cross-platform mobile development. +- [`react-native-architecture`](../../skills/react-native-architecture/): React Native with Expo. +- [`flutter-expert`](../../skills/flutter-expert/): Flutter multi-platform apps. +- [`ios-developer`](../../skills/ios-developer/): iOS development with Swift. +- [`app-store-optimization`](../../skills/app-store-optimization/): ASO for App Store and Play Store. + +### 🔗 The "Integration & APIs" Pack + +_For connecting services and building integrations._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`stripe-integration`](../../skills/stripe-integration/): Payments and subscriptions. +- [`twilio-communications`](../../skills/twilio-communications/): SMS, voice, WhatsApp. +- [`hubspot-integration`](../../skills/hubspot-integration/): CRM integration. +- [`plaid-fintech`](../../skills/plaid-fintech/): Bank account linking and ACH. +- [`algolia-search`](../../skills/algolia-search/): Search implementation. + +### 🎯 The "Architecture & Design" Pack + +_For system design and technical decisions._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`senior-architect`](../../skills/senior-architect/): Comprehensive software architecture. +- [`architecture-patterns`](../../skills/architecture-patterns/): Clean Architecture, DDD, Hexagonal. +- [`microservices-patterns`](../../skills/microservices-patterns/): Microservices architecture. +- [`event-sourcing-architect`](../../skills/event-sourcing-architect/): Event sourcing and CQRS. +- [`architecture-decision-records`](../../skills/architecture-decision-records/): Document technical decisions. + +### 🧱 The "DDD & Evented Architecture" Pack + +_For teams modeling complex domains and evolving toward evented systems._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`domain-driven-design`](../../skills/domain-driven-design/): Route DDD work from strategic modeling to implementation patterns. +- [`ddd-strategic-design`](../../skills/ddd-strategic-design/): Subdomains, bounded contexts, and ubiquitous language. +- [`ddd-context-mapping`](../../skills/ddd-context-mapping/): Cross-context integration and anti-corruption boundaries. +- [`ddd-tactical-patterns`](../../skills/ddd-tactical-patterns/): Aggregates, value objects, repositories, and domain events. +- [`cqrs-implementation`](../../skills/cqrs-implementation/): Read/write model separation. +- [`event-store-design`](../../skills/event-store-design/): Event persistence and replay architecture. +- [`saga-orchestration`](../../skills/saga-orchestration/): Cross-context long-running transaction coordination. +- [`projection-patterns`](../../skills/projection-patterns/): Materialized read models from event streams. + +### 🤖 The "Automation Builder" Pack + +_For connecting tools and building repeatable automated workflows._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`workflow-automation`](../../skills/workflow-automation/): Design durable automation flows for AI and business systems. +- [`mcp-builder`](../../skills/mcp-builder/): Create tool interfaces agents can use reliably. +- [`make-automation`](../../skills/make-automation/): Build automations in Make/Integromat. +- [`airtable-automation`](../../skills/airtable-automation/): Automate Airtable records, bases, and views. +- [`notion-automation`](../../skills/notion-automation/): Automate Notion pages, databases, and blocks. +- [`slack-automation`](../../skills/slack-automation/): Automate Slack messaging and channel workflows. +- [`googlesheets-automation`](../../skills/googlesheets-automation/): Automate spreadsheet updates and data operations. + +### 💼 The "RevOps & CRM Automation" Pack + +_For revenue operations, support handoffs, and CRM-heavy automation._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`hubspot-automation`](../../skills/hubspot-automation/): Automate contacts, companies, deals, and tickets. +- [`sendgrid-automation`](../../skills/sendgrid-automation/): Automate email sends, contacts, and templates. +- [`zendesk-automation`](../../skills/zendesk-automation/): Automate support tickets and reply workflows. +- [`google-calendar-automation`](../../skills/google-calendar-automation/): Schedule events and manage availability. +- [`outlook-calendar-automation`](../../skills/outlook-calendar-automation/): Automate Outlook meetings and invitations. +- [`stripe-automation`](../../skills/stripe-automation/): Automate billing, invoices, and subscriptions. +- [`shopify-automation`](../../skills/shopify-automation/): Automate products, orders, customers, and inventory. + +### 💳 The "Commerce & Payments" Pack + +_For monetization, payments, and commerce workflows._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`stripe-integration`](../../skills/stripe-integration/): Build robust checkout, subscription, and webhook flows. +- [`paypal-integration`](../../skills/paypal-integration/): Integrate PayPal payments and related flows. +- [`plaid-fintech`](../../skills/plaid-fintech/): Link bank accounts and handle ACH-related use cases. +- [`hubspot-integration`](../../skills/hubspot-integration/): Connect CRM data into product and revenue workflows. +- [`algolia-search`](../../skills/algolia-search/): Add search and discovery to commerce experiences. +- [`monetization`](../../skills/monetization/): Design pricing and monetization systems deliberately. + +### 🏢 The "Odoo ERP" Pack + +_For teams building or operating around Odoo-based business systems._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`odoo-module-developer`](../../skills/odoo-module-developer/): Create custom Odoo modules cleanly. +- [`odoo-orm-expert`](../../skills/odoo-orm-expert/): Work effectively with Odoo ORM patterns and performance. +- [`odoo-sales-crm-expert`](../../skills/odoo-sales-crm-expert/): Optimize sales pipelines, leads, and forecasting. +- [`odoo-ecommerce-configurator`](../../skills/odoo-ecommerce-configurator/): Configure storefront, catalog, and order flows. +- [`odoo-performance-tuner`](../../skills/odoo-performance-tuner/): Diagnose and improve slow Odoo instances. +- [`odoo-security-rules`](../../skills/odoo-security-rules/): Apply secure access controls and rule design. +- [`odoo-docker-deployment`](../../skills/odoo-docker-deployment/): Deploy and run Odoo in Docker-based environments. + +### ☁️ The "Azure AI & Cloud" Pack + +_For building on Azure across cloud, AI, and platform services._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`azd-deployment`](../../skills/azd-deployment/): Ship Azure apps with Azure Developer CLI workflows. +- [`azure-functions`](../../skills/azure-functions/): Build serverless workloads with Azure Functions. +- [`azure-ai-openai-dotnet`](../../skills/azure-ai-openai-dotnet/): Use Azure OpenAI from .NET applications. +- [`azure-search-documents-py`](../../skills/azure-search-documents-py/): Build search, hybrid search, and indexing in Python. +- [`azure-identity-py`](../../skills/azure-identity-py/): Handle Azure authentication flows in Python services. +- [`azure-monitor-opentelemetry-ts`](../../skills/azure-monitor-opentelemetry-ts/): Add telemetry and tracing from TypeScript apps. + +### 📲 The "Expo & React Native" Pack + +_For shipping mobile apps with Expo and React Native._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`react-native-architecture`](../../skills/react-native-architecture/): Structure production React Native apps well. +- [`expo-api-routes`](../../skills/expo-api-routes/): Build API routes in Expo Router and EAS Hosting. +- [`expo-dev-client`](../../skills/expo-dev-client/): Build and distribute Expo development clients. +- [`expo-tailwind-setup`](../../skills/expo-tailwind-setup/): Set up Tailwind and NativeWind in Expo apps. +- [`expo-cicd-workflows`](../../skills/expo-cicd-workflows/): Automate builds and releases with EAS workflows. +- [`expo-deployment`](../../skills/expo-deployment/): Deploy Expo apps and manage release flow. +- [`app-store-optimization`](../../skills/app-store-optimization/): Improve App Store and Play Store discoverability. + +### 🍎 The "Apple Platform Design" Pack + +_For teams designing native-feeling Apple platform experiences._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`hig-foundations`](../../skills/hig-foundations/): Learn the core Apple Human Interface Guidelines. +- [`hig-patterns`](../../skills/hig-patterns/): Apply Apple interaction and UX patterns correctly. +- [`hig-components-layout`](../../skills/hig-components-layout/): Use Apple layout and navigation components well. +- [`hig-inputs`](../../skills/hig-inputs/): Design for gestures, keyboards, Pencil, focus, and controllers. +- [`hig-components-system`](../../skills/hig-components-system/): Work with widgets, live activities, and system surfaces. +- [`hig-platforms`](../../skills/hig-platforms/): Adapt experiences across Apple device families. + +### 🧩 The "Makepad Builder" Pack + +_For building UI-heavy apps with the Makepad ecosystem._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`makepad-basics`](../../skills/makepad-basics/): Start with Makepad fundamentals and mental model. +- [`makepad-layout`](../../skills/makepad-layout/): Handle sizing, flow, alignment, and layout composition. +- [`makepad-widgets`](../../skills/makepad-widgets/): Build interfaces from Makepad widgets. +- [`makepad-event-action`](../../skills/makepad-event-action/): Wire interaction and event handling correctly. +- [`makepad-shaders`](../../skills/makepad-shaders/): Create GPU-driven visual effects and custom drawing. +- [`makepad-deployment`](../../skills/makepad-deployment/): Package and ship Makepad projects. + +### 🔎 The "SEO Specialist" Pack + +_For technical SEO, content structure, and search growth._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`seo-fundamentals`](../../skills/seo-fundamentals/): Build from sound SEO principles and search constraints. +- [`seo-content-planner`](../../skills/seo-content-planner/): Plan clusters, calendars, and content gaps. +- [`seo-content-writer`](../../skills/seo-content-writer/): Produce search-aware content drafts with intent alignment. +- [`seo-structure-architect`](../../skills/seo-structure-architect/): Improve hierarchy, internal links, and structure. +- [`seo-cannibalization-detector`](../../skills/seo-cannibalization-detector/): Find overlapping pages competing for the same intent. +- [`seo-content-auditor`](../../skills/seo-content-auditor/): Audit existing content quality and optimization gaps. +- [`schema-markup`](../../skills/schema-markup/): Add structured data to support richer search results. + +### 📄 The "Documents & Presentations" Pack + +_For document-heavy workflows, spreadsheets, PDFs, and presentations._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`office-productivity`](../../skills/office-productivity/): Coordinate document, spreadsheet, and presentation workflows. +- [`docx-official`](../../skills/docx-official/): Create and edit Word-compatible documents. +- [`pptx-official`](../../skills/pptx-official/): Create and edit PowerPoint-compatible presentations. +- [`xlsx-official`](../../skills/xlsx-official/): Create and analyze spreadsheet files with formulas and formatting. +- [`pdf-official`](../../skills/pdf-official/): Extract, generate, and manipulate PDFs programmatically. +- [`google-slides-automation`](../../skills/google-slides-automation/): Automate presentation updates in Google Slides. +- [`google-sheets-automation`](../../skills/google-sheets-automation/): Automate reads and writes in Google Sheets. + + +--- + +## 🧰 Maintainer & OSS + +### 🛠️ The "OSS Maintainer" Pack + +_For shipping clean changes in public repositories._ + +**Plugin status:** Codex plugin-safe · Claude plugin-safe + +- [`commit`](../../skills/commit/): High-quality conventional commits. +- [`create-pr`](../../skills/create-pr/): PR creation with review-ready context. +- [`requesting-code-review`](../../skills/requesting-code-review/): Ask for targeted, high-signal reviews. +- [`receiving-code-review`](../../skills/receiving-code-review/): Apply feedback with technical rigor. +- [`changelog-automation`](../../skills/changelog-automation/): Keep release notes and changelogs consistent. +- [`git-advanced-workflows`](../../skills/git-advanced-workflows/): Rebase, cherry-pick, bisect, recovery. +- [`documentation-templates`](../../skills/documentation-templates/): Standardize docs and handoffs. + +### 🧱 The "Skill Author" Pack + +_For creating and maintaining high-quality SKILL.md assets._ + +**Plugin status:** Codex pending hardening · Claude pending hardening + +- [`skill-creator`](../../skills/skill-creator/): Design effective new skills. +- [`skill-developer`](../../skills/skill-developer/): Implement triggers, hooks, and skill lifecycle. +- [`writing-skills`](../../skills/writing-skills/): Improve clarity and structure of skill instructions. +- [`documentation-generation-doc-generate`](../../skills/documentation-generation-doc-generate/): Generate maintainable technical docs. +- [`lint-and-validate`](../../skills/lint-and-validate/): Validate quality after edits. +- [`verification-before-completion`](../../skills/verification-before-completion/): Confirm changes before claiming done. + +## 📚 How to Use Bundles + +### 1) Pick by immediate goal + +- Need to ship a feature now: `Essentials` + one domain pack (`Web Wizard`, `Python Pro`, `DevOps & Cloud`). +- Need reliability and hardening: add `QA & Testing` + `Security Developer`. +- Need product growth: add `Startup Founder` or `Marketing & Growth`. + +### 2) Start with 3-5 skills, not 20 + +Pick the minimum set for your current milestone. Expand only when you hit a real gap. + +### 3) Invoke skills consistently + +- **Claude Code**: install a bundle plugin or use `>> /skill-name help me...` +- **Codex CLI**: install a bundle plugin where marketplaces are available, or use `Use skill-name...` +- **Cursor**: `@skill-name` in chat +- **Gemini CLI**: `Use skill-name...` + +### 4) Build your personal shortlist + +Keep a small list of high-frequency skills and reuse it across tasks to reduce context switching. + +## 🧩 Recommended Bundle Combos + +### Ship a SaaS MVP (2 weeks) + +`Essentials` + `Full-Stack Developer` + `QA & Testing` + `Startup Founder` + +### Harden an existing production app + +`Essentials` + `Security Developer` + `DevOps & Cloud` + `Observability & Monitoring` + +### Build an AI product + +`Essentials` + `Agent Architect` + `LLM Application Developer` + `Data Engineering` + +### Grow traffic and conversions + +`Web Wizard` + `Marketing & Growth` + `Data & Analytics` + +### Launch and maintain open source + +`Essentials` + `OSS Maintainer` + `Architecture & Design` + +--- + +## Learning Paths + +### Beginner → Intermediate → Advanced + +**Web Development:** + +1. Start: `Essentials` → `Web Wizard` +2. Grow: `Full-Stack Developer` → `Architecture & Design` +3. Master: `Observability & Monitoring` → `Security Developer` + +**AI/ML:** + +1. Start: `Essentials` → `Agent Architect` +2. Grow: `LLM Application Developer` → `Data Engineering` +3. Master: Advanced RAG and agent orchestration + +**Security:** + +1. Start: `Essentials` → `Security Developer` +2. Grow: `Security Engineer` → Advanced pentesting +3. Master: Red team tactics and threat modeling + +**Open Source Maintenance:** + +1. Start: `Essentials` → `OSS Maintainer` +2. Grow: `Architecture & Design` → `QA & Testing` +3. Master: `Skill Author` + release automation workflows + +--- + +## Contributing + +Found a skill that should be in a bundle? Or want to create a new bundle? [Open an issue](https://github.com/sickn33/antigravity-awesome-skills/issues) or submit a PR! + +--- + +## Related Documentation + +- [Getting Started Guide](getting-started.md) +- [Full Skill Catalog](../../CATALOG.md) +- [Contributing Guide](../../CONTRIBUTING.md) + +--- + +_Last updated: March 2026 | Total Skills: 1,340+ | Total Bundles: 37_ diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/https-proxy.js b/https-proxy.js new file mode 100644 index 0000000..ac5618c --- /dev/null +++ b/https-proxy.js @@ -0,0 +1,47 @@ +const https = require('https'); +const httpProxy = require('http-proxy'); +const fs = require('fs'); + +const certPath = process.argv[2]; +const keyPath = process.argv[3]; +const targetHost = process.argv[4] || 'localhost'; +const targetPort = parseInt(process.argv[5] || '3000', 10); +const PORT = parseInt(process.argv[6] || '3443', 10); +const HOST = process.argv[7] || '0.0.0.0'; + +const proxy = httpProxy.createProxyServer({ + target: { host: targetHost, port: targetPort, protocol: 'http:' }, + ws: true, + changeOrigin: true, + proxyTimeout: 60000, + timeout: 60000, +}); + +proxy.on('error', (err, req, res) => { + if (res && res.writeHead) { + try { res.writeHead(502).end(); } catch {} + } +}); + +const server = https.createServer({ + cert: fs.readFileSync(certPath), + key: fs.readFileSync(keyPath), +}, (req, res) => { + proxy.web(req, res); +}); + +server.on('upgrade', (req, socket, head) => { + proxy.ws(req, socket, head); +}); + +server.listen(PORT, HOST, () => { + const ifaces = require('os').networkInterfaces(); + let ip = 'localhost'; + for (const name of Object.keys(ifaces)) { + for (const iface of ifaces[name]) { + if (iface.family === 'IPv4' && !iface.internal) { ip = iface.address; break; } + } + if (ip !== 'localhost') break; + } + console.log(`HTTPS proxy: https://${ip}:${PORT}`); +}); diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..a1affdb --- /dev/null +++ b/next.config.ts @@ -0,0 +1,30 @@ +import type { NextConfig } from "next"; + +const API_DEST = process.env.API_BACKEND_URL || "http://localhost:8080/api/v1"; + +const nextConfig: NextConfig = { + output: "standalone", + allowedDevOrigins: ["192.168.31.168", "192.168.31.168:3000", "192.168.31.168:3443", "localhost", "127.0.0.1"], + async rewrites() { + return [ + { + source: "/api/v1/:path*", + destination: `${API_DEST}/:path*`, + }, + ]; + }, + async headers() { + return [ + { + source: "/api/v1/:path*", + headers: [ + { key: "Access-Control-Allow-Origin", value: "*" }, + { key: "Access-Control-Allow-Methods", value: "GET,POST,PUT,DELETE,PATCH,OPTIONS" }, + { key: "Access-Control-Allow-Headers", value: "Content-Type, Authorization" }, + ], + }, + ]; + }, +}; + +export default nextConfig; diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..637a617 --- /dev/null +++ b/opencode.json @@ -0,0 +1,136 @@ +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "architect": { + "description": "MID TIER - Software Architect. Requirements analysis, structural design, domain modeling, DB schema, complex debugging", + "mode": "subagent", + "model": "opencode-go/deepseek-v4-pro", + "temperature": 0.2, + "permission": { + "edit": "ask", + "bash": "ask" + }, + "prompt": "You are a Software Architect Agent (MID TIER). Analyze user requirements and produce structural design specifications, domain models, and DB schemas. For complex runtime/dependency errors across multiple modules, provide debugging strategies. Be precise and thorough. For extremely difficult problems, recommend @escalation (MAX)." + }, + "context-manager": { + "description": "MID TIER - Repository Context Agent. Long context codebase analysis, legacy refactoring scope, API doc ingestion", + "mode": "subagent", + "model": "opencode-go/mimo-v2.5-pro", + "temperature": 0.1, + "permission": { + "edit": "deny", + "bash": "allow", + "read": "allow", + "glob": "allow", + "grep": "allow" + }, + "prompt": "You are a Repository Context Agent (MID TIER). Analyze the entire codebase with long context understanding. Identify refactoring scopes, parse large legacy code, and ingest external API documentation. Extract only necessary code snippets for downstream Worker Agents. Do NOT make edits - only analyze and report. For extremely deep analysis, recommend @escalation (MAX)." + }, + "worker": { + "description": "MID TIER - Worker Agent. Unit function implementation, API binding, DTO/Entity boilerplate, unit test generation", + "mode": "subagent", + "model": "opencode-go/qwen3.6-plus", + "temperature": 0.3, + "permission": { + "edit": "allow", + "bash": "allow", + "read": "allow", + "glob": "allow", + "grep": "allow" + }, + "prompt": "You are a Senior Backend Developer Worker Agent (MID TIER). Implement business logic, create unit functions, bind API endpoints, generate DTO/Entity boilerplate, and auto-generate unit tests. Write clean, production-ready code. For simple repetitive work, recommend @worker-lite (LITE). For impossible problems, recommend @escalation (MAX)." + }, + "inspector": { + "description": "MID TIER - Inspector Agent. Compile/lint error fix, output validation, self-correction loop (minimum 3 iterations)", + "mode": "subagent", + "model": "opencode-go/deepseek-v4-flash", + "temperature": 0.1, + "max_steps": 10, + "permission": { + "edit": "allow", + "bash": "allow", + "read": "allow", + "glob": "allow", + "grep": "allow" + }, + "prompt": "You are a QA & Code Inspector Agent (MID TIER). Monitor build output, analyze compile/lint error messages, and immediately fix typos, semicolons, import errors, and syntax issues. Run minimum 3 self-correction loops. NEVER escalate simple errors to higher tiers. Validate output format (JSON/MD parsing). Only escalate to @escalation (MAX) after 3+ failures on the same bug." + }, + "escalation": { + "description": "MAX TIER - Escalation Fallback. Last resort for bugs other agents fail to fix after 3+ attempts (GLM-5.1, 880/hr limit)", + "mode": "subagent", + "model": "opencode-go/glm-5.1", + "temperature": 0.1, + "hidden": true, + "permission": { + "edit": "ask", + "bash": "ask" + }, + "prompt": "You are an Escalation Fallback Agent (MAX TIER). You are invoked ONLY when other agents have failed 3+ times on the same bug. Provide definitive solutions to complex issues. Use sparingly due to strict rate limits." + }, + "architect-lite": { + "description": "LITE TIER - Quick architectural sketches and simple design decisions using low-cost model", + "mode": "subagent", + "model": "opencode-go/deepseek-v4-flash", + "temperature": 0.2, + "hidden": true, + "permission": { + "edit": "ask", + "bash": "ask" + }, + "prompt": "You are a Lite Architect Agent (LITE TIER). Handle simple architecture questions, brief design reviews, and straightforward structural decisions. For complex architecture problems, recommend using @architect (MID) or @escalation (MAX)." + }, + "context-manager-lite": { + "description": "LITE TIER - Fast codebase scan and simple file navigation using low-cost model", + "mode": "subagent", + "model": "opencode-go/deepseek-v4-flash", + "temperature": 0.1, + "hidden": true, + "permission": { + "edit": "deny", + "bash": "allow", + "read": "allow", + "glob": "allow", + "grep": "allow" + }, + "prompt": "You are a Lite Context Agent (LITE TIER). Perform quick file discovery, simple code navigation, and basic pattern searches. For deep codebase analysis or complex refactoring scoping, recommend using @context-manager (MID)." + }, + "worker-lite": { + "description": "LITE TIER - Simple DTO/Entity boilerplate, basic unit tests, repetitive code generation using cheapest model", + "mode": "subagent", + "model": "opencode-go/mimo-v2.5", + "temperature": 0.3, + "hidden": true, + "permission": { + "edit": "allow", + "bash": "allow", + "read": "allow", + "glob": "allow", + "grep": "allow" + }, + "prompt": "You are a Lite Worker Agent (LITE TIER). Generate straightforward DTO/Entity boilerplate, simple CRUD implementations, basic unit tests, and repetitive code patterns. For complex business logic or non-trivial implementations, recommend using @worker (MID)." + }, + "inspector-lite": { + "description": "LITE TIER - Simple syntax checks, import fixes, basic lint corrections using cheapest model", + "mode": "subagent", + "model": "opencode-go/mimo-v2.5", + "temperature": 0.1, + "max_steps": 5, + "hidden": true, + "permission": { + "edit": "allow", + "bash": "allow", + "read": "allow", + "glob": "allow", + "grep": "allow" + }, + "prompt": "You are a Lite Inspector Agent (LITE TIER). Fix simple syntax errors, missing imports, and basic formatting issues quickly. For complex compile errors, runtime bugs, or multi-step debugging, recommend using @inspector (MID)." + } + }, + "instructions": [ + ".opencode/token-efficiency.md", + ".opencode/tier-routing.md", + "docs/01-requirements.md", + "docs/02-architecture.md", + "docs/07-design-system.md" + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8636d49 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,7364 @@ +{ + "name": "sr-system", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sr-system", + "version": "0.1.0", + "dependencies": { + "@fullcalendar/core": "^6.1.20", + "@fullcalendar/daygrid": "^6.1.20", + "@fullcalendar/interaction": "^6.1.20", + "@fullcalendar/react": "^6.1.20", + "axios": "^1.16.1", + "axios-mock-adapter": "^2.1.0", + "html2pdf.js": "^0.14.0", + "html5-qrcode": "^2.3.8", + "http-proxy": "^1.18.1", + "lucide-react": "^1.14.0", + "next": "16.2.6", + "qrcode": "^1.5.4", + "react": "19.2.4", + "react-dom": "19.2.4", + "zustand": "^5.0.13" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/qrcode": "^1.5.6", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.6", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fullcalendar/core": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", + "integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==", + "license": "MIT", + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz", + "integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, + "node_modules/@fullcalendar/interaction": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz", + "integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, + "node_modules/@fullcalendar/react": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.20.tgz", + "integrity": "sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20", + "react": "^16.7.0 || ^17 || ^18 || ^19", + "react-dom": "^16.7.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz", + "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.6.tgz", + "integrity": "sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", + "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz", + "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz", + "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz", + "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz", + "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz", + "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz", + "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz", + "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz", + "integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "postcss": "^8.5.10", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.40", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.40.tgz", + "integrity": "sha512-xxx6M2IpSTnnKcR0cMvIiohkiCx20/oRPtWGbenFygKCGl3zqUzdNjQ/1V4solq1LU+dgv0nQzeGOuqkqZGg0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": 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/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "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/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "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/axios-mock-adapter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-2.1.0.tgz", + "integrity": "sha512-AZUe4OjECGCNNssH8SOdtneiQELsqTsat3SQQCWLPjN436/H+L9AjWfV7bF+Zg/YL9cgbhrz5671hoh+Tbn98w==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "is-buffer": "^2.0.5" + }, + "peerDependencies": { + "axios": ">= 0.17.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "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/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "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/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/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dompurify": { + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz", + "integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "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/electron-to-chromium": { + "version": "1.5.353", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz", + "integrity": "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "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/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.6.tgz", + "integrity": "sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "16.2.6", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "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/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "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/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "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/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "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/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "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.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "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/html2pdf.js": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/html2pdf.js/-/html2pdf.js-0.14.0.tgz", + "integrity": "sha512-yvNJgE/8yru2UeGflkPdjW8YEY+nDH5X7/2WG4uiuSCwYiCp8PZ8EKNiTAa6HxJ1NjC51fZSIEq6xld5CADKBQ==", + "license": "MIT", + "dependencies": { + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0", + "jspdf": "^4.0.0" + } + }, + "node_modules/html5-qrcode": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", + "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==", + "license": "Apache-2.0" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "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/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.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/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", + "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "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/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz", + "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.6", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.6", + "@next/swc-darwin-x64": "16.2.6", + "@next/swc-linux-arm64-gnu": "16.2.6", + "@next/swc-linux-arm64-musl": "16.2.6", + "@next/swc-linux-x64-gnu": "16.2.6", + "@next/swc-linux-x64-musl": "16.2.6", + "@next/swc-win32-arm64-msvc": "16.2.6", + "@next/swc-win32-x64-msvc": "16.2.6", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "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.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "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/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "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/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "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/tinyglobby/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/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "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/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "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/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e03932b --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "sr-system", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev -H 0.0.0.0", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@fullcalendar/core": "^6.1.20", + "@fullcalendar/daygrid": "^6.1.20", + "@fullcalendar/interaction": "^6.1.20", + "@fullcalendar/react": "^6.1.20", + "axios": "^1.16.1", + "axios-mock-adapter": "^2.1.0", + "html2pdf.js": "^0.14.0", + "html5-qrcode": "^2.3.8", + "http-proxy": "^1.18.1", + "lucide-react": "^1.14.0", + "next": "16.2.6", + "qrcode": "^1.5.4", + "react": "19.2.4", + "react-dom": "19.2.4", + "zustand": "^5.0.13" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/qrcode": "^1.5.6", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/public/file.svg b/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/next.svg b/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/window.svg b/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..9143d65 --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,3 @@ +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..9958822 --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -0,0 +1,110 @@ +'use client'; + +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { LogIn, Eye, EyeOff, Shield } from 'lucide-react'; +import { useAuthStore } from '@/stores'; +import type { LoginRequest } from '@/types'; + +export default function LoginPage() { + const router = useRouter(); + const login = useAuthStore((s) => s.login); + const [loginId, setLoginId] = useState(''); + const [password, setPassword] = useState(''); + const [showPw, setShowPw] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!loginId.trim() || !password.trim()) { + setError('아이디와 비밀번호를 입력해주세요.'); + return; + } + setLoading(true); + setError(''); + try { + await login({ loginId, password } as LoginRequest); + router.push('/'); + } catch (err: unknown) { + const msg = (err as { message?: string })?.message || '로그인에 실패했습니다.'; + setError(msg); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+ SR +
+

SR System

+

HQ 헬프데스크 포털

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setLoginId(e.target.value)} + placeholder="아이디를 입력하세요" + className="w-full px-4 py-2.5 rounded-xl border border-border bg-white text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all" + autoComplete="username" + /> +
+ +
+ +
+ setPassword(e.target.value)} + placeholder="비밀번호를 입력하세요" + className="w-full px-4 py-2.5 rounded-xl border border-border bg-white text-text-main text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all pr-10" + autoComplete="current-password" + /> + +
+
+ + +
+ +

+ 문의: HQ IT Support (내선 1234) +

+
+
+ ); +} diff --git a/src/app/(main)/calendar/CalendarClient.tsx b/src/app/(main)/calendar/CalendarClient.tsx new file mode 100644 index 0000000..d9f947e --- /dev/null +++ b/src/app/(main)/calendar/CalendarClient.tsx @@ -0,0 +1,80 @@ +'use client'; + +import React, { useEffect, useRef } from 'react'; +import { Calendar, CalendarOptions } from '@fullcalendar/core'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import interactionPlugin from '@fullcalendar/interaction'; +import { CalendarCheck } from 'lucide-react'; + +export default function CalendarPage() { + const elRef = useRef(null); + const calRef = useRef(null); + + useEffect(() => { + if (!elRef.current || calRef.current) return; + + const fetchEvents = async (start: string, end: string) => { + const res = await fetch(`/api/v1/calendar/events?start=${start}&end=${end}`); + const data = await res.json(); + if (!data.success) return []; + return (data.data || []).map((e: any) => ({ + id: e.id, + title: e.title, + start: e.date, + end: e.endDate || e.date, + backgroundColor: ({ ticket: '#3b82f6', report: '#10b981', dispatch: '#f59e0b' }[e.type as string] || '#6b7280'), + borderColor: ({ ticket: '#3b82f6', report: '#10b981', dispatch: '#f59e0b' }[e.type as string] || '#6b7280'), + textColor: '#fff', + extendedProps: { type: e.type, status: e.status }, + })); + }; + + const cal = new Calendar(elRef.current, { + plugins: [dayGridPlugin, interactionPlugin], + initialView: 'dayGridMonth', + height: 'auto', + locale: 'ko', + headerToolbar: { + left: 'prev,next today', + center: 'title', + right: 'dayGridMonth,dayGridWeek', + }, + datesSet: async (info: any) => { + const start = info.start.toISOString().split('T')[0]; + const end = info.end.toISOString().split('T')[0]; + const events = await fetchEvents(start, end); + cal.removeAllEvents(); + cal.addEventSource(events); + }, + eventClick: (info: any) => { + const { type, status } = info.event.extendedProps; + alert(`[${type?.toUpperCase()}] ${info.event.title}\nStatus: ${status}`); + }, + } as CalendarOptions); + + cal.render(); + calRef.current = cal; + + return () => { + cal.destroy(); + calRef.current = null; + }; + }, []); + + return ( +
+
+
+ +
+

Calendar

+

Unified view of tickets, reports, and dispatch schedules.

+
+
+
+
+
+
+
+ ); +} diff --git a/src/app/(main)/calendar/page.tsx b/src/app/(main)/calendar/page.tsx new file mode 100644 index 0000000..9e19c39 --- /dev/null +++ b/src/app/(main)/calendar/page.tsx @@ -0,0 +1 @@ +export { default } from './CalendarClient'; diff --git a/src/app/(main)/daily-reports/page.tsx b/src/app/(main)/daily-reports/page.tsx new file mode 100644 index 0000000..a65d927 --- /dev/null +++ b/src/app/(main)/daily-reports/page.tsx @@ -0,0 +1,329 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { CalendarCheck, Send, CheckCircle2, ChevronDown, ChevronUp, AlertCircle, FileText, Clock, Eye, PenTool, Ticket } from 'lucide-react'; +import { useAuthStore } from '@/stores'; +import reportService from '@/services/reportService'; +import type { WorkReport } from '@/types'; + +export default function DailyReportsPage() { + const user = useAuthStore((s) => s.user); + const [reports, setReports] = useState([]); + const [todaySubmitted, setTodaySubmitted] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [form, setForm] = useState({ completedTasks: '', plannedTasks: '', issuesAndRisks: '', workHours: '8', blockerFlag: false, artifacts: '' }); + const [submitting, setSubmitting] = useState(false); + const [showForm, setShowForm] = useState(false); + const [selectedReport, setSelectedReport] = useState(null); + const [expanded, setExpanded] = useState>({}); + + // Feature 1: SR Auto-complete + const [srTickets, setSrTickets] = useState([]); + const [showSrPicker, setShowSrPicker] = useState(false); + const [selectedSrIds, setSelectedSrIds] = useState>(new Set()); + const [loadingSr, setLoadingSr] = useState(false); + + // Feature 2: Manager Review Tab + const [pendingReports, setPendingReports] = useState([]); + const [reviewTab, setReviewTab] = useState<'my' | 'pending'>('my'); + + const today = new Date().toISOString().split('T')[0]; + + const fetchReports = () => { + if (!user) return; + setLoading(true); + reportService.getAll({ reportType: 'DAILY', size: 50 }).then((res) => { + const list = res.data.content || []; + setReports(list); + setTodaySubmitted(list.some((r: any) => r.targetDate === today)); + }).catch(() => setError('Failed to load reports')) + .finally(() => setLoading(false)); + }; + + useEffect(() => { fetchReports(); }, [user]); + + // Feature 2: Fetch pending reports for MANAGER/ADMIN + useEffect(() => { + if (!user || user.roleCode === 'EMPLOYEE') return; + reportService.getAll({ status: 'SUBMITTED', reportType: 'DAILY', size: 50 }).then((res) => { + setPendingReports(res.data.content || []); + }).catch(() => {}); + }, [user]); + + // Feature 1: SR Auto-complete functions + const fetchSrTickets = async () => { + setLoadingSr(true); + try { + const res = await fetch(`/api/v1/reports/sr-auto-complete?date=${today}`); + const data = await res.json(); + if (data.success) setSrTickets(data.data); + setShowSrPicker(true); + } catch {} + setLoadingSr(false); + }; + + const appendSrToReport = () => { + const selected = srTickets.filter(t => selectedSrIds.has(t.ticketId)); + if (selected.length === 0) return; + const srText = selected.map(t => `[#${t.ticketId}] ${t.title}${t.resolutionNote ? ` → ${t.resolutionNote}` : ''}`).join('\n'); + setForm(f => ({ ...f, completedTasks: f.completedTasks + (f.completedTasks ? '\n\n[Resolved SR Tickets]\n' : '[Resolved SR Tickets]\n') + srText })); + setShowSrPicker(false); + }; + + // Feature 2: Manager review function + const handleReview = async (reportId: number, status: 'APPROVED' | 'REJECTED') => { + const comment = status === 'REJECTED' ? prompt('Rejection reason:') : ''; + if (status === 'REJECTED' && !comment) return; + try { + await reportService.review(reportId, status, comment || ''); + fetchReports(); // refresh both lists + // Re-fetch pending reports + if (user && user.roleCode !== 'EMPLOYEE') { + reportService.getAll({ status: 'SUBMITTED', reportType: 'DAILY', size: 50 }).then((res) => { + setPendingReports(res.data.content || []); + }).catch(() => {}); + } + } catch { alert('Review failed.'); } + }; + + const handleSubmit = async () => { + if (!form.completedTasks.trim()) return; + setSubmitting(true); + try { + await reportService.create({ + reportType: 'DAILY', + targetDate: today, + completedTasks: form.completedTasks, + plannedTasks: form.plannedTasks, + issuesAndRisks: form.issuesAndRisks, + status: 'SUBMITTED', + workHours: form.workHours ? parseFloat(form.workHours) : null, + blockerFlag: form.blockerFlag ? 'Y' : 'N', + artifacts: form.artifacts ? JSON.stringify([{ type: 'note', description: form.artifacts }]) : '[]', + }); + setForm({ completedTasks: '', plannedTasks: '', issuesAndRisks: '', workHours: '8', blockerFlag: false, artifacts: '' }); + setShowForm(false); + fetchReports(); + } catch { alert('Failed to submit.'); } + setSubmitting(false); + }; + + const todayReport = reports.find((r: any) => r.targetDate === today); + + return ( +
+
+ {/* Header */} +
+
+ +
+

Daily Reports

+

Track your daily work logs

+
+
+ {!showForm && !todaySubmitted && ( + + )} +
+ + {/* Tab switcher for MANAGER/ADMIN */} + {user && user.roleCode !== 'EMPLOYEE' && ( +
+ + +
+ )} + + {error && ( +
+ {error} +
+ )} + + {/* Today's report status / write form */} +
+
+
+ +

+ {todayReport ? "Today's Report" : 'New Daily Report'} +

+
+ {todaySubmitted && ( + + Submitted + + )} +
+ + {showForm && !todaySubmitted ? ( +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ +
+ ); +} diff --git a/src/components/issues/DiagnosticCockpit.tsx b/src/components/issues/DiagnosticCockpit.tsx new file mode 100644 index 0000000..8d4aa8e --- /dev/null +++ b/src/components/issues/DiagnosticCockpit.tsx @@ -0,0 +1,102 @@ +"use client"; + +import React from 'react'; +import { + Activity, + Cpu, + Database, + Wifi, + Terminal, + Filter, + Maximize2 +} from 'lucide-react'; + +/** + * DiagnosticCockpit 컴포넌트 + * 현장의 실시간 시스템 메트릭과 에러 로그를 시각화합니다. + */ +export default function DiagnosticCockpit() { + const metrics = [ + { label: 'CPU Usage', value: 84, color: 'text-orange-500', bg: 'bg-orange-500/10', bar: 'bg-orange-500', icon: Cpu }, + { label: 'Memory', value: 92, color: 'text-red-500', bg: 'bg-red-500/10', bar: 'bg-red-500', icon: Database }, + { label: 'Latency', value: 124, unit: 'ms', color: 'text-yellow-500', bg: 'bg-yellow-500/10', bar: 'bg-yellow-500', icon: Wifi }, + ]; + + const logs = [ + { time: '14:20:12', type: 'ERROR', message: 'java.lang.OutOfMemoryError: Java heap space' }, + { time: '14:20:15', type: 'WARN', message: 'GC overhead limit exceeded' }, + { time: '14:21:03', type: 'INFO', message: 'Connection pool established (32/100)' }, + { time: '14:22:10', type: 'ERROR', message: 'Fail to process transaction [ID: TX-9002]' }, + { time: '14:22:45', type: 'DEBUG', message: 'Snapshot thread started' }, + ]; + + return ( +
+ + {/* Metrics Widgets */} +
+ {metrics.map((m, i) => ( +
+
+
+
+ +
+ {m.label} +
+ + {m.value}{m.unit || '%'} + +
+
+
+
+
+ ))} +
+ + {/* Log Viewer */} +
+
+
+ +

Field Diagnostic Logs

+
+
+ + +
+
+ +
+ {logs.map((log, i) => ( +
+ [{log.time}] + + {log.type} + + {log.message} +
+ ))} +
+
+ _ + Tailing logs from site... +
+
+
+ +
+ ); +} diff --git a/src/components/issues/KanbanBoard.tsx b/src/components/issues/KanbanBoard.tsx new file mode 100644 index 0000000..29aac59 --- /dev/null +++ b/src/components/issues/KanbanBoard.tsx @@ -0,0 +1,110 @@ +'use client'; + +import React from 'react'; +import { AlertCircle, Clock, CheckCircle2, User } from 'lucide-react'; + +export interface TicketCardData { + ticketId: number; + title: string; + projectName?: string; + requesterName?: string; + assigneeName?: string | null; + priority: string; + category: string; + status: string; + dueDate?: string | null; + isImminent?: boolean; +} + +const statusLabel: Record = { + OPEN: { color: 'text-status-open-text', bg: 'bg-status-open-bg', label: 'Open' }, + ASSIGNED: { color: 'text-blue-600', bg: 'bg-blue-50', label: 'Assigned' }, + IN_PROGRESS: { color: 'text-status-pending-text', bg: 'bg-status-pending-bg', label: 'In Progress' }, + RESOLVED: { color: 'text-status-resolved-text', bg: 'bg-status-resolved-bg', label: 'Resolved' }, + CLOSED: { color: 'text-status-closed-text', bg: 'bg-status-closed-bg', label: 'Closed' }, +}; + +const priorityConfig: Record = { + CRITICAL: { color: 'text-status-critical-text', bg: 'bg-status-critical-bg', label: '긴급' }, + HIGH: { color: 'text-status-pending-text', bg: 'bg-status-pending-bg', label: '높음' }, + NORMAL: { color: 'text-status-open-text', bg: 'bg-status-open-bg', label: '보통' }, + LOW: { color: 'text-text-muted', bg: 'bg-gray-100', label: '낮음' }, +}; + +const categoryLabel: Record = { + BUG: '버그', + FEATURE: '기능', + SUPPORT: '기술지원', + QA: 'QA', +}; + +export default function TicketCard({ ticket, onClick }: { ticket: TicketCardData; onClick?: () => void }) { + const pri = priorityConfig[ticket.priority] || priorityConfig.NORMAL; + const stat = statusLabel[ticket.status]; + + return ( +
+
+
+ + {pri.label} + + {stat && ( + + {stat.label} + + )} +
+ + {categoryLabel[ticket.category] || ticket.category} + +
+ +

+ {ticket.title} +

+ + {ticket.projectName && ( +

{ticket.projectName}

+ )} + +
+
+ + {ticket.assigneeName || ticket.requesterName || '-'} +
+ {ticket.dueDate && ( +
+ {ticket.isImminent ? : } + {new Date(ticket.dueDate).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })} +
+ )} +
+
+ ); +} + +export function KanbanColumn({ title, count, color, children }: { title: string; count: number; color: string; children: React.ReactNode }) { + return ( +
+
+
+

{title}

+ {count} +
+
+ {count === 0 ? ( +
+ +

No tickets

+
+ ) : ( + children + )} +
+
+ ); +} diff --git a/src/components/issues/SopRoadmap.tsx b/src/components/issues/SopRoadmap.tsx new file mode 100644 index 0000000..18be5ba --- /dev/null +++ b/src/components/issues/SopRoadmap.tsx @@ -0,0 +1,109 @@ +"use client"; + +import React, { useState } from 'react'; +import { + ClipboardList, + CheckCircle2, + Circle, + ArrowRight, + Lightbulb, + AlertTriangle +} from 'lucide-react'; + +/** + * SopRoadmap 컴포넌트 + * 전문가가 제시하는 단계별 해결 절차와 진행률을 표시합니다. + */ +export default function SopRoadmap() { + const [steps, setSteps] = useState([ + { id: 1, title: 'JVM Heap 설정 확인', status: 'completed', description: '현재 설정된 -Xmx, -Xms 값을 서버 로그에서 확인' }, + { id: 2, title: 'JVM 옵션 상향 조정', status: 'in-progress', description: '기존 4G에서 8G로 변경 후 서비스 재기동' }, + { id: 3, title: '재기동 후 GC 로그 모니터링', status: 'pending', description: 'Full GC 발생 주기 및 처리 시간 확인' }, + { id: 4, title: '최종 안정성 검증', status: 'pending', description: '24시간 동안 메모리 누수 발생 여부 체크' }, + ]); + + const completedCount = steps.filter(s => s.status === 'completed').length; + const progress = Math.round((completedCount / steps.length) * 100); + + return ( +
+ + {/* Progress Card */} +
+
+
+ +

SOP Roadmap

+
+ + {progress}% Done + +
+
+
+
+

{completedCount} of {steps.length} steps completed

+
+ + {/* Step List */} +
+ {steps.map((step, i) => ( +
+
+
+ {step.status === 'completed' ? ( + + ) : step.status === 'in-progress' ? ( +
+
+
+ ) : ( + + )} +
+
+

+ {step.title} +

+ {step.status !== 'completed' && ( +

{step.description}

+ )} + + {step.status === 'in-progress' && ( + + )} +
+
+
+ ))} +
+ + {/* Expert Tips */} +
+
+ + Safety Reminder +
+

+ JVM 설정을 변경할 때는 서비스 가용성에 영향을 줄 수 있으므로, 반드시 **운영 시간 이후(PM 10:00)**에 작업을 진행해 주십시오. +

+
+ +
+ ); +} diff --git a/src/components/itam/AssetActionModal.tsx b/src/components/itam/AssetActionModal.tsx new file mode 100644 index 0000000..94eeca6 --- /dev/null +++ b/src/components/itam/AssetActionModal.tsx @@ -0,0 +1,232 @@ +'use client'; + +import React, { useState } from 'react'; +import { + X, + Plus, + ArrowUpRight, + ArrowDownLeft, + Wrench, + Camera, + Upload, + CheckCircle2, + AlertCircle +} from 'lucide-react'; + +interface AssetActionModalProps { + isOpen: boolean; + onClose: () => void; + asset?: any; +} + +/** + * IT 자산 통합 업무 처리 모달 + * 등록, 반입, 반출, 정비를 한 곳에서 처리합니다. + */ +export default function AssetActionModal({ isOpen, onClose, asset }: AssetActionModalProps) { + const [activeTab, setActiveTab] = useState<'checkout' | 'checkin' | 'maintenance' | 'register'>('checkout'); + + if (!isOpen) return null; + + const tabs = [ + { id: 'checkout', label: '반출 (배정)', icon: ArrowUpRight, color: 'text-orange-500 bg-orange-50' }, + { id: 'checkin', label: '반입 (회수)', icon: ArrowDownLeft, color: 'text-blue-500 bg-blue-50' }, + { id: 'maintenance', label: '정비 (수리)', icon: Wrench, color: 'text-purple-500 bg-purple-50' }, + { id: 'register', label: '신규 등록', icon: Plus, color: 'text-emerald-500 bg-emerald-50' }, + ]; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal Content */} +
+ + {/* Modal Header */} +
+
+

장비 업무 처리

+

자산의 상태 변경 및 이동 이력을 기록합니다.

+
+ +
+ + {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Content Area */} +
+ + {/* Selected Asset Info (Summary) */} + {asset && ( +
+
+
+ +
+
+

{asset.id}

+

{asset.model}

+
+
+
+

현재 상태

+ + {asset.status} + +
+
+ )} + + {/* Form Content Based on Tab */} +
+ {activeTab === 'checkout' && ( + <> +
+ + +
+
+ +
+ +
+
+

김코딩

+

BACKEND · 삼성페이 SI

+
+
+

이현장

+

DBA · LG CNS SM

+
+
+
+
+
+ + +
+
+ + +
+ + )} + + {activeTab === 'checkin' && ( + <> +
+ + +
+
+ + +
+ + )} + + {activeTab === 'maintenance' && ( + <> +
+ + +
+
+ + +
+
+ + +
+ + )} + + {activeTab === 'register' && ( + <> +
+ + +
+
+ + +
+
+ + +
+ + )} + + {/* Simple Photo Record Area (단순 기록용 사진 첨부) */} +
+ +
+
+ +

사진을 드래그하거나 클릭하여 첨부

+
+
+ +
+ 촬영하기 +
+
+
+
+
+
+ + {/* Modal Footer */} +
+ + +
+
+
+ ); +} diff --git a/src/components/itam/AssetInventory.tsx b/src/components/itam/AssetInventory.tsx new file mode 100644 index 0000000..51ebf20 --- /dev/null +++ b/src/components/itam/AssetInventory.tsx @@ -0,0 +1,220 @@ +'use client'; + +import React, { useState } from 'react'; +import { + Laptop, + Monitor, + Smartphone, + Server, + User, + Calendar, + MoreVertical, + ExternalLink, + History, + ArrowUpRight, + ArrowDownLeft, + Wrench, + ChevronRight, + Image as ImageIcon, + X, + QrCode +} from 'lucide-react'; +import { useCommonCodes } from '@/hooks/useCommonCodes'; +import AssetLabel from './AssetLabel'; +import Link from 'next/link'; + +/** + * 자산 인벤토리 리스트 컴포넌트 + * IT 자산의 상세 목록을 출력하며 개별 액션을 제공합니다. + */ +export default function AssetInventory({ equipment, onRefresh, currentPage, totalPages, onPageChange }: { equipment: import('@/types').Equipment[]; onRefresh: () => void; currentPage?: number; totalPages?: number; onPageChange?: (page: number) => void }) { + const [labelAsset, setLabelAsset] = useState(null); + const { codes: equipmentStatusCodes, loading: statusCodesLoading } = useCommonCodes('EQUIPMENT_STATUS'); + + const statusLabel = statusCodesLoading ? {} : Object.fromEntries(equipmentStatusCodes.map((c) => [c.codeValue, c.codeName])); + const getTypeIcon = (type: string) => { + switch (type) { + case 'LAPTOP': return ; + case 'SERVER': return ; + case 'MONITOR': return ; + default: return ; + } + }; + + const getStatusStyle = (status: string) => { + switch (status) { + case 'IN_USE': return 'bg-green-50 text-green-600 border-green-100'; + case 'AVAILABLE': return 'bg-blue-50 text-blue-600 border-blue-100'; + case 'REPAIRING': return 'bg-orange-50 text-orange-600 border-orange-100'; + case 'RETIRED': return 'bg-gray-100 text-gray-500 border-gray-200'; + default: return 'bg-gray-50 text-gray-600 border-gray-100'; + } + }; + + const getStatusDot = (status: string) => { + switch (status) { + case 'IN_USE': return 'bg-green-500'; + case 'AVAILABLE': return 'bg-blue-500'; + case 'REPAIRING': return 'bg-orange-500'; + default: return 'bg-gray-400'; + } + }; + + return ( +
+ {/* Desktop table */} +
+ + + + + + + + + + + + + {equipment.map((asset) => ( + + + + + + + + + ))} + +
PhotoAsset ID & TypeModel & SerialProject / UserStatusContext Actions
+ {(() => { + let imgs: { url: string }[] = []; + if (asset.images) { + try { imgs = typeof asset.images === 'string' ? JSON.parse(asset.images) : asset.images; } catch {} + } + return imgs.length > 0 ? ( +
+ +
+ ) : ( +
+ +
+ ); + })()} +
+
+
+ {getTypeIcon(asset.equipmentType)} +
+
+

{asset.assetCode}

+

{asset.equipmentType}

+
+
+
+

{asset.modelName}

+

{asset.serialNumber}

+
+

{asset.manufacturer}

+

Purchased: {asset.purchaseDate}

+
+ +
+ {statusLabel[asset.status as keyof typeof statusLabel] || asset.status} +
+
+
+ {asset.status === 'AVAILABLE' ? ( + + + + ) : asset.status === 'IN_USE' ? ( + + + + ) : null} + + + + + +
+
+
+ + {/* Mobile cards */} +
+ {equipment.map((asset) => ( +
+
+
+ {(() => { + let imgs: {url:string}[] = []; + try { imgs = typeof asset.images === 'string' ? JSON.parse(asset.images) : (asset.images || []); } catch {} + return imgs.length > 0 ? ( +
+ ) : ( +
{getTypeIcon(asset.equipmentType)}
+ ); + })()} +
+

{asset.modelName}

+

{asset.serialNumber}

+
+
+ + {statusLabel[asset.status as keyof typeof statusLabel] || asset.status} + +
+
+
Code: {asset.assetCode}
+
Type: {asset.equipmentType}
+
Maker: {asset.manufacturer}
+
Date: {asset.purchaseDate}
+
+
+ + {asset.status === 'AVAILABLE' ? ( + Assign + ) : null} +
+
+ ))} +
+ + {/* Pagination - shows on both mobile and desktop */} +
+

Showing {equipment.length} assets

+ {totalPages !== undefined && totalPages > 1 && ( +
+ {Array.from({ length: Math.min(totalPages, 10) }, (_, i) => i).map((p) => ( + + ))} +
+ )} +
+ {labelAsset && ( + setLabelAsset(null)} + /> + )} +
+ ); +} diff --git a/src/components/itam/AssetLabel.tsx b/src/components/itam/AssetLabel.tsx new file mode 100644 index 0000000..aba197b --- /dev/null +++ b/src/components/itam/AssetLabel.tsx @@ -0,0 +1,87 @@ +'use client'; + +import React, { useEffect, useRef } from 'react'; +import QRCode from 'qrcode'; + +interface Props { + assetCode: string; + modelName: string; + serialNumber: string; + manufacturer: string; + purchaseDate: string; + onClose?: () => void; +} + +export default function AssetLabel({ assetCode, modelName, serialNumber, manufacturer, purchaseDate, onClose }: Props) { + const canvasRef = useRef(null); + + useEffect(() => { + if (canvasRef.current) { + QRCode.toCanvas(canvasRef.current, assetCode, { + width: 160, + margin: 1, + color: { dark: '#1e293b', light: '#ffffff' }, + }); + } + }, [assetCode]); + + const handlePrint = async () => { + const w = window.open('', '_blank'); + if (!w) return; + + // Generate QR as data URL + const qrCanvas = document.createElement('canvas'); + qrCanvas.width = 160; + qrCanvas.height = 160; + await QRCode.toCanvas(qrCanvas, assetCode, { width: 160, margin: 1 }); + const qrDataUrl = qrCanvas.toDataURL(); + + w.document.write(` + +
+
SR System
+ QR +
${assetCode}
+
+ Model: ${modelName}
+ S/N: ${serialNumber}
+ Maker: ${manufacturer}
+ Date: ${purchaseDate} +
+
+ + + `); + w.document.close(); + }; + + return ( +
+
e.stopPropagation()}> +
+

SR System · Asset Label

+
+ +

{assetCode}

+
+

Model: {modelName}

+

S/N: {serialNumber}

+

Maker: {manufacturer}

+

Date: {purchaseDate}

+
+
+ + +
+
+
+ ); +} diff --git a/src/components/itam/AssetStatusOverview.tsx b/src/components/itam/AssetStatusOverview.tsx new file mode 100644 index 0000000..f7533a8 --- /dev/null +++ b/src/components/itam/AssetStatusOverview.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Box, CheckCircle2, Wrench, History, TrendingUp } from 'lucide-react'; + +export default function AssetStatusOverview({ + summary, + filter, + onFilterChange, +}: { + summary: Record; + filter: string; + onFilterChange: (f: string) => void; +}) { + const stats = [ + { label: 'Total Assets', value: summary.ALL || 0, icon: Box, color: 'text-primary bg-indigo-50', key: 'ALL' }, + { label: 'In Use', value: summary.IN_USE || 0, icon: CheckCircle2, color: 'text-green-600 bg-green-50', key: 'IN_USE' }, + { label: 'Available', value: summary.AVAILABLE || 0, icon: History, color: 'text-blue-600 bg-blue-50', key: 'AVAILABLE' }, + { label: 'Under Repair', value: summary.REPAIRING || 0, icon: Wrench, color: 'text-orange-600 bg-orange-50', key: 'REPAIRING' }, + ]; + + return ( +
+ {stats.map((stat) => ( + + ))} +
+ ); +} diff --git a/src/components/itam/AssetVoucherCard.tsx b/src/components/itam/AssetVoucherCard.tsx new file mode 100644 index 0000000..ac763ac --- /dev/null +++ b/src/components/itam/AssetVoucherCard.tsx @@ -0,0 +1,138 @@ +'use client'; + +import React from 'react'; +import { + FileText, + ShieldCheck, + Calendar, + User, + MapPin, + ArrowUpRight, + ArrowDownLeft, + PenTool +} from 'lucide-react'; + +interface AssetVoucherCardProps { + type: 'CHECK_OUT' | 'CHECK_IN'; + id: string; + date: string; + project: string; + user: string; + assets: any[]; + signature?: string; +} + +/** + * 디지털 전표(Voucher) UI 컴포넌트 + * 반입/반출 증빙 자료를 시각화합니다. + */ +export default function AssetVoucherCard({ + type, + id, + date, + project, + user, + assets, + signature +}: AssetVoucherCardProps) { + const isCheckout = type === 'CHECK_OUT'; + + return ( +
+ {/* Voucher Header */} +
+
+
+ +
+
+

Digital Voucher

+

{id}

+
+
+
+
+ {isCheckout ? 'CHECK-OUT (반출/배정)' : 'CHECK-IN (반입/회수)'} +
+ + + {date} + +
+
+ + {/* Voucher Body */} +
+ {/* Info Grid */} +
+
+

대상 프로젝트

+

{project}

+
+
+

책임 사용자

+

{user}

+
+
+ + {/* Asset List */} +
+

대상 자산 목록 ({assets.length}건)

+
+ {assets.map((asset, i) => ( +
+
+
+ {isCheckout ? : } +
+
+

{asset.model}

+

{asset.sn}

+
+
+
+ + {asset.status || 'Normal'} + +
+
+ ))} +
+
+ + {/* Declaration & Signature */} +
+
+
+ + 인수/반납 확인 완료 +
+

+ 본 전표에 기재된 장비의 {isCheckout ? '인수' : '반납'}를 확인하며, {isCheckout ? '파손 및 분실 시 회사 규정에 따른 책임' : '재고 복귀'}를 승인합니다. +

+
+
+
+ {signature ? ( + {signature} + ) : ( + + )} +
Signature
+
+

{user}

+
+
+
+ + {/* Footer Branding */} +
+

SR-SYSTEM ITAM INFRA

+
+ + +
+
+
+ ); +} diff --git a/src/components/itam/BarcodeScanner.tsx b/src/components/itam/BarcodeScanner.tsx new file mode 100644 index 0000000..38de013 --- /dev/null +++ b/src/components/itam/BarcodeScanner.tsx @@ -0,0 +1,145 @@ +'use client'; + +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { Html5Qrcode } from 'html5-qrcode'; +import { X, Camera, AlertCircle, ZoomIn, ZoomOut } from 'lucide-react'; + +interface BarcodeScannerProps { + onScan: (value: string) => void; + onClose: () => void; +} + +export default function BarcodeScanner({ onScan, onClose }: BarcodeScannerProps) { + const containerRef = useRef(null); + const [error, setError] = useState(''); + const [zoom, setZoom] = useState(1); + const [zoomMax, setZoomMax] = useState(3); + const [supportsZoom, setSupportsZoom] = useState(false); + const initializedRef = useRef(false); + const scannerRef = useRef(null); + const trackRef = useRef(null); + + const applyZoom = useCallback(async (level: number, video?: HTMLVideoElement | null) => { + const v = video || (document.querySelector('#sr-barcode-scanner video') as HTMLVideoElement | null); + if (v && v.srcObject) { + if (trackRef.current && supportsZoom) { + try { await trackRef.current.applyConstraints({ advanced: [{ zoom: level }] as any }); } catch { } + } + (v as HTMLElement).style.transform = `scale(${level})`; + } + }, [supportsZoom]); + + useEffect(() => { + // StrictMode guard: skip second effect run + if (initializedRef.current) return; + initializedRef.current = true; + + const el = containerRef.current; + if (!el) return; + + const id = 'sr-barcode-scanner'; + el.id = id; + el.innerHTML = ''; + + let cancelled = false; + const s = new Html5Qrcode(id); + scannerRef.current = s; + + const startWithFacing = async (facingMode: 'environment' | 'user') => { + await s.start( + { facingMode }, + { fps: 30 }, + (decodedText) => { + if (cancelled) return; + try { s.stop(); } catch { } + onScan(decodedText); + }, + () => {} + ); + // Set up zoom after camera starts + setTimeout(() => { + if (cancelled) return; + const video = el.querySelector('video'); + if (video && video.srcObject) { + (video as HTMLElement).style.transition = 'transform 0.2s'; + (video as HTMLElement).style.transformOrigin = 'center center'; + const track = (video.srcObject as MediaStream).getVideoTracks()[0]; + if (track) { + trackRef.current = track; + const caps = track.getCapabilities() as any; + if (caps?.zoom) { setSupportsZoom(true); setZoomMax(caps.zoom.max || 3); } + } + } + }, 300); + }; + + startWithFacing('environment').catch(() => { + return startWithFacing('user'); + }).catch((err: unknown) => { + if (cancelled) return; + const msg = err instanceof Error ? err.message : String(err); + const isIOS = navigator.userAgent.includes('iPhone') || navigator.userAgent.includes('iPad'); + if (msg.includes('NotAllowed') || msg.includes('Permission')) { + setError(isIOS ? 'Camera denied. Settings → Safari → Camera → Allow.' : 'Camera permission denied.'); + } else if (msg.includes('NotFound')) { + setError('No camera found on this device.'); + } else { + setError(msg); + } + }); + + return () => { + cancelled = true; + try { s.stop(); } catch { } + }; + }, []); + + const handleZoomIn = () => { + const newZoom = Math.min(zoom + 0.5, zoomMax); + setZoom(newZoom); + applyZoom(newZoom); + }; + const handleZoomOut = () => { + const newZoom = Math.max(zoom - 0.5, 0.5); + setZoom(newZoom); + applyZoom(newZoom); + }; + + return ( +
+
e.stopPropagation()}> +
+
+ + Scan Barcode +
+ +
+ + {error ? ( +
+
+ +
+

{error}

+ +
+ ) : ( + <> +
+
+
+ + {zoom.toFixed(1)}x + +
+
+

Position the barcode or QR code within the scanning area

+ + )} +
+
+ ); +} diff --git a/src/components/itam/BatchRegistrationView.tsx b/src/components/itam/BatchRegistrationView.tsx new file mode 100644 index 0000000..4483128 --- /dev/null +++ b/src/components/itam/BatchRegistrationView.tsx @@ -0,0 +1,300 @@ +'use client'; + +import React, { useState } from 'react'; +import { + Download, + Upload, + FileSpreadsheet, + AlertTriangle, + CheckCircle2, + RefreshCcw, + Zap +} from 'lucide-react'; +import equipmentService from '@/services/equipmentService'; +import type { Equipment, EquipmentType } from '@/types'; + +interface ParsedItem { + row: number; + data: Partial; + error?: string; +} + +interface ValidationResult { + row: number; + status: 'Error' | 'Warning' | 'Success'; + field: string; + value: string; + message: string; +} + +/** + * 대량 등록 (엑셀) 뷰 컴포넌트 + * 엑셀 업로드 및 데이터 유효성 검증 프로세스를 제공합니다. + */ +export default function BatchRegistrationView() { + const [isUploaded, setIsUploaded] = useState(false); + const [inputText, setInputText] = useState(''); + const [parsedItems, setParsedItems] = useState([]); + const [validationResults, setValidationResults] = useState([]); + const [isProcessing, setIsProcessing] = useState(false); + const [successCount, setSuccessCount] = useState(0); + const [errorCount, setErrorCount] = useState(0); + + const typeMapping: Record = { + 'LAPTOP': 'LAPTOP', + 'MONITOR': 'MONITOR', + 'TEST_DEVICE': 'TEST_DEVICE', + 'ACCESSORY': 'ACCESSORY', + }; + + const parseInput = () => { + const lines = inputText.trim().split('\n'); + const items: ParsedItem[] = []; + const errors: ValidationResult[] = []; + + lines.forEach((line, index) => { + const row = index + 1; + const parts = line.split(',').map(p => p.trim()); + + if (parts.length < 7) { + errors.push({ + row, + status: 'Error', + field: 'Format', + value: line, + message: '필수 항목이 부족합니다 (7 개 항목 필요)', + }); + items.push({ row, data: {}, error: 'Invalid format' }); + return; + } + + const [equipmentType, manufacturer, modelName, serialNumber, assetCode, location, purchaseDate] = parts; + + if (!typeMapping[equipmentType.toUpperCase()]) { + errors.push({ + row, + status: 'Error', + field: 'EquipmentType', + value: equipmentType, + message: '허용되지 않는 장비 유형입니다 (LAPTOP, MONITOR, TEST_DEVICE, ACCESSORY)', + }); + items.push({ row, data: {}, error: 'Invalid type' }); + return; + } + + items.push({ + row, + data: { + equipmentType: typeMapping[equipmentType.toUpperCase()], + manufacturer, + modelName, + serialNumber, + assetCode, + specifications: { location }, + status: 'AVAILABLE', + purchaseDate, + warrantyExpDate: '', + }, + }); + + if (errors.length === 0) { + errors.push({ + row, + status: 'Success', + field: 'All', + value: '-', + message: '정상', + }); + } + }); + + setParsedItems(items); + setValidationResults(errors); + setIsUploaded(true); + }; + + const handleBatchRegister = async () => { + setIsProcessing(true); + setSuccessCount(0); + setErrorCount(0); + + const validItems = parsedItems.filter(item => !item.error); + + for (const item of validItems) { + try { + await equipmentService.create(item.data); + setSuccessCount(prev => prev + 1); + } catch (error: any) { + setErrorCount(prev => prev + 1); + setValidationResults(prev => prev.map(res => + res.row === item.row + ? { ...res, status: 'Error', message: error.response?.data?.message || '등록 실패' } + : res + )); + } + } + + setIsProcessing(false); + }; + + const handleReset = () => { + setIsUploaded(false); + setInputText(''); + setParsedItems([]); + setValidationResults([]); + setSuccessCount(0); + setErrorCount(0); + }; + + return ( +
+ + {/* Step 1: Template & Upload */} +
+ {/* Template Card */} +
+
+
+ +
+

1. 템플릿 양식 다운로드

+

+ 정해진 엑셀 양식에 맞춰 데이터를 작성해야 오류 없이 일괄 등록이 가능합니다. 필수 입력 항목인 [모델명, 자산유형, 시리얼] 을 반드시 포함해 주세요. +

+
+ +
+ + {/* Upload Card - Textarea Input */} +
+
+
+ +
+

2. 데이터 입력

+

+ 각 줄마다 장비를 입력하세요: type,manufacturer,model,serial,assetCode,location,purchaseDate +

+